You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-09-02 20:46:21 +02:00
Compare commits
54 Commits
server_pop
...
android-v2
Author | SHA1 | Date | |
---|---|---|---|
|
4aa2cc689f | ||
|
6c74a4d3e4 | ||
|
cb69b5192c | ||
|
d0a674957e | ||
|
03e0bd76a9 | ||
|
cc19b2bd18 | ||
|
e09f99aeac | ||
|
11eead1cd5 | ||
|
df9db9c702 | ||
|
388b9b681d | ||
|
5e01f82474 | ||
|
9aed3e04f4 | ||
|
0069069254 | ||
|
773e02bcd0 | ||
|
6f9ba953a6 | ||
|
5bd45abc10 | ||
|
c1a18bac6b | ||
|
e45835ed9a | ||
|
154619cc42 | ||
|
a77462f8ea | ||
|
909776c666 | ||
|
cd8b5388ec | ||
|
4ecc4816e9 | ||
|
edda92c055 | ||
|
03bd77c107 | ||
|
c2bfc526e7 | ||
|
0e6891fd88 | ||
|
d3744b0e6e | ||
|
6b319f4738 | ||
|
39c336a5d8 | ||
|
1d4ea3d99f | ||
|
cc230e51ed | ||
|
3fbdb8307c | ||
|
8d20aa0bb8 | ||
|
63aea35e36 | ||
|
240f9a3ff0 | ||
|
7a4eb7313e | ||
|
90832daa90 | ||
|
1c7d22eda3 | ||
|
c2d3c5baa4 | ||
|
21f9189000 | ||
|
d92032e634 | ||
|
5986710fc0 | ||
|
4d1e0cc21b | ||
|
7b42211581 | ||
|
2fc7bcec06 | ||
|
3e3d01d93e | ||
|
f634a1c731 | ||
|
1fe91b4808 | ||
|
d20c48855c | ||
|
38d310c0ad | ||
|
c06ca87573 | ||
|
d50d940f3c | ||
|
bde74d1f97 |
@@ -242,6 +242,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
|
||||
@@ -499,7 +500,8 @@ packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/checkPermissions.js
|
||||
packages/app-mobile/utils/createRootStyle.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/runOnDeviceTests.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/types.js
|
||||
@@ -533,6 +535,7 @@ packages/editor/CodeMirror/testUtil/createTestEditor.js
|
||||
packages/editor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/editor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/util/isInSyntaxNode.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
packages/editor/types.js
|
||||
@@ -624,12 +627,14 @@ packages/lib/import-enex.js
|
||||
packages/lib/initLib.js
|
||||
packages/lib/locale.test.js
|
||||
packages/lib/locale.js
|
||||
packages/lib/makeDiscourseDebugUrl.js
|
||||
packages/lib/markdownUtils.test.js
|
||||
packages/lib/markdownUtils.js
|
||||
packages/lib/markdownUtils2.test.js
|
||||
packages/lib/markupLanguageUtils.js
|
||||
packages/lib/migrations/42.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
@@ -705,6 +710,10 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/migrations/42.js
|
||||
packages/lib/services/database/migrations/43.js
|
||||
packages/lib/services/database/migrations/44.js
|
||||
packages/lib/services/database/types.js
|
||||
packages/lib/services/debug/populateDatabase.js
|
||||
packages/lib/services/e2ee/EncryptionService.test.js
|
||||
|
11
.gitignore
vendored
11
.gitignore
vendored
@@ -224,6 +224,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
|
||||
@@ -481,7 +482,8 @@ packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/checkPermissions.js
|
||||
packages/app-mobile/utils/createRootStyle.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/runOnDeviceTests.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/types.js
|
||||
@@ -515,6 +517,7 @@ packages/editor/CodeMirror/testUtil/createTestEditor.js
|
||||
packages/editor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/editor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/util/isInSyntaxNode.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
packages/editor/types.js
|
||||
@@ -606,12 +609,14 @@ packages/lib/import-enex.js
|
||||
packages/lib/initLib.js
|
||||
packages/lib/locale.test.js
|
||||
packages/lib/locale.js
|
||||
packages/lib/makeDiscourseDebugUrl.js
|
||||
packages/lib/markdownUtils.test.js
|
||||
packages/lib/markdownUtils.js
|
||||
packages/lib/markdownUtils2.test.js
|
||||
packages/lib/markupLanguageUtils.js
|
||||
packages/lib/migrations/42.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
@@ -687,6 +692,10 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/migrations/42.js
|
||||
packages/lib/services/database/migrations/43.js
|
||||
packages/lib/services/database/migrations/44.js
|
||||
packages/lib/services/database/types.js
|
||||
packages/lib/services/debug/populateDatabase.js
|
||||
packages/lib/services/e2ee/EncryptionService.test.js
|
||||
|
@@ -1,4 +1,25 @@
|
||||
<?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>Wed, 06 Sep 2023 12:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 06 Sep 2023 12:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 2.12]]></title><description><![CDATA[<h1>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h1>
|
||||
<?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, 23 Oct 2023 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><item><title><![CDATA[Working in the shadows with white-hat hackers]]></title><description><![CDATA[<p>The majority of Joplin's development is carried out in the public domain. This includes the discussion of issues on GitHub, as well as the submission of pull requests and related discussions. The transparency of these processes allows for collaborative problem-solving and shared insights.</p>
|
||||
<p>However, there is one aspect that operates behind closed doors, and for good reason: addressing cybersecurity vulnerabilities. It is imperative that these issues remain undisclosed until they have been resolved. Once a solution is implemented, it is usually accompanied by discreet commits and a message in the changelog to signify the progress made.</p>
|
||||
<p>Typically, the process begins with an email from a security researcher. They provide valuable insights, such as a specially crafted note that triggers a bug, or an API call, along with an explanation of how the application's security can be circumvented. We examine the vulnerability, create a fix, and create automated test units to prevent any accidental reintroduction of the vulnerability in future code updates. An example of such a commit is: <a href="https://github.com/laurent22/joplin/commit/9e90d9016daf79b5414646a93fd369aedb035071">9e90d9016daf79b5414646a93fd369aedb035071</a></p>
|
||||
<p>We then share our fix with the researcher for validation. Additionally, we often apply the fix to previous versions of Joplin, depending on the severity of the vulnerability.</p>
|
||||
<p>The contribution of security researchers in this regard is immeasurable. They employ their ingenuity to identify inventive methods of bypassing existing security measures and often discover subtle flaws in the code that might otherwise go unnoticed.</p>
|
||||
<p>We would like to express our sincere gratitude to the security researchers who have assisted us throughout the years in identifying and rectifying security vulnerabilities!</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/a1ise">@Alise</a></li>
|
||||
<li>@hexodotsh</li>
|
||||
<li><a href="https://github.com/ly1g3">@ly1g3</a></li>
|
||||
<li><a href="https://twitter.com/maple3142">@maple3142</a></li>
|
||||
<li>Ademar Nowasky Junior</li>
|
||||
<li><a href="mailto:ben@mayhem.sg">Benjamin Harris</a></li>
|
||||
<li><a href="https://github.com/JavierOlmedo">Javier Olmedo</a></li>
|
||||
<li><a href="https://twitter.com/newfolderj">Jubair Rehman Yousafzai</a></li>
|
||||
<li>lin@UCCU Hacker</li>
|
||||
<li><a href="https://github.com/personalizedrefrigerator">personalizedrefrigerator</a></li>
|
||||
<li><a href="https://twitter.com/fhlipZero">Phil Holbrook</a></li>
|
||||
<li><a href="https://ryotak.net/">RyotaK</a></li>
|
||||
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
|
||||
</ul>
|
||||
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers/</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.12]]></title><description><![CDATA[<h1>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h1>
|
||||
<h2>Support for Apple Silicon<a name="support-for-apple-silicon" href="#support-for-apple-silicon" class="heading-anchor">🔗</a></h2>
|
||||
<p>A new release is now available for Apple Silicon, which provides improve performances on this architecture.</p>
|
||||
<h2>Rich Text editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h2>
|
||||
@@ -9,7 +30,7 @@
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-share-permissions.png" alt=""></p>
|
||||
<h2>Email to Note<a name="email-to-note" href="#email-to-note" class="heading-anchor">🔗</a></h2>
|
||||
<p>Joplin Cloud Pro and Teams also now include the Email to Note feature, allowing you to conveniently store your emails within Joplin Cloud. By simply forwarding your emails to your Joplin Cloud address, you can transform them into notes. The email's subject will serve as the note title, while the body of the email will be the note's content. These notes will be organized within a notebook named "Inbox."</p>
|
||||
<p>More information in the <a href="https://joplinapp.org/email%5C_to%5C_note/">Email to Note documentation</a>.</p>
|
||||
<p>More information in the <a href="https://joplinapp.org/email_to_note/">Email to Note documentation</a>.</p>
|
||||
<h2>Choose to resize an image or not<a name="choose-to-resize-an-image-or-not" href="#choose-to-resize-an-image-or-not" class="heading-anchor">🔗</a></h2>
|
||||
<p>By default, when you add a large image, Joplin will ask you if you would like to shrink it down or not. With this new release, you now have the option to always ask, to always resize, or to never resize the image, giving you more flexibility and reducing the number of prompts in the app.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-resize-note.png" alt=""></p>
|
||||
@@ -319,17 +340,4 @@
|
||||
<p><a href="https://joplinapp.org/changelog_android/">https://joplinapp.org/changelog_android/</a></p>
|
||||
<p><a href="https://joplinapp.org/changelog_ios/">https://joplinapp.org/changelog_ios/</a></p>
|
||||
<p><a href="https://joplinapp.org/changelog_cli/">https://joplinapp.org/changelog_cli/</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20211217-120324/</link><guid isPermaLink="false">20211217-120324</guid><pubDate>Fri, 17 Dec 2021 12:03:24 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Potential breaking change in next Joplin Server update (2.5.10)]]></title><description><![CDATA[<p>Just a head up that the next Joplin Server update could potentially include a breaking change, depending on your data.</p>
|
||||
<p>One of the database migration is going to add an "owner_id" column to the "items" table (where all notes, notebooks, etc. are stored), and automatically populate it. Normally that shouldn't take too long but you might want to make sure you won't need the server right away when you process this.</p>
|
||||
<p>The second database migration will add a unique constraint on items.name and items.owner_id and that's where the breaking change might be. Normally this data is already unique because that's enforced by the application but in some rare cases, due a race condition, there could be duplicate data in there. If that happens the migration will fail and the server will not start.</p>
|
||||
<p>If that happens, you'll need to decide what to do with the data, as it's not possible to automatically decide. You can find all duplicates using this query:</p>
|
||||
<p><code><strong>select</strong> count(<em>), name, owner_id<br>
|
||||
<strong>from</strong> items <strong>group</strong> <strong>by</strong> name, owner_id<br>
|
||||
<strong>having</strong> count(</em>) > 1;</code></p>
|
||||
<p>Once you have the list of IDs you have a few options:</p>
|
||||
<ul>
|
||||
<li>Find the corresponding item in Joplin (it can unfortunately be anything - a note, resource, folder, etc.), then delete it and sync.</li>
|
||||
<li>Or, just delete the data directly in the database. You'll want to delete the corresponding item_id from the user_items table too.</li>
|
||||
</ul>
|
||||
<p>But really in most cases you should be fine. Especially if you don't have that many notes it's unlikely you have duplicates.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20211102-150403/</link><guid isPermaLink="false">20211102-150403</guid><pubDate>Tue, 02 Nov 2021 15:04:03 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20211217-120324/</link><guid isPermaLink="false">20211217-120324</guid><pubDate>Fri, 17 Dec 2021 12:03:24 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
@@ -22,11 +22,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
|
||||
|
||||
Operating System | Download
|
||||
---|---
|
||||
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-Setup-2.12.18.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-Setup-2.12.19.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-2.12.19.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-2.12.19.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
|
||||
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.18/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.19/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
|
||||
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:
|
||||
|
||||
|
1
packages/app-cli/tests/support/sample.txt
Normal file
1
packages/app-cli/tests/support/sample.txt
Normal file
@@ -0,0 +1 @@
|
||||
just testing
|
1
packages/app-cli/tests/support/sample2.txt
Normal file
1
packages/app-cli/tests/support/sample2.txt
Normal file
@@ -0,0 +1 @@
|
||||
just testing 2
|
@@ -4,6 +4,7 @@ import shim from '@joplin/lib/shim';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
|
||||
import { BrowserWindow, Tray, screen } from 'electron';
|
||||
import bridge from './bridge';
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
const { dirname } = require('@joplin/lib/path-utils');
|
||||
@@ -142,6 +143,15 @@ export default class ElectronAppWrapper {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
||||
this.win_.webContents.on('will-frame-navigate', event => {
|
||||
// If the link changes the URL of the browser window,
|
||||
if (event.isMainFrame) {
|
||||
event.preventDefault();
|
||||
void bridge().openExternal(event.url);
|
||||
}
|
||||
});
|
||||
|
||||
this.win_.on('close', (event: any) => {
|
||||
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
|
||||
// otherwise the window is simply hidden, and will be re-open once the app is "activated" (which happens when the
|
||||
|
@@ -184,11 +184,16 @@ export class Bridge {
|
||||
return dialog.showMessageBoxSync(window, options);
|
||||
}
|
||||
|
||||
public showErrorMessageBox(message: string) {
|
||||
public showErrorMessageBox(message: string, options: any = null) {
|
||||
options = {
|
||||
buttons: [_('OK')],
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.showMessageBox_(this.window(), {
|
||||
type: 'error',
|
||||
message: message,
|
||||
buttons: [_('OK')],
|
||||
buttons: options.buttons,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -740,7 +740,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
editor: () => {
|
||||
let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
|
||||
|
||||
if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) {
|
||||
if (this.props.isSafeMode) {
|
||||
bodyEditor = 'PlainText';
|
||||
} else if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) {
|
||||
bodyEditor = 'CodeMirror6';
|
||||
}
|
||||
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
|
||||
|
@@ -9,6 +9,7 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import versionInfo from '@joplin/lib/versionInfo';
|
||||
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
import InteropServiceHelper from '../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
@@ -316,14 +317,30 @@ function useMenu(props: Props) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
bridge().showErrorMessageBox('There was some errors importing the notes. Please check the console for more details.');
|
||||
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
|
||||
if (errors.length) {
|
||||
const response = bridge().showErrorMessageBox('There was some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
|
||||
buttons: [_('Close'), _('Send bug report')],
|
||||
});
|
||||
|
||||
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
|
||||
|
||||
if (response === 1) {
|
||||
const url = makeDiscourseDebugUrl(
|
||||
`Error importing notes from format: ${module.format}`,
|
||||
`- Input format: ${module.format}\n- Output format: ${module.outputFormat}`,
|
||||
errors,
|
||||
packageInfo,
|
||||
PluginService.instance(),
|
||||
props.pluginSettings,
|
||||
);
|
||||
|
||||
void bridge().openExternal(url);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.selectedFolderId]);
|
||||
}, [props.selectedFolderId, props.pluginSettings]);
|
||||
|
||||
const onMenuItemClickRef = useRef(null);
|
||||
onMenuItemClickRef.current = onMenuItemClick;
|
||||
@@ -817,6 +834,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.setTags,
|
||||
menuItemDic.showShareNoteDialog,
|
||||
separator(),
|
||||
menuItemDic.showNoteProperties,
|
||||
menuItemDic.showNoteContentProperties,
|
||||
],
|
||||
},
|
||||
|
@@ -0,0 +1,56 @@
|
||||
|
||||
// Used in safe mode
|
||||
|
||||
import * as React from 'react';
|
||||
import { ForwardedRef } from 'react';
|
||||
import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { NoteBodyEditorProps, NoteBodyEditorRef } from '../../utils/types';
|
||||
|
||||
const PlainEditor = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
|
||||
const editorRef = useRef<HTMLTextAreaElement>();
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => editorRef.current?.value ?? '',
|
||||
resetScroll: () => {
|
||||
editorRef.current.scrollTop = 0;
|
||||
},
|
||||
scrollTo: () => {
|
||||
// Not supported
|
||||
},
|
||||
|
||||
supportsCommand: _name => {
|
||||
return false;
|
||||
},
|
||||
execCommand: async _command => {
|
||||
// Not supported
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
if (editorRef.current.value !== props.content) {
|
||||
editorRef.current.value = props.content;
|
||||
}
|
||||
}, [props.content]);
|
||||
|
||||
const onChange = useCallback((event: any) => {
|
||||
props.onChange({ changeId: null, content: event.target.value });
|
||||
}, [props.onChange]);
|
||||
|
||||
return (
|
||||
<div style={props.style}>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
defaultValue={props.content}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(PlainEditor);
|
||||
|
@@ -1,50 +0,0 @@
|
||||
// Kept only for reference
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
export interface OnChangeEvent {
|
||||
changeId: number,
|
||||
content: any,
|
||||
}
|
||||
|
||||
interface PlainEditorProps {
|
||||
style: any,
|
||||
onChange(event: OnChangeEvent): void,
|
||||
onWillChange(event:any): void,
|
||||
markupToHtml: Function,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
const PlainEditor = (props:PlainEditorProps, ref:any) => {
|
||||
const editorRef = useRef<any>();
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => '',
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.value = props.defaultEditorState.value;
|
||||
}, [props.defaultEditorState]);
|
||||
|
||||
const onChange = useCallback((event:any) => {
|
||||
props.onChange({ changeId: null, content: event.target.value });
|
||||
}, [props.onWillChange, props.onChange]);
|
||||
|
||||
return (
|
||||
<div style={props.style}>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
defaultValue={props.defaultEditorState.value}
|
||||
onChange={onChange}
|
||||
/>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(PlainEditor);
|
||||
|
@@ -14,7 +14,7 @@ import useFormNote, { OnLoadEvent } from './utils/useFormNote';
|
||||
import useEffectiveNoteId from './utils/useEffectiveNoteId';
|
||||
import useFolder from './utils/useFolder';
|
||||
import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps, AllAssetsOptions } from './utils/types';
|
||||
import { NoteEditorProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps, AllAssetsOptions, NoteBodyEditorRef } from './utils/types';
|
||||
import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import ToolbarButton from '../ToolbarButton/ToolbarButton';
|
||||
@@ -45,6 +45,7 @@ import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ErrorCode } from '@joplin/lib/errors';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import PlainEditor from './NoteBody/PlainEditor/PlainEditor';
|
||||
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
|
||||
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
|
||||
|
||||
@@ -60,7 +61,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
|
||||
const editorRef = useRef<any>();
|
||||
const editorRef = useRef<NoteBodyEditorRef>();
|
||||
const titleInputRef = useRef<any>();
|
||||
const isMountedRef = useRef(true);
|
||||
const noteSearchBarRef = useRef(null);
|
||||
@@ -390,8 +391,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
noteId: formNoteRef.current.id,
|
||||
percent: event.percent,
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.dispatch, formNote]);
|
||||
}, [props.dispatch]);
|
||||
|
||||
function renderNoNotes(rootStyle: any) {
|
||||
const emptyDivStyle = {
|
||||
@@ -462,6 +462,8 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
|
||||
if (props.bodyEditor === 'TinyMCE') {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'PlainText') {
|
||||
editor = <PlainEditor {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'CodeMirror') {
|
||||
editor = <CodeMirror5 {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'CodeMirror6') {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FormNote, ScrollOptionTypes } from './types';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { FormNote, NoteBodyEditorRef, ScrollOptionTypes } from './types';
|
||||
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
|
||||
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import time from '@joplin/lib/time';
|
||||
@@ -12,6 +12,8 @@ const commandsWithDependencies = [
|
||||
require('../commands/pasteAsText'),
|
||||
];
|
||||
|
||||
type SetFormNoteCallback = (callback: (prev: FormNote)=> FormNote)=> void;
|
||||
|
||||
interface HookDependencies {
|
||||
formNote: FormNote;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
@@ -19,16 +21,18 @@ interface HookDependencies {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
dispatch: Function;
|
||||
noteSearchBarRef: any;
|
||||
editorRef: any;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
titleInputRef: any;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
saveNoteAndWait: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
setFormNote: Function;
|
||||
setFormNote: SetFormNoteCallback;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, setFormNote: Function): CommandRuntime {
|
||||
function editorCommandRuntime(
|
||||
declaration: CommandDeclaration,
|
||||
editorRef: RefObject<NoteBodyEditorRef>,
|
||||
setFormNote: SetFormNoteCallback,
|
||||
): CommandRuntime {
|
||||
return {
|
||||
execute: async (_context: CommandContext, ...args: any[]) => {
|
||||
if (!editorRef.current) {
|
||||
|
@@ -67,5 +67,6 @@ export default function() {
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
'pasteAsText',
|
||||
'showNoteProperties',
|
||||
];
|
||||
}
|
||||
|
@@ -76,4 +76,49 @@ test.describe('main', () => {
|
||||
|
||||
await expect(mainScreen.noteListContainer.locator('[title^="Toggle sort order"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
await mainScreen.waitFor();
|
||||
|
||||
// Mock openExternal
|
||||
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
|
||||
return new Promise<string>(resolve => {
|
||||
const openExternal = async (url: string) => {
|
||||
resolve(url);
|
||||
};
|
||||
shell.openExternal = openExternal;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a test link
|
||||
const testLinkTitle = 'This is a test link!';
|
||||
const linkHref = 'https://joplinapp.org/';
|
||||
|
||||
await mainWindow.evaluate(({ testLinkTitle, linkHref }) => {
|
||||
const testLink = document.createElement('a');
|
||||
testLink.textContent = testLinkTitle;
|
||||
testLink.onclick = () => {
|
||||
// We need to navigate by setting location.href -- clicking on a link
|
||||
// directly within the main window (i.e. not in a PDF viewer) doesn't
|
||||
// navigate.
|
||||
location.href = linkHref;
|
||||
};
|
||||
testLink.href = '#';
|
||||
|
||||
// Display on top of everything
|
||||
testLink.style.zIndex = '99999';
|
||||
testLink.style.position = 'fixed';
|
||||
testLink.style.top = '0';
|
||||
testLink.style.left = '0';
|
||||
|
||||
document.body.appendChild(testLink);
|
||||
}, { testLinkTitle, linkHref });
|
||||
|
||||
const testLink = mainWindow.getByText(testLinkTitle);
|
||||
await expect(testLink).toBeVisible();
|
||||
await testLink.click({ noWaitAfter: true });
|
||||
|
||||
expect(await nextExternalUrlPromise).toBe(linkHref);
|
||||
});
|
||||
});
|
||||
|
@@ -31,12 +31,6 @@ const React = require('react');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const initLib = require('@joplin/lib/initLib').default;
|
||||
|
||||
// Security: If we attempt to navigate away from the root HTML page, it's likely because
|
||||
// of an improperly sanitized link. Prevent this by closing the window before we can
|
||||
// navigate away.
|
||||
window.onbeforeunload = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
if (bridge().env() === 'dev') {
|
||||
const newConsole = function(oldConsole) {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.13.2",
|
||||
"version": "2.13.3",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -121,17 +121,17 @@
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.19",
|
||||
"@types/react": "18.2.23",
|
||||
"@types/react-redux": "7.1.26",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-redux": "7.1.27",
|
||||
"@types/styled-components": "5.1.28",
|
||||
"electron": "25.8.1",
|
||||
"electron": "25.9.0",
|
||||
"electron-builder": "24.4.0",
|
||||
"glob": "10.3.10",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.6.4",
|
||||
"jest-environment-jsdom": "29.6.4",
|
||||
"js-sha512": "0.8.0",
|
||||
"nan": "2.17.0",
|
||||
"nan": "2.18.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
@@ -149,7 +149,7 @@
|
||||
"@joplin/lib": "~2.13",
|
||||
"@joplin/renderer": "~2.13",
|
||||
"@joplin/utils": "~2.13",
|
||||
"@types/mustache": "4.2.2",
|
||||
"@types/mustache": "4.2.3",
|
||||
"async-mutex": "0.4.0",
|
||||
"codemirror": "5.65.9",
|
||||
"color": "3.2.1",
|
||||
@@ -174,8 +174,8 @@
|
||||
"react": "18.2.0",
|
||||
"react-datetime": "3.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "8.1.2",
|
||||
"react-select": "5.7.5",
|
||||
"react-redux": "8.1.3",
|
||||
"react-select": "5.7.7",
|
||||
"react-toggle-button": "2.2.0",
|
||||
"react-tooltip": "4.5.1",
|
||||
"redux": "4.2.1",
|
||||
|
@@ -12,8 +12,9 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
|
||||
// UI elements
|
||||
markdownEditorVisible: !!state.settings['editor.codeView'],
|
||||
richTextEditorVisible: !state.settings['editor.codeView'],
|
||||
markdownEditorVisible: !!state.settings['editor.codeView'] && !state.settings['isSafeMode'],
|
||||
richTextEditorVisible: !state.settings['editor.codeView'] && !state.settings['isSafeMode'],
|
||||
|
||||
markdownEditorPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('editor'),
|
||||
markdownViewerPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('viewer'),
|
||||
modalDialogVisible: !!Object.keys(state.visibleDialogs).length,
|
||||
|
@@ -110,8 +110,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097722
|
||||
versionName "2.13.2"
|
||||
versionCode 2097724
|
||||
versionName "2.13.4"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
@@ -517,13 +517,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.13.0;
|
||||
MARKETING_VERSION = 12.13.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -546,12 +546,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.13.0;
|
||||
MARKETING_VERSION = 12.13.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -698,14 +698,14 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.13.0;
|
||||
MARKETING_VERSION = 12.13.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
@@ -729,14 +729,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.13.0;
|
||||
MARKETING_VERSION = 12.13.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@@ -284,11 +284,11 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-fingerprint-scanner (6.0.0):
|
||||
- React
|
||||
- react-native-geolocation (3.0.6):
|
||||
- react-native-geolocation (3.1.0):
|
||||
- React-Core
|
||||
- react-native-get-random-values (1.9.0):
|
||||
- React-Core
|
||||
- react-native-image-picker (5.6.1):
|
||||
- react-native-image-picker (5.7.0):
|
||||
- React-Core
|
||||
- react-native-image-resizer (3.0.7):
|
||||
- React-Core
|
||||
@@ -306,7 +306,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-version-info (1.1.1):
|
||||
- React-Core
|
||||
- react-native-webview (13.5.1):
|
||||
- react-native-webview (13.6.0):
|
||||
- React-Core
|
||||
- React-perflogger (0.71.10)
|
||||
- React-RCTActionSheet (0.71.10):
|
||||
@@ -418,11 +418,11 @@ PODS:
|
||||
- React-Core
|
||||
- RNVectorIcons (10.0.0):
|
||||
- React-Core
|
||||
- RNZipArchive (6.0.9):
|
||||
- RNZipArchive (6.1.0):
|
||||
- React-Core
|
||||
- RNZipArchive/Core (= 6.0.9)
|
||||
- RNZipArchive/Core (= 6.1.0)
|
||||
- SSZipArchive (~> 2.2)
|
||||
- RNZipArchive/Core (6.0.9):
|
||||
- RNZipArchive/Core (6.1.0):
|
||||
- React-Core
|
||||
- SSZipArchive (~> 2.2)
|
||||
- SSZipArchive (2.4.3)
|
||||
@@ -667,9 +667,9 @@ SPEC CHECKSUMS:
|
||||
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
|
||||
react-native-document-picker: 2b8f18667caee73a96708a82b284a4f40b30a156
|
||||
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
|
||||
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
|
||||
react-native-geolocation: ef66fb798d96284c6043f0b16c15d9d1d4955db4
|
||||
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
|
||||
react-native-image-picker: 5fcac5a5ffcb3737837f0617d43fd767249290de
|
||||
react-native-image-picker: 3269f75c251cdcd61ab51b911dd30d6fff8c6169
|
||||
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
|
||||
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
|
||||
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
|
||||
@@ -678,7 +678,7 @@ SPEC CHECKSUMS:
|
||||
react-native-slider: 1cdd6ba29675df21f30544253bf7351d3c2d68c4
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
|
||||
react-native-webview: 8baa0f5c6d336d6ba488e942bcadea5bf51f050a
|
||||
react-native-webview: 669ae162965f629a8d6a4bdd3b99a304d36ef1f2
|
||||
React-perflogger: 217095464d5c4bb70df0742fa86bf2a363693468
|
||||
React-RCTActionSheet: 8deae9b85a4cbc6a2243618ea62a374880a2c614
|
||||
React-RCTAnimation: 59c62353a8b59ce206044786c5d30e4754bffa64
|
||||
@@ -705,7 +705,7 @@ SPEC CHECKSUMS:
|
||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||
RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6
|
||||
RNVectorIcons: 8b5bb0fa61d54cd2020af4f24a51841ce365c7e9
|
||||
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
|
||||
RNZipArchive: ef9451b849c45a29509bf44e65b788829ab07801
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
Yoga: e7ea9e590e27460d28911403b894722354d73479
|
||||
|
||||
|
@@ -27,7 +27,7 @@
|
||||
"@joplin/utils": "~2.13",
|
||||
"@react-native-community/clipboard": "1.5.1",
|
||||
"@react-native-community/datetimepicker": "7.5.0",
|
||||
"@react-native-community/geolocation": "3.0.6",
|
||||
"@react-native-community/geolocation": "3.1.0",
|
||||
"@react-native-community/netinfo": "9.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
"@react-native-community/slider": "4.4.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"react-native-device-info": "10.9.0",
|
||||
"react-native-dialogbox": "0.6.10",
|
||||
"react-native-document-picker": "9.0.1",
|
||||
"react-native-dropdownalert": "4.5.1",
|
||||
"react-native-dropdownalert": "5.1.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fingerprint-scanner": "6.0.0",
|
||||
@@ -71,9 +71,9 @@
|
||||
"react-native-vector-icons": "10.0.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-vosk": "0.1.12",
|
||||
"react-native-webview": "13.5.1",
|
||||
"react-native-webview": "13.6.0",
|
||||
"react-native-zip-archive": "6.1.0",
|
||||
"react-redux": "8.1.2",
|
||||
"react-redux": "8.1.3",
|
||||
"redux": "4.2.1",
|
||||
"rn-fetch-blob": "0.12.0",
|
||||
"stream": "0.0.2",
|
||||
@@ -95,9 +95,9 @@
|
||||
"@tsconfig/react-native": "2.0.2",
|
||||
"@types/fs-extra": "11.0.2",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/react": "18.2.23",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-native": "0.70.6",
|
||||
"@types/react-redux": "7.1.26",
|
||||
"@types/react-redux": "7.1.27",
|
||||
"@types/tar-stream": "2.2.3",
|
||||
"babel-jest": "29.6.4",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
|
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"3b957b10f317a91aaea146b3445709dd", files: {
|
||||
hash:"7d3976ee03fc0f6880dd54c78d1a325b", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
File diff suppressed because one or more lines are too long
@@ -97,7 +97,7 @@ SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||
|
||||
import FsDriverRN from './utils/fs-driver-rn';
|
||||
import FsDriverRN from './utils/fs-driver/fs-driver-rn';
|
||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import MigrationService from '@joplin/lib/services/MigrationService';
|
||||
@@ -109,7 +109,7 @@ import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/s
|
||||
import SyncTargetNone from '@joplin/lib/SyncTargetNone';
|
||||
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
|
||||
import RSA from './services/e2ee/RSA.react-native';
|
||||
import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
|
||||
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
|
||||
import { Theme, ThemeAppearance } from '@joplin/lib/themes/type';
|
||||
import { AppState } from './utils/types';
|
||||
import ProfileSwitcher from './components/ProfileSwitcher/ProfileSwitcher';
|
||||
@@ -121,6 +121,7 @@ import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetche
|
||||
import { ReactNode } from 'react';
|
||||
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
||||
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
|
||||
import runOnDeviceFsDriverTests from './utils/fs-driver/runOnDeviceTests';
|
||||
|
||||
type SideMenuPosition = 'left' | 'right';
|
||||
|
||||
@@ -749,7 +750,10 @@ async function initialize(dispatch: Function) {
|
||||
// call will throw an error, alerting us of the issue. Otherwise it will
|
||||
// just print some messages in the console.
|
||||
// ----------------------------------------------------------------------------
|
||||
if (Setting.value('env') === 'dev') await runIntegrationTests();
|
||||
if (Setting.value('env') === 'dev') {
|
||||
await runRsaIntegrationTests();
|
||||
await runOnDeviceFsDriverTests();
|
||||
}
|
||||
|
||||
reg.logger().info('Application initialized');
|
||||
}
|
||||
@@ -759,6 +763,7 @@ class AppComponent extends React.Component {
|
||||
private urlOpenListener_: EmitterSubscription|null = null;
|
||||
private appStateChangeListener_: NativeEventSubscription|null = null;
|
||||
private themeChangeListener_: NativeEventSubscription|null = null;
|
||||
private dropdownAlert_ = (_data: any) => new Promise<any>(res => res);
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
@@ -891,7 +896,11 @@ class AppComponent extends React.Component {
|
||||
AlarmService.setInAppNotificationHandler(async (alarmId: string) => {
|
||||
const alarm = await Alarm.load(alarmId);
|
||||
const notification = await Alarm.makeNotification(alarm);
|
||||
this.dropdownAlert_.alertWithType('info', notification.title, notification.body ? notification.body : '');
|
||||
void this.dropdownAlert_({
|
||||
type: 'info',
|
||||
title: notification.title,
|
||||
message: notification.body ? notification.body : '',
|
||||
});
|
||||
});
|
||||
|
||||
this.appStateChangeListener_ = RNAppState.addEventListener('change', this.onAppStateChange_);
|
||||
@@ -1082,7 +1091,7 @@ class AppComponent extends React.Component {
|
||||
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
|
||||
{ shouldShowMainContent && <AppNav screens={appNavInit} dispatch={this.props.dispatch} /> }
|
||||
</View>
|
||||
<DropdownAlert ref={(ref: any) => this.dropdownAlert_ = ref} tapToCloseEnabled={true} />
|
||||
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
|
||||
{ !shouldShowMainContent && <BiometricPopup
|
||||
dispatch={this.props.dispatch}
|
||||
themeId={this.props.themeId}
|
||||
|
@@ -3,7 +3,7 @@ const RNFetchBlob = require('rn-fetch-blob').default;
|
||||
import * as RNFS from 'react-native-fs';
|
||||
const DocumentPicker = require('react-native-document-picker').default;
|
||||
import { openDocument } from '@joplin/react-native-saf-x';
|
||||
import RNSAF, { Encoding, DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
|
||||
import RNSAF, { DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
|
||||
import { Platform } from 'react-native';
|
||||
import * as tar from 'tar-stream';
|
||||
import { resolve } from 'path';
|
||||
@@ -18,24 +18,63 @@ function isScopedUri(path: string) {
|
||||
return path.includes(ANDROID_URI_PREFIX);
|
||||
}
|
||||
|
||||
// Encodings supported by rn-fetch-blob, RNSAF, and
|
||||
// RNFS.
|
||||
// See also
|
||||
// - https://github.com/itinance/react-native-fs#readfilefilepath-string-encoding-string-promisestring
|
||||
// - https://github.com/joltup/rn-fetch-blob/blob/cf9e8843599de92031df2660d5a1da18491fa3c0/android/src/main/java/com/RNFetchBlob/RNFetchBlobFS.java#L1049
|
||||
export enum SupportedEncoding {
|
||||
Utf8 = 'utf8',
|
||||
Ascii = 'ascii',
|
||||
Base64 = 'base64',
|
||||
}
|
||||
const supportedEncodings = Object.values<string>(SupportedEncoding);
|
||||
|
||||
// Converts some encodings specifiers that work with NodeJS into encodings
|
||||
// that work with RNSAF, RNFetchBlob.fs, and RNFS.
|
||||
//
|
||||
// Throws if an encoding can't be normalized.
|
||||
const normalizeEncoding = (encoding: string): SupportedEncoding => {
|
||||
encoding = encoding.toLowerCase();
|
||||
|
||||
// rn-fetch-blob and RNSAF require the exact string "utf8", but NodeJS (and thus
|
||||
// fs-driver-node) support variants on this like "UtF-8" and "utf-8". Convert them:
|
||||
if (encoding === 'utf-8') {
|
||||
encoding = 'utf8';
|
||||
}
|
||||
|
||||
if (!supportedEncodings.includes(encoding)) {
|
||||
throw new Error(`Unsupported encoding: ${encoding}.`);
|
||||
}
|
||||
|
||||
return encoding as SupportedEncoding;
|
||||
};
|
||||
|
||||
export default class FsDriverRN extends FsDriverBase {
|
||||
public appendFileSync() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
// Encoding can be either "utf8" or "base64"
|
||||
public appendFile(path: string, content: any, encoding = 'base64') {
|
||||
// Requires that the file already exists.
|
||||
// TODO: Update for compatibility with fs-driver-node's appendFile (which does not
|
||||
// require that the file exists).
|
||||
public appendFile(path: string, content: any, rawEncoding = 'base64') {
|
||||
const encoding = normalizeEncoding(rawEncoding);
|
||||
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.writeFile(path, content, { encoding: encoding as Encoding, append: true });
|
||||
return RNSAF.writeFile(path, content, { encoding, append: true });
|
||||
}
|
||||
return RNFS.appendFile(path, content, encoding);
|
||||
}
|
||||
|
||||
// Encoding can be either "utf8" or "base64"
|
||||
public writeFile(path: string, content: any, encoding = 'base64') {
|
||||
// Encoding can be either "utf8", "utf-8", or "base64"
|
||||
public writeFile(path: string, content: any, rawEncoding = 'base64') {
|
||||
const encoding = normalizeEncoding(rawEncoding);
|
||||
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.writeFile(path, content, { encoding: encoding as Encoding });
|
||||
return RNSAF.writeFile(path, content, { encoding: encoding });
|
||||
}
|
||||
|
||||
// We need to use rn-fetch-blob here due to this bug:
|
||||
// https://github.com/itinance/react-native-fs/issues/700
|
||||
return RNFetchBlob.fs.writeFile(path, content, encoding);
|
||||
@@ -195,10 +234,11 @@ export default class FsDriverRN extends FsDriverBase {
|
||||
return null;
|
||||
}
|
||||
|
||||
public readFile(path: string, encoding = 'utf8') {
|
||||
if (encoding === 'Buffer') throw new Error('Raw buffer output not supported for FsDriverRN.readFile');
|
||||
public readFile(path: string, rawEncoding = 'utf8') {
|
||||
const encoding = normalizeEncoding(rawEncoding);
|
||||
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.readFile(path, { encoding: encoding as Encoding });
|
||||
return RNSAF.readFile(path, { encoding: encoding });
|
||||
}
|
||||
return RNFS.readFile(path, encoding);
|
||||
}
|
||||
@@ -244,7 +284,9 @@ export default class FsDriverRN extends FsDriverBase {
|
||||
}
|
||||
}
|
||||
|
||||
public async readFileChunk(handle: any, length: number, encoding = 'base64') {
|
||||
public async readFileChunk(handle: any, length: number, rawEncoding = 'base64') {
|
||||
const encoding = normalizeEncoding(rawEncoding);
|
||||
|
||||
if (handle.offset + length > handle.stat.size) {
|
||||
length = handle.stat.size - handle.offset;
|
||||
}
|
249
packages/app-mobile/utils/fs-driver/runOnDeviceTests.ts
Normal file
249
packages/app-mobile/utils/fs-driver/runOnDeviceTests.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import { join } from 'path';
|
||||
import FsDriverBase from '@joplin/lib/fs-driver-base';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
const logger = Logger.create('fs-driver-tests');
|
||||
|
||||
const expectToBe = async <T> (actual: T, expected: T) => {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Integration test failure: ${actual} was expected to be ${expected}`);
|
||||
}
|
||||
};
|
||||
|
||||
const testExpect = async () => {
|
||||
// Verify that expect is working
|
||||
await expectToBe(1, 1);
|
||||
await expectToBe(true, true);
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
await expectToBe('a', 'test');
|
||||
failed = true;
|
||||
} catch (_error) {
|
||||
failed = false;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
throw new Error('expectToBe should throw when given non-equal inputs');
|
||||
}
|
||||
};
|
||||
|
||||
const testAppendFile = async (tempDir: string) => {
|
||||
logger.info('Testing fsDriver.appendFile...');
|
||||
|
||||
const targetFile = join(tempDir, uuid.createNano());
|
||||
|
||||
const fsDriver: FsDriverBase = shim.fsDriver();
|
||||
|
||||
// For fs-driver-rn's appendFile to work, we first need to create the file.
|
||||
// TODO: This is different from the requirements of fs-driver-node.
|
||||
await fsDriver.writeFile(targetFile, '');
|
||||
|
||||
const firstChunk = 'A 𝓊𝓃𝒾𝒸𝓸𝒹𝓮 test\n...';
|
||||
await fsDriver.appendFile(targetFile, firstChunk, 'utf-8');
|
||||
await expectToBe(await fsDriver.readFile(targetFile), firstChunk);
|
||||
|
||||
const secondChunk = '▪️ More unicode ▪️';
|
||||
await fsDriver.appendFile(targetFile, secondChunk, 'utf8');
|
||||
await expectToBe(await fsDriver.readFile(targetFile), firstChunk + secondChunk);
|
||||
|
||||
const thirdChunk = 'ASCII';
|
||||
await fsDriver.appendFile(targetFile, thirdChunk, 'ascii');
|
||||
await expectToBe(await fsDriver.readFile(targetFile), firstChunk + secondChunk + thirdChunk);
|
||||
|
||||
const lastChunk = 'Test...';
|
||||
await fsDriver.appendFile(
|
||||
targetFile, Buffer.from(lastChunk, 'utf8').toString('base64'), 'base64',
|
||||
);
|
||||
await expectToBe(
|
||||
await fsDriver.readFile(targetFile), firstChunk + secondChunk + thirdChunk + lastChunk,
|
||||
);
|
||||
|
||||
// Should throw if given an invalid encoding
|
||||
let didThrow = false;
|
||||
try {
|
||||
await fsDriver.appendFile(targetFile, 'test', 'bad-encoding');
|
||||
} catch (_error) {
|
||||
didThrow = true;
|
||||
}
|
||||
await expectToBe(didThrow, true);
|
||||
};
|
||||
|
||||
const testReadWriteFileUtf8 = async (tempDir: string) => {
|
||||
logger.info('Testing fsDriver.writeFile and fsDriver.readFile with utf-8...');
|
||||
|
||||
const filePath = join(tempDir, uuid.createNano());
|
||||
|
||||
const testStrings = [
|
||||
// ASCII
|
||||
'test',
|
||||
|
||||
// Special characters
|
||||
'𝐴 𝒕𝐞𝑺𝒕',
|
||||
|
||||
// Emojis
|
||||
'✅ Test. 🕳️',
|
||||
];
|
||||
|
||||
const testEncodings = ['utf-8', 'utf8', 'UtF-8'];
|
||||
|
||||
// Use the same file for all tests to test overwriting
|
||||
for (const encoding of testEncodings) {
|
||||
for (const testString of testStrings) {
|
||||
const fsDriver: FsDriverBase = shim.fsDriver();
|
||||
await fsDriver.writeFile(filePath, testString, encoding);
|
||||
|
||||
const fileData = await fsDriver.readFile(filePath, encoding);
|
||||
await expectToBe(fileData, testString);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testReadFileChunkUtf8 = async (tempDir: string) => {
|
||||
logger.info('Testing fsDriver.readFileChunk...');
|
||||
|
||||
const filePath = join(tempDir, `${uuid.createNano()}.txt`);
|
||||
|
||||
const fsDriver: FsDriverBase = shim.fsDriver();
|
||||
|
||||
// 🕳️ is 7 bytes when utf-8 encoded
|
||||
// à,á,â, and ã are each 2 bytes
|
||||
const expectedFileContent = '01234567\nàáâã\n🕳️🕳️🕳️\ntēst...';
|
||||
await fsDriver.writeFile(filePath, expectedFileContent, 'utf8');
|
||||
|
||||
const testEncodings = ['utf-8', 'utf8', 'UtF-8'];
|
||||
|
||||
for (const encoding of testEncodings) {
|
||||
const handle = await fsDriver.open(filePath, 'r');
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 8, encoding), '01234567',
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 1, encoding), '\n',
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 8, encoding), 'àáâã',
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 8, encoding), '\n🕳️',
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 15, encoding), '🕳️🕳️\n',
|
||||
);
|
||||
|
||||
// A 0 length should return null and not advance
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 0, encoding), null,
|
||||
);
|
||||
|
||||
// Reading a different encoding (then switching back to the original)
|
||||
// should be supported
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 3, 'base64'),
|
||||
Buffer.from('tē', 'utf-8').toString('base64'),
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 100, encoding), 'st...',
|
||||
);
|
||||
|
||||
// Should not be able to read past the end
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 10, encoding), null,
|
||||
);
|
||||
|
||||
await expectToBe(
|
||||
await fsDriver.readFileChunk(handle, 1, encoding), null,
|
||||
);
|
||||
|
||||
await fsDriver.close(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
const testTarCreate = async (tempDir: string) => {
|
||||
logger.info('Testing fsDriver.tarCreate...');
|
||||
|
||||
const directoryToPack = join(tempDir, uuid.createNano());
|
||||
|
||||
const fsDriver: FsDriverBase = shim.fsDriver();
|
||||
|
||||
// Add test files to the directory
|
||||
const fileContents: Record<string, string> = {};
|
||||
|
||||
// small utf-8 encoded files
|
||||
for (let i = 0; i < 10; i ++) {
|
||||
const testFilePath = join(directoryToPack, uuid.createNano());
|
||||
|
||||
const fileContent = `✅ Testing... ä ✅ File #${i}`;
|
||||
await fsDriver.writeFile(testFilePath, fileContent, 'utf-8');
|
||||
|
||||
fileContents[testFilePath] = fileContent;
|
||||
}
|
||||
|
||||
// larger utf-8 encoded files
|
||||
for (let i = 0; i < 3; i ++) {
|
||||
const testFilePath = join(directoryToPack, uuid.createNano());
|
||||
|
||||
let fileContent = `✅ Testing... ä ✅ File #${i}`;
|
||||
|
||||
for (let j = 0; j < 8; j ++) {
|
||||
fileContent += fileContent;
|
||||
}
|
||||
|
||||
await fsDriver.writeFile(testFilePath, fileContent, 'utf-8');
|
||||
|
||||
fileContents[testFilePath] = fileContent;
|
||||
}
|
||||
|
||||
// Pack the files
|
||||
const pathsToTar = Object.keys(fileContents);
|
||||
const tarOutputPath = join(tempDir, 'test-tar.tar');
|
||||
await fsDriver.tarCreate({
|
||||
cwd: tempDir,
|
||||
file: tarOutputPath,
|
||||
}, pathsToTar);
|
||||
|
||||
// Read the tar file as utf-8 and search for the written file contents
|
||||
// (which should work).
|
||||
const rawTarData: string = await fsDriver.readFile(tarOutputPath, 'utf8');
|
||||
|
||||
for (const fileContent of Object.values(fileContents)) {
|
||||
await expectToBe(rawTarData.includes(fileContent), true);
|
||||
}
|
||||
};
|
||||
|
||||
// In the past, some fs-driver functionality has worked correctly on some devices and not others.
|
||||
// As such, we need to be able to run some tests on-device.
|
||||
const runOnDeviceTests = async () => {
|
||||
const tempDir = join(Setting.value('tempDir'), uuid.createNano());
|
||||
|
||||
if (await shim.fsDriver().exists(tempDir)) {
|
||||
await shim.fsDriver().remove(tempDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await testExpect();
|
||||
await testAppendFile(tempDir);
|
||||
await testReadWriteFileUtf8(tempDir);
|
||||
await testReadFileChunkUtf8(tempDir);
|
||||
await testTarCreate(tempDir);
|
||||
} catch (error) {
|
||||
const errorMessage = `On-device testing failed with an exception: ${error}.`;
|
||||
|
||||
logger.error(errorMessage, error);
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(tempDir);
|
||||
}
|
||||
};
|
||||
|
||||
export default runOnDeviceTests;
|
@@ -3,7 +3,7 @@ const { GeolocationReact } = require('./geolocation-react.js');
|
||||
const PoorManIntervals = require('@joplin/lib/PoorManIntervals').default;
|
||||
const RNFetchBlob = require('rn-fetch-blob').default;
|
||||
const { generateSecureRandom } = require('react-native-securerandom');
|
||||
const FsDriverRN = require('./fs-driver-rn').default;
|
||||
const FsDriverRN = require('./fs-driver/fs-driver-rn').default;
|
||||
const { Buffer } = require('buffer');
|
||||
const { Linking, Platform } = require('react-native');
|
||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||
|
@@ -20,6 +20,7 @@ import { SearchState, EditorProps, EditorSettings } from '../types';
|
||||
import { EditorEventType, SelectionRangeChangeEvent } from '../events';
|
||||
import {
|
||||
decreaseIndent, increaseIndent,
|
||||
insertOrIncreaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleItalicized, toggleMath,
|
||||
} from './markdown/markdownCommands';
|
||||
@@ -254,7 +255,7 @@ const createEditor = (
|
||||
notifyLinkEditRequest();
|
||||
return true;
|
||||
}),
|
||||
keyCommand('Tab', increaseIndent, true),
|
||||
keyCommand('Tab', insertOrIncreaseIndent, true),
|
||||
keyCommand('Shift-Tab', decreaseIndent, true),
|
||||
|
||||
...standardKeymap, ...historyKeymap, ...searchKeymap,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import {
|
||||
insertOrIncreaseIndent,
|
||||
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
import createTestEditor from '../testUtil/createTestEditor';
|
||||
@@ -238,5 +239,41 @@ describe('markdownCommands', () => {
|
||||
expect(sel.from).toBe('> Testing...> \n> \n'.length);
|
||||
expect(sel.to).toBe(editor.state.doc.length);
|
||||
});
|
||||
|
||||
it('insertOrIncreaseIndent should indent when text is selected', async () => {
|
||||
const initialText = '> Testing...\n> Test.';
|
||||
const editor = await createTestEditor(
|
||||
initialText,
|
||||
EditorSelection.range(0, initialText.length),
|
||||
['Blockquote'],
|
||||
);
|
||||
|
||||
insertOrIncreaseIndent(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe('> \tTesting...\n> \tTest.');
|
||||
});
|
||||
|
||||
it('insertOrIncreaseIndent should insert tabs when selection is empty, in a paragraph', async () => {
|
||||
const initialText = 'This is a test\nof indentation.';
|
||||
const editor = await createTestEditor(
|
||||
initialText,
|
||||
EditorSelection.cursor(initialText.length),
|
||||
[],
|
||||
);
|
||||
|
||||
insertOrIncreaseIndent(editor);
|
||||
|
||||
const finalText = editor.state.doc.toString();
|
||||
|
||||
// Should add tab character at the cursor
|
||||
expect(finalText).toBe('This is a test\nof indentation.\t');
|
||||
|
||||
// Should move the selection after the tab
|
||||
expect(editor.state.selection.ranges).toHaveLength(1);
|
||||
expect(editor.state.selection.main).toMatchObject({
|
||||
from: finalText.length,
|
||||
to: finalText.length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -63,6 +63,35 @@ describe('markdownCommands.toggleList', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not toggle a the full list when the cursor is on a blank line', async () => {
|
||||
const checklistStartText = [
|
||||
'# Test',
|
||||
'',
|
||||
'- [ ] This',
|
||||
'- [ ] is',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const checklistEndText = [
|
||||
'- [ ] a',
|
||||
'- [ ] test',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(
|
||||
`${checklistStartText}\n${checklistEndText}`,
|
||||
|
||||
// Place the cursor on the blank line between the checklist
|
||||
// regions
|
||||
EditorSelection.cursor(unorderedListText.length + 1),
|
||||
['BulletList', 'ATXHeading1'],
|
||||
);
|
||||
|
||||
// Should create a checkbox on the blank line
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${checklistStartText}- [ ] \n${checklistEndText}`,
|
||||
);
|
||||
});
|
||||
|
||||
// it('should correctly replace an unordered list with a checklist', async () => {
|
||||
// const editor = await createEditor(
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
|
||||
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
|
||||
} from './markdownReformatter';
|
||||
import intersectsSyntaxNode from '../util/isInSyntaxNode';
|
||||
|
||||
const startingSpaceRegex = /^(\s*)/;
|
||||
|
||||
@@ -183,8 +184,11 @@ export const toggleList = (listType: ListType): Command => {
|
||||
const origFirstLineIndentation = firstLineIndentation;
|
||||
const origContainerType = containerType;
|
||||
|
||||
// Grow [sel] to the smallest containing list
|
||||
if (sel.empty) {
|
||||
// Grow `sel` to the smallest containing list, unless the
|
||||
// cursor is on an empty line, in which case, the user
|
||||
// probably wants to add a list item (and not select the entire
|
||||
// list).
|
||||
if (sel.empty && fromLine.text.trim() !== '') {
|
||||
sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
|
||||
computeSelectionProps();
|
||||
}
|
||||
@@ -420,6 +424,35 @@ export const increaseIndent: Command = (view: EditorView): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Like `increaseIndent`, but may insert tabs, rather than
|
||||
// indenting, in some instances.
|
||||
export const insertOrIncreaseIndent: Command = (view: EditorView): boolean => {
|
||||
const selection = view.state.selection;
|
||||
const mainSelection = selection.main;
|
||||
if (selection.ranges.length !== 1 || !mainSelection.empty) {
|
||||
return increaseIndent(view);
|
||||
}
|
||||
|
||||
|
||||
if (intersectsSyntaxNode(view.state, mainSelection, 'ListItem')) {
|
||||
return increaseIndent(view);
|
||||
}
|
||||
|
||||
const indentUnit = indentString(view.state, getIndentUnit(view.state));
|
||||
view.dispatch(view.state.changeByRange(selection => {
|
||||
return {
|
||||
// Move the selection to after the inserted text
|
||||
range: EditorSelection.cursor(selection.from + indentUnit.length),
|
||||
changes: {
|
||||
from: selection.from,
|
||||
insert: indentUnit,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const decreaseIndent: Command = (view: EditorView): boolean => {
|
||||
const matchEmpty = true;
|
||||
const changes = toggleSelectedLinesStartWith(
|
||||
|
32
packages/editor/CodeMirror/util/isInSyntaxNode.ts
Normal file
32
packages/editor/CodeMirror/util/isInSyntaxNode.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
interface Range {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const intersectsSyntaxNode = (state: EditorState, range: Range, nodeName: string) => {
|
||||
let foundNode = false;
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
enter: node => {
|
||||
if (node.name === nodeName) {
|
||||
foundNode = true;
|
||||
|
||||
// Skip children
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search children if we haven't found a matching node yet.
|
||||
return !foundNode;
|
||||
},
|
||||
});
|
||||
|
||||
return foundNode;
|
||||
};
|
||||
|
||||
export default intersectsSyntaxNode;
|
||||
|
@@ -18,8 +18,8 @@
|
||||
"@joplin/lib": "~2.13",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/react": "18.2.23",
|
||||
"@types/react-redux": "7.1.26",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-redux": "7.1.27",
|
||||
"@types/styled-components": "5.1.28",
|
||||
"jest": "29.6.3",
|
||||
"jest-environment-jsdom": "29.6.3",
|
||||
|
@@ -16,7 +16,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"standard": "17.1.0",
|
||||
"tap": "16.3.8"
|
||||
"tap": "16.3.9"
|
||||
},
|
||||
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
|
||||
}
|
||||
|
@@ -1,6 +1,11 @@
|
||||
import Resource from './models/Resource';
|
||||
import shim from './shim';
|
||||
import Database, { SqlQuery } from './database';
|
||||
import Database from './database';
|
||||
import migration42 from './services/database/migrations/42';
|
||||
import migration43 from './services/database/migrations/43';
|
||||
import migration44 from './services/database/migrations/44';
|
||||
import { SqlQuery, Migration } from './services/database/types';
|
||||
import addMigrationFile from './services/database/addMigrationFile';
|
||||
|
||||
const { promiseChain } = require('./promise-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
@@ -119,6 +124,12 @@ CREATE TABLE version (
|
||||
INSERT INTO version (version) VALUES (1);
|
||||
`;
|
||||
|
||||
const migrations: Migration[] = [
|
||||
migration42,
|
||||
migration43,
|
||||
migration44,
|
||||
];
|
||||
|
||||
export interface TableField {
|
||||
name: string;
|
||||
type: number;
|
||||
@@ -334,16 +345,11 @@ export default class JoplinDatabase extends Database {
|
||||
});
|
||||
}
|
||||
|
||||
public addMigrationFile(num: number) {
|
||||
const timestamp = Date.now();
|
||||
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
|
||||
}
|
||||
|
||||
public async upgradeDatabase(fromVersion: number) {
|
||||
// INSTRUCTIONS TO UPGRADE THE DATABASE:
|
||||
//
|
||||
// 1. Add the new version number to the existingDatabaseVersions array
|
||||
// 2. Add the upgrade logic to the "switch (targetVersion)" statement below
|
||||
// 1. Add the migration to lib/services/database/migrations.
|
||||
// 2. Import the migration and add it to the `migrations` array above.
|
||||
|
||||
// IMPORTANT:
|
||||
//
|
||||
@@ -354,7 +360,9 @@ export default class JoplinDatabase extends Database {
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43];
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41];
|
||||
|
||||
for (let i = 0; i < migrations.length; i++) existingDatabaseVersions.push(existingDatabaseVersions[existingDatabaseVersions.length - 1] + 1);
|
||||
|
||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||
|
||||
@@ -378,7 +386,7 @@ export default class JoplinDatabase extends Database {
|
||||
const targetVersion = existingDatabaseVersions[currentVersionIndex + 1];
|
||||
this.logger().info(`Converting database to version ${targetVersion}`);
|
||||
|
||||
let queries: any[] = [];
|
||||
let queries: (SqlQuery|string)[] = [];
|
||||
|
||||
if (targetVersion === 1) {
|
||||
queries = this.wrapQueries(this.sqlStringToLines(structureSql));
|
||||
@@ -657,7 +665,7 @@ export default class JoplinDatabase extends Database {
|
||||
queries.push(this.sqlStringToLines(newTableSql)[0]);
|
||||
|
||||
queries.push('ALTER TABLE resources ADD COLUMN `size` INT NOT NULL DEFAULT -1');
|
||||
queries.push(this.addMigrationFile(20));
|
||||
queries.push(addMigrationFile(20));
|
||||
}
|
||||
|
||||
if (targetVersion === 21) {
|
||||
@@ -717,7 +725,7 @@ export default class JoplinDatabase extends Database {
|
||||
}
|
||||
|
||||
if (targetVersion === 27) {
|
||||
queries.push(this.addMigrationFile(27));
|
||||
queries.push(addMigrationFile(27));
|
||||
}
|
||||
|
||||
if (targetVersion === 28) {
|
||||
@@ -772,7 +780,7 @@ export default class JoplinDatabase extends Database {
|
||||
queries.push('ALTER TABLE tags ADD COLUMN parent_id TEXT NOT NULL DEFAULT ""');
|
||||
// Drop the tag note count view, instead compute note count on the fly
|
||||
// queries.push('DROP VIEW tags_with_note_count');
|
||||
// queries.push(this.addMigrationFile(31));
|
||||
// queries.push(addMigrationFile(31));
|
||||
}
|
||||
|
||||
if (targetVersion === 32) {
|
||||
@@ -867,7 +875,7 @@ export default class JoplinDatabase extends Database {
|
||||
CREATE TRIGGER notes_after_insert AFTER INSERT ON notes_normalized BEGIN
|
||||
INSERT INTO notes_fts(docid, ${tableFields}) SELECT rowid, ${tableFields} FROM notes_normalized WHERE new.rowid = notes_normalized.rowid;
|
||||
END;`);
|
||||
queries.push(this.addMigrationFile(33));
|
||||
queries.push(addMigrationFile(33));
|
||||
}
|
||||
|
||||
if (targetVersion === 34) {
|
||||
@@ -878,7 +886,7 @@ export default class JoplinDatabase extends Database {
|
||||
if (targetVersion === 35) {
|
||||
queries.push('ALTER TABLE notes_normalized ADD COLUMN todo_due INT NOT NULL DEFAULT 0');
|
||||
queries.push('CREATE INDEX notes_normalized_todo_due ON notes_normalized (todo_due)');
|
||||
queries.push(this.addMigrationFile(35));
|
||||
queries.push(addMigrationFile(35));
|
||||
}
|
||||
|
||||
if (targetVersion === 36) {
|
||||
@@ -913,15 +921,11 @@ export default class JoplinDatabase extends Database {
|
||||
queries.push('ALTER TABLE `folders` ADD COLUMN icon TEXT NOT NULL DEFAULT ""');
|
||||
}
|
||||
|
||||
if (targetVersion === 42) {
|
||||
queries.push(this.addMigrationFile(42));
|
||||
}
|
||||
|
||||
if (targetVersion === 43) {
|
||||
queries.push('ALTER TABLE `notes` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""');
|
||||
queries.push('ALTER TABLE `tags` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""');
|
||||
queries.push('ALTER TABLE `folders` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""');
|
||||
queries.push('ALTER TABLE `resources` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""');
|
||||
if (targetVersion > 41) {
|
||||
const migration = migrations[targetVersion - 42];
|
||||
if (!migration) throw new Error(`No such migration: ${targetVersion}`);
|
||||
const migrationQueries = migration();
|
||||
queries = queries.concat(migrationQueries);
|
||||
}
|
||||
|
||||
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
||||
|
@@ -48,8 +48,7 @@ describe('RotatingLogs', () => {
|
||||
try {
|
||||
dir = await createTempDir();
|
||||
await createTestLogFile(dir);
|
||||
await msleep(100);
|
||||
const rotatingLogs: RotatingLogs = new RotatingLogs(dir, 1, 100);
|
||||
const rotatingLogs: RotatingLogs = new RotatingLogs(dir, 1, 5000);
|
||||
await rotatingLogs.cleanActiveLogFile();
|
||||
await rotatingLogs.deleteNonActiveLogFiles();
|
||||
const files = await readdir(dir);
|
||||
|
@@ -604,7 +604,7 @@ export default class Synchronizer {
|
||||
} else {
|
||||
// Note: in order to know the real updated_time value, we need to load the content. In theory we could
|
||||
// rely on the file timestamp (in remote.updated_time) but in practice it's not accurate enough and
|
||||
// can lead to conflicts (for example when the file timestamp is slightly ahead of it's real
|
||||
// can lead to conflicts (for example when the file timestamp is slightly ahead of its real
|
||||
// updated_time). updated_time is set and managed by clients so it's always accurate.
|
||||
// Same situation below for updateLocal.
|
||||
//
|
||||
@@ -701,7 +701,15 @@ export default class Synchronizer {
|
||||
logger.warn(`Uploading a large resource (resourceId: ${local.id}, size:${resource.size} bytes) which may tie up the sync process.`);
|
||||
}
|
||||
|
||||
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file', shareId: resource.share_id });
|
||||
// We skip updating the blob if it hasn't
|
||||
// been modified since the last sync. In
|
||||
// that case, it means the resource metadata
|
||||
// (title, filename, etc.) has been changed,
|
||||
// but not the data blob.
|
||||
const syncItem = await BaseItem.syncItem(syncTargetId, resource.id, { fields: ['sync_time', 'force_sync'] });
|
||||
if (!syncItem || syncItem.sync_time < resource.blob_updated_time || syncItem.force_sync) {
|
||||
await this.apiCall('put', remoteContentPath, null, { path: localResourceContentPath, source: 'file', shareId: resource.share_id });
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCannotSyncError(error)) {
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||
|
@@ -48,7 +48,7 @@ type ImageObject = {
|
||||
naturalHeight?: number;
|
||||
};
|
||||
|
||||
export function getImageSizes(element: HTMLElement, forceAbsoluteUrls = false) {
|
||||
export function getImageSizes(element: Document, forceAbsoluteUrls = false) {
|
||||
const output: Record<string, ImageObject[]> = {};
|
||||
|
||||
const images = element.getElementsByTagName('img');
|
||||
|
@@ -1,18 +1,10 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import time from './time';
|
||||
import shim from './shim';
|
||||
import { SqlParams, SqlQuery, StringOrSqlQuery } from './services/database/types';
|
||||
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
type SqlParams = any[];
|
||||
|
||||
export interface SqlQuery {
|
||||
sql: string;
|
||||
params?: SqlParams;
|
||||
}
|
||||
|
||||
type StringOrSqlQuery = string | SqlQuery;
|
||||
|
||||
export type Row = Record<string, any>;
|
||||
|
||||
export default class Database {
|
||||
|
@@ -25,6 +25,10 @@ export default class FsDriverBase {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public async appendFile(_path: string, _content: string, _encoding = 'base64'): Promise<any> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public async copy(_source: string, _dest: string) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
25
packages/lib/makeDiscourseDebugUrl.ts
Normal file
25
packages/lib/makeDiscourseDebugUrl.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PluginSettings } from './services/plugins/PluginService';
|
||||
import type PluginService from './services/plugins/PluginService';
|
||||
import versionInfo from './versionInfo';
|
||||
|
||||
const renderErrorBlock = (errors: any[]): string => {
|
||||
if (!errors.length) return '';
|
||||
return `\`\`\`\n${errors.map(e => typeof e === 'string' ? e.trim() : e.message.trim())}\n\`\`\``;
|
||||
};
|
||||
|
||||
export default (title: string, body: string, errors: any[], packageInfo: any, pluginService: PluginService, pluginSettings: PluginSettings) => {
|
||||
const v = versionInfo(packageInfo, pluginService.enabledPlugins(pluginSettings));
|
||||
|
||||
const errorBlock = renderErrorBlock(errors);
|
||||
|
||||
const query: Record<string, string> = {
|
||||
title,
|
||||
body: `# About\n\n${v.body.trim()}\n\n# Body\n\n${body}${errorBlock ? `\n\n# Errors\n\n${errorBlock}` : ''}`,
|
||||
category: 'support',
|
||||
};
|
||||
|
||||
const queryString = Object.keys(query).map(k => `${k}=${encodeURIComponent(query[k])}`).join('&');
|
||||
|
||||
const url = `https://discourse.joplinapp.org/new-topic?${queryString}`;
|
||||
return url;
|
||||
};
|
@@ -1,17 +1,22 @@
|
||||
const { setupDatabaseAndSynchronizer, switchClient } = require('../testing/test-utils.js');
|
||||
const Folder = require('../models/Folder').default;
|
||||
const Note = require('../models/Note').default;
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, syncTargetId, synchronizerStart, msleep } from '../testing/test-utils';
|
||||
import BaseItem from './BaseItem';
|
||||
import Folder from './Folder';
|
||||
import Note from './Note';
|
||||
|
||||
describe('models/BaseItem', () => {
|
||||
describe('BaseItem', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
// This is to handle the case where a property is removed from a BaseItem table - in that case files in
|
||||
// the sync target will still have the old property but we don't need it locally.
|
||||
it('should ignore properties that are present in sync file but not in database when serialising', (async () => {
|
||||
it('should ignore properties that are present in sync file but not in database when serialising', async () => {
|
||||
const folder = await Folder.save({ title: 'folder1' });
|
||||
|
||||
let serialized = await Folder.serialize(folder);
|
||||
@@ -20,9 +25,9 @@ describe('models/BaseItem', () => {
|
||||
const unserialized = await Folder.unserialize(serialized);
|
||||
|
||||
expect('ignore_me' in unserialized).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not modify title when unserializing', (async () => {
|
||||
it('should not modify title when unserializing', async () => {
|
||||
const folder1 = await Folder.save({ title: '' });
|
||||
const folder2 = await Folder.save({ title: 'folder1' });
|
||||
|
||||
@@ -35,9 +40,9 @@ describe('models/BaseItem', () => {
|
||||
const unserialized2 = await Folder.unserialize(serialized2);
|
||||
|
||||
expect(unserialized2.title).toBe(folder2.title);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should correctly unserialize note timestamps', (async () => {
|
||||
it('should correctly unserialize note timestamps', async () => {
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', parent_id: folder.id });
|
||||
|
||||
@@ -48,9 +53,9 @@ describe('models/BaseItem', () => {
|
||||
expect(unserialized.updated_time).toEqual(note.updated_time);
|
||||
expect(unserialized.user_created_time).toEqual(note.user_created_time);
|
||||
expect(unserialized.user_updated_time).toEqual(note.user_updated_time);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should serialize geolocation fields', (async () => {
|
||||
it('should serialize geolocation fields', async () => {
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
let note = await Note.save({ title: 'note', parent_id: folder.id });
|
||||
note = await Note.load(note.id);
|
||||
@@ -76,9 +81,9 @@ describe('models/BaseItem', () => {
|
||||
expect(unserialized.latitude).toEqual(note.latitude);
|
||||
expect(unserialized.longitude).toEqual(note.longitude);
|
||||
expect(unserialized.altitude).toEqual(note.altitude);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should serialize and unserialize notes', (async () => {
|
||||
it('should serialize and unserialize notes', async () => {
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', parent_id: folder.id });
|
||||
await Note.save({
|
||||
@@ -93,9 +98,9 @@ describe('models/BaseItem', () => {
|
||||
const noteAfter = await Note.unserialize(serialized);
|
||||
|
||||
expect(noteAfter).toEqual(noteBefore);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should serialize and unserialize properties that contain new lines', (async () => {
|
||||
it('should serialize and unserialize properties that contain new lines', async () => {
|
||||
const sourceUrl = `
|
||||
https://joplinapp.org/ \\n
|
||||
`;
|
||||
@@ -107,9 +112,9 @@ https://joplinapp.org/ \\n
|
||||
const noteAfter = await Note.unserialize(serialized);
|
||||
|
||||
expect(noteAfter).toEqual(noteBefore);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not serialize the note title and body', (async () => {
|
||||
it('should not serialize the note title and body', async () => {
|
||||
const note = await Note.save({ title: 'my note', body: `one line
|
||||
two line
|
||||
three line \\n no escape` });
|
||||
@@ -121,5 +126,27 @@ three line \\n no escape` });
|
||||
one line
|
||||
two line
|
||||
three line \\n no escape`)).toBe(0);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should update item sync item', async () => {
|
||||
const note1 = await Note.save({ });
|
||||
|
||||
const syncTime = async (itemId: string) => {
|
||||
const syncItem = await BaseItem.syncItem(syncTargetId(), itemId, { fields: ['sync_time'] });
|
||||
return syncItem ? syncItem.sync_time : 0;
|
||||
};
|
||||
|
||||
expect(await syncTime(note1.id)).toBe(0);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
const newTime = await syncTime(note1.id);
|
||||
expect(newTime).toBeLessThanOrEqual(Date.now());
|
||||
|
||||
// Check that it doesn't change if we sync again
|
||||
await msleep(1);
|
||||
await synchronizerStart();
|
||||
expect(await syncTime(note1.id)).toBe(newTime);
|
||||
});
|
||||
|
||||
});
|
@@ -1,5 +1,5 @@
|
||||
import { ModelType, DeleteOptions } from '../BaseModel';
|
||||
import { BaseItemEntity, DeletedItemEntity, NoteEntity } from '../services/database/types';
|
||||
import { BaseItemEntity, DeletedItemEntity, NoteEntity, SyncItemEntity } from '../services/database/types';
|
||||
import Setting from './Setting';
|
||||
import BaseModel from '../BaseModel';
|
||||
import time from '../time';
|
||||
@@ -194,6 +194,14 @@ export default class BaseItem extends BaseModel {
|
||||
return output;
|
||||
}
|
||||
|
||||
public static async syncItem(syncTarget: number, itemId: string, options: LoadOptions = null): Promise<SyncItemEntity> {
|
||||
options = {
|
||||
fields: '*',
|
||||
...options,
|
||||
};
|
||||
return await this.db().selectOne(`SELECT ${this.db().escapeFieldsToString(options.fields)} FROM sync_items WHERE sync_target = ? AND item_id = ?`, [syncTarget, itemId]);
|
||||
}
|
||||
|
||||
public static async allSyncItems(syncTarget: number) {
|
||||
const output = await this.db().selectAll('SELECT * FROM sync_items WHERE sync_target = ?', [syncTarget]);
|
||||
return output;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import BaseModel from '../BaseModel';
|
||||
import { SqlQuery } from '../database';
|
||||
import { SqlQuery } from '../services/database/types';
|
||||
import BaseItem from './BaseItem';
|
||||
|
||||
// - If is_associated = 1, note_resources indicates which note_id is currently associated with the given resource_id
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import { supportDir, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, expectThrow, createTempFile } from '../testing/test-utils';
|
||||
import { supportDir, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, expectThrow, createTempFile, msleep } from '../testing/test-utils';
|
||||
import Folder from '../models/Folder';
|
||||
import Note from '../models/Note';
|
||||
import Resource from '../models/Resource';
|
||||
import shim from '../shim';
|
||||
import { ErrorCode } from '../errors';
|
||||
import { remove, pathExists } from 'fs-extra';
|
||||
import { ResourceEntity } from '../services/database/types';
|
||||
|
||||
const testImagePath = `${supportDir}/photo.jpg`;
|
||||
|
||||
@@ -95,6 +96,39 @@ describe('models/Resource', () => {
|
||||
expect(originalStat.size).toBe(newStat.size);
|
||||
}));
|
||||
|
||||
it('should set the blob_updated_time property if the blob is updated', (async () => {
|
||||
const note = await Note.save({});
|
||||
await shim.attachFileToNote(note, testImagePath);
|
||||
|
||||
const resourceA: ResourceEntity = (await Resource.all())[0];
|
||||
expect(resourceA.updated_time).toBe(resourceA.blob_updated_time);
|
||||
|
||||
await msleep(1);
|
||||
|
||||
await Resource.updateResourceBlobContent(resourceA.id, testImagePath);
|
||||
|
||||
const resourceB: ResourceEntity = (await Resource.all())[0];
|
||||
expect(resourceB.updated_time).toBeGreaterThan(resourceA.updated_time);
|
||||
expect(resourceB.blob_updated_time).toBeGreaterThan(resourceA.blob_updated_time);
|
||||
}));
|
||||
|
||||
it('should NOT set the blob_updated_time property if the blob is NOT updated', (async () => {
|
||||
const note = await Note.save({});
|
||||
await shim.attachFileToNote(note, testImagePath);
|
||||
|
||||
const resourceA: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
await msleep(1);
|
||||
|
||||
// We only update the resource metadata - so the blob timestamp should
|
||||
// not change
|
||||
await Resource.save({ id: resourceA.id, title: 'new title' });
|
||||
|
||||
const resourceB: ResourceEntity = (await Resource.all())[0];
|
||||
expect(resourceB.updated_time).toBeGreaterThan(resourceA.updated_time);
|
||||
expect(resourceB.blob_updated_time).toBe(resourceA.blob_updated_time);
|
||||
}));
|
||||
|
||||
it('should not allow modifying a read-only resource', async () => {
|
||||
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||
await expectThrow(async () => Resource.save({ id: resource.id, share_id: '123456789', title: 'cannot do this!' }), ErrorCode.IsReadOnly);
|
||||
|
@@ -15,6 +15,7 @@ import JoplinError from '../JoplinError';
|
||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
import { SaveOptions } from './utils/types';
|
||||
|
||||
export default class Resource extends BaseItem {
|
||||
|
||||
@@ -372,9 +373,15 @@ export default class Resource extends BaseItem {
|
||||
// We first save the resource metadata because this can throw, for
|
||||
// example if modifying a resource that is read-only
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const result = await Resource.save({
|
||||
id: resource.id,
|
||||
size: fileStat.size,
|
||||
updated_time: now,
|
||||
blob_updated_time: now,
|
||||
}, {
|
||||
autoTimestamp: false,
|
||||
});
|
||||
|
||||
// If the above call has succeeded, we save the data blob
|
||||
@@ -442,4 +449,18 @@ export default class Resource extends BaseItem {
|
||||
}, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
|
||||
public static async save(o: ResourceEntity, options: SaveOptions = null): Promise<ResourceEntity> {
|
||||
const resource = { ...o };
|
||||
|
||||
if (this.isNew(o, options)) {
|
||||
const now = Date.now();
|
||||
options = { ...options, autoTimestamp: false };
|
||||
if (!resource.created_time) resource.created_time = now;
|
||||
if (!resource.updated_time) resource.updated_time = now;
|
||||
if (!resource.blob_updated_time) resource.blob_updated_time = now;
|
||||
}
|
||||
|
||||
return await super.save(resource, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -303,6 +303,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
|
||||
public static autoSaveEnabled = true;
|
||||
public static allowFileStorage = true;
|
||||
|
||||
private static metadata_: SettingItems = null;
|
||||
private static keychainService_: any = null;
|
||||
@@ -2055,7 +2056,7 @@ class Setting extends BaseModel {
|
||||
}
|
||||
|
||||
private static canUseFileStorage(): boolean {
|
||||
return !shim.mobilePlatform();
|
||||
return this.allowFileStorage && !shim.mobilePlatform();
|
||||
}
|
||||
|
||||
private static keyStorage(key: string): SettingStorage {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ModelType } from '../../BaseModel';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import JoplinError from '../../JoplinError';
|
||||
@@ -5,6 +6,8 @@ import { State as ShareState } from '../../services/share/reducer';
|
||||
import ItemChange from '../ItemChange';
|
||||
import Setting from '../Setting';
|
||||
|
||||
const logger = Logger.create('models/utils/readOnly');
|
||||
|
||||
export interface ItemSlice {
|
||||
id?: string;
|
||||
share_id: string;
|
||||
@@ -43,6 +46,18 @@ export const checkIfItemCanBeChanged = (itemType: ModelType, changeSource: numbe
|
||||
export const checkIfItemCanBeAddedToFolder = async (itemType: ModelType, Folder: any, changeSource: number, shareState: ShareState, parentId: string) => {
|
||||
if (needsReadOnlyChecks(itemType, changeSource, shareState) && parentId) {
|
||||
const parentFolder = await Folder.load(parentId, { fields: ['id', 'share_id'] });
|
||||
|
||||
if (!parentFolder) {
|
||||
// Historically it's always been possible to set the parent_id of a
|
||||
// note to a folder that does not exist - this is to support
|
||||
// synchronisation, where items are downloaded in random order. It
|
||||
// is not ideal to skip the check here, but if for some reason the
|
||||
// folder turns out to be read-only the issue will be resolved
|
||||
// during sync.
|
||||
logger.warn('checkIfItemCanBeAddedToFolder: Trying to add an item to a folder that does not exist - skipping check');
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemIsReadOnlySync(itemType, changeSource, parentFolder, Setting.value('sync.userId'), shareState)) {
|
||||
throw new JoplinError('Cannot add an item as a child of a read-only item', ErrorCode.IsReadOnly);
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { SqlQuery } from '../../database';
|
||||
import { SqlQuery } from '../../services/database/types';
|
||||
|
||||
export enum PaginationOrderDir {
|
||||
ASC = 'ASC',
|
||||
|
@@ -21,7 +21,7 @@
|
||||
"@types/js-yaml": "4.0.6",
|
||||
"@types/node": "18.17.19",
|
||||
"@types/node-rsa": "1.1.2",
|
||||
"@types/react": "18.2.23",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/uuid": "9.0.4",
|
||||
"clean-html": "1.5.0",
|
||||
"jest": "29.6.4",
|
||||
|
4
packages/lib/services/database/addMigrationFile.ts
Normal file
4
packages/lib/services/database/addMigrationFile.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default (num: number) => {
|
||||
const timestamp = Date.now();
|
||||
return { sql: 'INSERT INTO migrations (number, created_time, updated_time) VALUES (?, ?, ?)', params: [num, timestamp, timestamp] };
|
||||
};
|
8
packages/lib/services/database/migrations/42.ts
Normal file
8
packages/lib/services/database/migrations/42.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import addMigrationFile from '../addMigrationFile';
|
||||
import { SqlQuery } from '../types';
|
||||
|
||||
export default (): (SqlQuery|string)[] => {
|
||||
return [
|
||||
addMigrationFile(42),
|
||||
];
|
||||
};
|
10
packages/lib/services/database/migrations/43.ts
Normal file
10
packages/lib/services/database/migrations/43.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SqlQuery } from '../types';
|
||||
|
||||
export default (): (SqlQuery|string)[] => {
|
||||
return [
|
||||
'ALTER TABLE `notes` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""',
|
||||
'ALTER TABLE `tags` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""',
|
||||
'ALTER TABLE `folders` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""',
|
||||
'ALTER TABLE `resources` ADD COLUMN `user_data` TEXT NOT NULL DEFAULT ""',
|
||||
];
|
||||
};
|
8
packages/lib/services/database/migrations/44.ts
Normal file
8
packages/lib/services/database/migrations/44.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SqlQuery } from '../types';
|
||||
|
||||
export default (): (SqlQuery|string)[] => {
|
||||
return [
|
||||
'ALTER TABLE `resources` ADD COLUMN blob_updated_time INT NOT NULL DEFAULT 0',
|
||||
'UPDATE `resources` SET blob_updated_time = updated_time',
|
||||
];
|
||||
};
|
@@ -15,6 +15,17 @@ export interface BaseItemEntity {
|
||||
created_time?: number;
|
||||
}
|
||||
|
||||
export type SqlParams = any[];
|
||||
|
||||
export interface SqlQuery {
|
||||
sql: string;
|
||||
params?: SqlParams;
|
||||
}
|
||||
|
||||
export type StringOrSqlQuery = string | SqlQuery;
|
||||
|
||||
export type Migration = () => (SqlQuery|string)[];
|
||||
|
||||
export enum FolderIconType {
|
||||
Emoji = 1,
|
||||
DataUrl = 2,
|
||||
@@ -139,6 +150,7 @@ export interface FolderEntity {
|
||||
'title'?: string;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_data'?: string;
|
||||
'user_updated_time'?: number;
|
||||
'type_'?: number;
|
||||
}
|
||||
@@ -244,6 +256,7 @@ export interface ResourceLocalStateEntity {
|
||||
'type_'?: number;
|
||||
}
|
||||
export interface ResourceEntity {
|
||||
'blob_updated_time'?: number;
|
||||
'created_time'?: number;
|
||||
'encryption_applied'?: number;
|
||||
'encryption_blob_encrypted'?: number;
|
||||
@@ -259,6 +272,7 @@ export interface ResourceEntity {
|
||||
'title'?: string;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_data'?: string;
|
||||
'user_updated_time'?: number;
|
||||
'type_'?: number;
|
||||
}
|
||||
@@ -319,6 +333,7 @@ export interface TagEntity {
|
||||
'title'?: string;
|
||||
'updated_time'?: number;
|
||||
'user_created_time'?: number;
|
||||
'user_data'?: string;
|
||||
'user_updated_time'?: number;
|
||||
'type_'?: number;
|
||||
}
|
||||
@@ -352,6 +367,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
title: { type: 'string' },
|
||||
updated_time: { type: 'number' },
|
||||
user_created_time: { type: 'number' },
|
||||
user_data: { type: 'string' },
|
||||
user_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
@@ -365,6 +381,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
title: { type: 'string' },
|
||||
updated_time: { type: 'number' },
|
||||
user_created_time: { type: 'number' },
|
||||
user_data: { type: 'string' },
|
||||
user_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
@@ -451,6 +468,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
resources: {
|
||||
blob_updated_time: { type: 'number' },
|
||||
created_time: { type: 'number' },
|
||||
encryption_applied: { type: 'number' },
|
||||
encryption_blob_encrypted: { type: 'number' },
|
||||
@@ -466,6 +484,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
title: { type: 'string' },
|
||||
updated_time: { type: 'number' },
|
||||
user_created_time: { type: 'number' },
|
||||
user_data: { type: 'string' },
|
||||
user_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
|
@@ -72,8 +72,8 @@ export default class RepositoryApi {
|
||||
// https://github.com/joplin/plugins
|
||||
// https://api.github.com/repos/joplin/plugins/releases
|
||||
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
|
||||
const defaultContentBaseUrl = `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
||||
this.contentBaseUrl_ = await findWorkingGitHubUrl(defaultContentBaseUrl);
|
||||
const defaultContentBaseUrl = this.isLocalRepo ? this.baseUrl_ : `${this.baseUrl_.replace(/github\.com/, 'raw.githubusercontent.com')}/master`;
|
||||
this.contentBaseUrl_ = this.isLocalRepo ? defaultContentBaseUrl : await findWorkingGitHubUrl(defaultContentBaseUrl);
|
||||
|
||||
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
import { Size } from './types';
|
||||
|
||||
// AUTO-GENERATED by generate-database-type
|
||||
type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
|
||||
type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
|
||||
// AUTO-GENERATED by generate-database-type
|
||||
|
||||
export enum ItemFlow {
|
||||
|
@@ -27,6 +27,7 @@ const { fileExtension, safeFileExtension, safeFilename, filename } = require('..
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
const { ErrorNotFound } = require('../utils/errors');
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
|
||||
const logger = Logger.create('routes/notes');
|
||||
|
||||
@@ -38,7 +39,31 @@ function htmlToMdParser() {
|
||||
return htmlToMdParser_;
|
||||
}
|
||||
|
||||
async function requestNoteToNote(requestNote: any) {
|
||||
type RequestNote = {
|
||||
id?: any;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
altitude?: number;
|
||||
author?: string;
|
||||
source_url?: string;
|
||||
is_todo?: number;
|
||||
todo_due?: number;
|
||||
todo_completed?: number;
|
||||
user_updated_time?: number;
|
||||
user_created_time?: number;
|
||||
markup_language?: number;
|
||||
body_html: string;
|
||||
base_url?: string;
|
||||
convert_to: string;
|
||||
anchor_names?: any[];
|
||||
image_sizes?: object;
|
||||
stylesheets: any;
|
||||
};
|
||||
|
||||
async function requestNoteToNote(requestNote: RequestNote): Promise<NoteEntity> {
|
||||
const output: any = {
|
||||
title: requestNote.title ? requestNote.title : '',
|
||||
body: requestNote.body ? requestNote.body : '',
|
||||
@@ -337,6 +362,34 @@ async function attachImageFromDataUrl(note: any, imageDataUrl: string, cropRect:
|
||||
return await shim.attachFileToNote(note, tempFilePath);
|
||||
}
|
||||
|
||||
export const extractNoteFromHTML = async (requestNote: RequestNote, requestId: number, imageSizes: any) => {
|
||||
const note = await requestNoteToNote(requestNote);
|
||||
|
||||
const mediaUrls = extractMediaUrls(note.markup_language, note.body);
|
||||
|
||||
logger.info(`Request (${requestId}): Downloading media files: ${mediaUrls.length}`);
|
||||
|
||||
const mediaFiles = await downloadMediaFiles(mediaUrls); // , allowFileProtocolImages);
|
||||
|
||||
logger.info(`Request (${requestId}): Creating resources from paths: ${Object.getOwnPropertyNames(mediaFiles).length}`);
|
||||
|
||||
const resources = await createResourcesFromPaths(mediaFiles);
|
||||
await removeTempFiles(resources);
|
||||
note.body = replaceUrlsByResources(note.markup_language, note.body, resources, imageSizes);
|
||||
|
||||
logger.info(`Request (${requestId}): Saving note...`);
|
||||
|
||||
const saveOptions = defaultSaveOptions('POST', note.id);
|
||||
saveOptions.autoTimestamp = false; // No auto-timestamp because user may have provided them
|
||||
const timestamp = Date.now();
|
||||
note.updated_time = timestamp;
|
||||
note.created_time = timestamp;
|
||||
if (!('user_updated_time' in note)) note.user_updated_time = timestamp;
|
||||
if (!('user_created_time' in note)) note.user_created_time = timestamp;
|
||||
|
||||
return { note, saveOptions, resources };
|
||||
};
|
||||
|
||||
export default async function(request: Request, id: string = null, link: string = null) {
|
||||
if (request.method === 'GET') {
|
||||
if (link && link === 'tags') {
|
||||
@@ -368,31 +421,9 @@ export default async function(request: Request, id: string = null, link: string
|
||||
|
||||
logger.info('Images:', imageSizes);
|
||||
|
||||
let note: any = await requestNoteToNote(requestNote);
|
||||
const extracted = await extractNoteFromHTML(requestNote, requestId, imageSizes);
|
||||
|
||||
const mediaUrls = extractMediaUrls(note.markup_language, note.body);
|
||||
|
||||
logger.info(`Request (${requestId}): Downloading media files: ${mediaUrls.length}`);
|
||||
|
||||
let result = await downloadMediaFiles(mediaUrls); // , allowFileProtocolImages);
|
||||
|
||||
logger.info(`Request (${requestId}): Creating resources from paths: ${Object.getOwnPropertyNames(result).length}`);
|
||||
|
||||
result = await createResourcesFromPaths(result);
|
||||
await removeTempFiles(result);
|
||||
note.body = replaceUrlsByResources(note.markup_language, note.body, result, imageSizes);
|
||||
|
||||
logger.info(`Request (${requestId}): Saving note...`);
|
||||
|
||||
const saveOptions = defaultSaveOptions('POST', note.id);
|
||||
saveOptions.autoTimestamp = false; // No auto-timestamp because user may have provided them
|
||||
const timestamp = Date.now();
|
||||
note.updated_time = timestamp;
|
||||
note.created_time = timestamp;
|
||||
if (!('user_updated_time' in note)) note.user_updated_time = timestamp;
|
||||
if (!('user_created_time' in note)) note.user_created_time = timestamp;
|
||||
|
||||
note = await Note.save(note, saveOptions);
|
||||
let note = await Note.save(extracted.note, extracted.saveOptions);
|
||||
|
||||
if (requestNote.tags) {
|
||||
const tagTitles = requestNote.tags.split(',');
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import time from '../../time';
|
||||
import shim from '../../shim';
|
||||
import Setting from '../../models/Setting';
|
||||
import { NoteEntity } from '../../services/database/types';
|
||||
import { NoteEntity, ResourceEntity } from '../../services/database/types';
|
||||
import { remoteNotesFoldersResources, remoteResources } from '../../testing/test-utils-synchronizer';
|
||||
import { synchronizerStart, tempFilePath, resourceFetcher, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, checkThrowAsync } from '../../testing/test-utils';
|
||||
import { synchronizerStart, tempFilePath, resourceFetcher, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, checkThrowAsync, msleep } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import Resource from '../../models/Resource';
|
||||
@@ -27,7 +27,7 @@ describe('Synchronizer.resources', () => {
|
||||
insideBeforeEach = false;
|
||||
});
|
||||
|
||||
it('should sync resources', (async () => {
|
||||
it('should sync resources', async () => {
|
||||
while (insideBeforeEach) await time.msleep(500);
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -58,9 +58,9 @@ describe('Synchronizer.resources', () => {
|
||||
|
||||
const resourcePath1_2 = Resource.fullPath(resource1_2);
|
||||
expect(fileContentEqual(resourcePath1, resourcePath1_2)).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle resource download errors', (async () => {
|
||||
it('should handle resource download errors', async () => {
|
||||
while (insideBeforeEach) await time.msleep(500);
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -87,9 +87,9 @@ describe('Synchronizer.resources', () => {
|
||||
const ls = await Resource.localState(resource1);
|
||||
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_ERROR);
|
||||
expect(ls.fetch_error).toBe('did not work');
|
||||
}));
|
||||
});
|
||||
|
||||
it('should set the resource file size if it is missing', (async () => {
|
||||
it('should set the resource file size if it is missing', async () => {
|
||||
while (insideBeforeEach) await time.msleep(500);
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -110,9 +110,9 @@ describe('Synchronizer.resources', () => {
|
||||
await fetcher.waitForAllFinished();
|
||||
r1 = await Resource.load(r1.id);
|
||||
expect(r1.size).toBe(2720);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should delete resources', (async () => {
|
||||
it('should delete resources', async () => {
|
||||
while (insideBeforeEach) await time.msleep(500);
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -142,9 +142,9 @@ describe('Synchronizer.resources', () => {
|
||||
allResources = await Resource.all();
|
||||
expect(allResources.length).toBe(0);
|
||||
expect(await shim.fsDriver().exists(resourcePath1)).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should encrypt resources', (async () => {
|
||||
it('should encrypt resources', async () => {
|
||||
setEncryptionEnabled(true);
|
||||
const masterKey = await loadEncryptionMasterKey();
|
||||
|
||||
@@ -170,9 +170,9 @@ describe('Synchronizer.resources', () => {
|
||||
const resourcePath1_2 = Resource.fullPath(resource1_2);
|
||||
|
||||
expect(fileContentEqual(resourcePath1, resourcePath1_2)).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should sync resource blob changes', (async () => {
|
||||
it('should sync resource blob changes', async () => {
|
||||
const tempFile = tempFilePath('txt');
|
||||
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
@@ -204,9 +204,9 @@ describe('Synchronizer.resources', () => {
|
||||
const resource1_1 = (await Resource.all())[0];
|
||||
expect(resource1_1.size).toBe(newSize);
|
||||
expect(await Resource.resourceBlobContent(resource1_1.id, 'utf8')).toBe('1234 MOD');
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle resource conflicts', (async () => {
|
||||
it('should handle resource conflicts', async () => {
|
||||
{
|
||||
const tempFile = tempFilePath('txt');
|
||||
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
||||
@@ -271,9 +271,9 @@ describe('Synchronizer.resources', () => {
|
||||
expect(resourceConflictFolder).toBeTruthy();
|
||||
expect(resourceConflictFolder.parent_id).toBeFalsy();
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle resource conflicts if a resource is changed locally but deleted remotely', (async () => {
|
||||
it('should handle resource conflicts if a resource is changed locally but deleted remotely', async () => {
|
||||
{
|
||||
const tempFile = tempFilePath('txt');
|
||||
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
||||
@@ -316,9 +316,9 @@ describe('Synchronizer.resources', () => {
|
||||
expect(originalResource.id).not.toBe(conflictResource.id);
|
||||
expect(conflictResource.title).toBe('modified resource');
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not upload a resource if it has not been fetched yet', (async () => {
|
||||
it('should not upload a resource if it has not been fetched yet', async () => {
|
||||
// In some rare cases, the synchronizer might try to upload a resource even though it
|
||||
// doesn't have the resource file. It can happen in this situation:
|
||||
// - C1 create resource
|
||||
@@ -350,9 +350,9 @@ describe('Synchronizer.resources', () => {
|
||||
await BaseItem.saveSyncEnabled(ModelType.Resource, resource.id);
|
||||
await synchronizerStart();
|
||||
expect((await remoteResources()).length).toBe(1);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not download resources over the limit', (async () => {
|
||||
it('should not download resources over the limit', async () => {
|
||||
const note1 = await Note.save({ title: 'note' });
|
||||
await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
|
||||
await synchronizer().start();
|
||||
@@ -368,6 +368,53 @@ describe('Synchronizer.resources', () => {
|
||||
expect(syncItems.length).toBe(2);
|
||||
expect(syncItems[1].item_location).toBe(BaseItem.SYNC_ITEM_LOCATION_REMOTE);
|
||||
expect(syncItems[1].sync_disabled).toBe(1);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not upload blob if it has not changed', async () => {
|
||||
const note = await Note.save({});
|
||||
await shim.attachFileToNote(note, `${supportDir}/sample.txt`);
|
||||
const resource: ResourceEntity = (await Resource.all())[0];
|
||||
const resourcePath = `.resource/${resource.id}`;
|
||||
|
||||
await synchronizer().api().put(resourcePath, 'before upload');
|
||||
expect(await synchronizer().api().get(resourcePath)).toBe('before upload');
|
||||
await synchronizerStart();
|
||||
expect(await synchronizer().api().get(resourcePath)).toBe('just testing');
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Change metadata only and check that blob is not uploaded. To do this,
|
||||
// we manually overwrite the data on the sync target, then sync. If the
|
||||
// synchronizer doesn't upload the blob, this manually changed data
|
||||
// should remain.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
await Resource.save({ id: resource.id, title: 'my new title' });
|
||||
await synchronizer().api().put(resourcePath, 'check if changed');
|
||||
await synchronizerStart();
|
||||
expect(await synchronizer().api().get(resourcePath)).toBe('check if changed');
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Now change the blob, and check that the remote item has been
|
||||
// overwritten.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
await Resource.updateResourceBlobContent(resource.id, `${supportDir}/sample.txt`);
|
||||
await synchronizerStart();
|
||||
expect(await synchronizer().api().get(resourcePath)).toBe('just testing');
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Change the blob, then change the metadata, and sync. Even though
|
||||
// blob_updated_time is earlier than updated_time, it should still
|
||||
// update everything on the sync target, because both times are after
|
||||
// the item sync_time.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
await Resource.updateResourceBlobContent(resource.id, `${supportDir}/sample2.txt`);
|
||||
await msleep(1);
|
||||
await Resource.save({ id: resource.id, title: 'my new title 2' });
|
||||
await synchronizerStart();
|
||||
expect(await synchronizer().api().get(resourcePath)).toBe('just testing 2');
|
||||
expect(await synchronizer().api().get(`${resource.id}.md`)).toContain('my new title 2');
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { SqlQuery } from '../../database';
|
||||
import JoplinDatabase from '../../JoplinDatabase';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import Setting from '../../models/Setting';
|
||||
import SyncTargetRegistry from '../../SyncTargetRegistry';
|
||||
import { SqlQuery } from '../database/types';
|
||||
|
||||
async function clearSyncContext() {
|
||||
const syncTargetIds = SyncTargetRegistry.allIds();
|
||||
|
@@ -142,12 +142,12 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
basicInfo: _('%d GB storage space', 1),
|
||||
proInfo: _('%d GB storage space', 10),
|
||||
teamsInfo: _('%d GB storage space', 10),
|
||||
basicInfoShort: _('%d GB', 1),
|
||||
proInfoShort: _('%d GB', 10),
|
||||
teamsInfoShort: _('%d GB', 10),
|
||||
basicInfo: _('%d GB storage space', 2),
|
||||
proInfo: _('%d GB storage space', 30),
|
||||
teamsInfo: _('%d GB storage space', 50),
|
||||
basicInfoShort: _('%d GB', 2),
|
||||
proInfoShort: _('%d GB', 30),
|
||||
teamsInfoShort: _('%d GB', 50),
|
||||
},
|
||||
publishNote: {
|
||||
title: _('Publish notes to the internet'),
|
||||
|
@@ -25,6 +25,13 @@ const userFetcher = async () => {
|
||||
const fileApi = await syncTarget.fileApi();
|
||||
const api = fileApi.driver().api();
|
||||
|
||||
if (!api.userId) {
|
||||
// That can happen if we don't have a session yet or if it has been
|
||||
// cleared
|
||||
logger.info('Skipping fetching user because user ID is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const owner: UserApiResponse = await api.exec('GET', `api/users/${api.userId}`);
|
||||
|
||||
logger.info('Got user:', owner);
|
||||
|
@@ -21,8 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/pdfjs-dist": "2.10.378",
|
||||
"@types/react": "18.2.23",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@types/styled-components": "5.1.28",
|
||||
"babel-jest": "29.6.4",
|
||||
"css-loader": "6.8.1",
|
||||
|
File diff suppressed because one or more lines are too long
@@ -35,7 +35,7 @@
|
||||
"highlight.js": "11.8.0",
|
||||
"html-entities": "1.4.0",
|
||||
"json-stringify-safe": "5.0.1",
|
||||
"katex": "0.16.8",
|
||||
"katex": "0.16.9",
|
||||
"markdown-it": "13.0.2",
|
||||
"markdown-it-abbr": "1.0.4",
|
||||
"markdown-it-anchor": "5.3.0",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.13.1",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "yarn run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
@@ -17,6 +17,7 @@
|
||||
"test-ci": "yarn test",
|
||||
"test-debug": "node --inspect node_modules/.bin/jest -- --verbose=false",
|
||||
"clean": "gulp clean",
|
||||
"populateDatabase": "JOPLIN_TESTS_SERVER_DB=pg node dist/utils/testing/populateDatabase",
|
||||
"stripeListen": "stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
|
||||
},
|
||||
@@ -60,6 +61,7 @@
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.13",
|
||||
"@rmp135/sql-ts": "1.18.0",
|
||||
"@types/bcryptjs": "2.4.5",
|
||||
"@types/formidable": "3.4.3",
|
||||
"@types/fs-extra": "11.0.2",
|
||||
"@types/jest": "29.5.4",
|
||||
@@ -67,9 +69,9 @@
|
||||
"@types/jsdom": "21.1.3",
|
||||
"@types/koa": "2.13.9",
|
||||
"@types/markdown-it": "12.2.3",
|
||||
"@types/mustache": "4.2.2",
|
||||
"@types/mustache": "4.2.3",
|
||||
"@types/nodemailer": "6.4.11",
|
||||
"@types/yargs": "17.0.25",
|
||||
"@types/yargs": "17.0.26",
|
||||
"@types/zxcvbn": "4.4.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.6.4",
|
||||
|
@@ -98,7 +98,7 @@ export const up = async (db: DbConnection) => {
|
||||
await db('users').insert({
|
||||
id: adminId,
|
||||
email: defaultAdminEmail,
|
||||
password: hashPassword(defaultAdminPassword),
|
||||
password: await hashPassword(defaultAdminPassword),
|
||||
full_name: 'Admin',
|
||||
is_admin: 1,
|
||||
updated_time: now,
|
||||
|
@@ -197,7 +197,7 @@ export default abstract class BaseModel<T> {
|
||||
// The `name` argument is only for debugging, so that any stuck transaction
|
||||
// can be more easily identified.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
protected async withTransaction<T>(fn: Function, name: string): Promise<T> {
|
||||
protected async withTransaction<T>(fn: Function, name = ''): Promise<T> {
|
||||
const debugSteps = false;
|
||||
const debugTimeout = true;
|
||||
const timeoutMs = 10000;
|
||||
|
@@ -6,7 +6,7 @@ import { md5 } from '../utils/crypto';
|
||||
import { ErrorResyncRequired } from '../utils/errors';
|
||||
import { Day, formatDateTime } from '../utils/time';
|
||||
import BaseModel, { SaveOptions } from './BaseModel';
|
||||
import { PaginatedResults, Pagination, PaginationOrderDir } from './utils/pagination';
|
||||
import { PaginatedResults } from './utils/pagination';
|
||||
|
||||
const logger = Logger.create('ChangeModel');
|
||||
|
||||
@@ -88,7 +88,44 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
};
|
||||
}
|
||||
|
||||
private changesForUserQuery(userId: Uuid, count: boolean): Knex.QueryBuilder {
|
||||
// private changesForUserQuery(userId: Uuid, count: boolean): Knex.QueryBuilder {
|
||||
// // When need to get:
|
||||
// //
|
||||
// // - All the CREATE and DELETE changes associated with the user
|
||||
// // - All the UPDATE changes that applies to items associated with the
|
||||
// // user.
|
||||
// //
|
||||
// // UPDATE changes do not have the user_id set because they are specific
|
||||
// // to the item, not to a particular user.
|
||||
|
||||
// const query = this
|
||||
// .db('changes')
|
||||
// .where(function() {
|
||||
// void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
|
||||
// // Need to use a RAW query here because Knex has a "not a
|
||||
// // bug" bug that makes it go into infinite loop in some
|
||||
// // contexts, possibly only when running inside Jest (didn't
|
||||
// // test outside).
|
||||
// // https://github.com/knex/knex/issues/1851
|
||||
// .orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
|
||||
// });
|
||||
|
||||
// if (count) {
|
||||
// void query.countDistinct('id', { as: 'total' });
|
||||
// } else {
|
||||
// void query.select([
|
||||
// 'id',
|
||||
// 'item_id',
|
||||
// 'item_name',
|
||||
// 'type',
|
||||
// 'updated_time',
|
||||
// ]);
|
||||
// }
|
||||
|
||||
// return query;
|
||||
// }
|
||||
|
||||
public async changesForUserQuery(userId: Uuid, fromCounter: number, limit: number, doCountQuery: boolean): Promise<Change[]> {
|
||||
// When need to get:
|
||||
//
|
||||
// - All the CREATE and DELETE changes associated with the user
|
||||
@@ -98,61 +135,125 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
// UPDATE changes do not have the user_id set because they are specific
|
||||
// to the item, not to a particular user.
|
||||
|
||||
const query = this
|
||||
.db('changes')
|
||||
.where(function() {
|
||||
void this.whereRaw('((type = ? OR type = ?) AND user_id = ?)', [ChangeType.Create, ChangeType.Delete, userId])
|
||||
// Need to use a RAW query here because Knex has a "not a
|
||||
// bug" bug that makes it go into infinite loop in some
|
||||
// contexts, possibly only when running inside Jest (didn't
|
||||
// test outside).
|
||||
// https://github.com/knex/knex/issues/1851
|
||||
.orWhereRaw('type = ? AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)', [ChangeType.Update, userId]);
|
||||
});
|
||||
// This used to be just one query but it kept getting slower and slower
|
||||
// as the `changes` table grew. So it is now split into two queries
|
||||
// merged by a UNION ALL.
|
||||
|
||||
if (count) {
|
||||
void query.countDistinct('id', { as: 'total' });
|
||||
const fields = [
|
||||
'id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'type',
|
||||
'updated_time',
|
||||
'counter',
|
||||
];
|
||||
|
||||
const fieldsSql = `"${fields.join('", "')}"`;
|
||||
|
||||
const subQuery1 = `
|
||||
SELECT ${fieldsSql}
|
||||
FROM "changes"
|
||||
WHERE counter > ?
|
||||
AND (type = ? OR type = ?)
|
||||
AND user_id = ?
|
||||
ORDER BY "counter" ASC
|
||||
${doCountQuery ? '' : 'LIMIT ?'}
|
||||
`;
|
||||
|
||||
const subParams1 = [
|
||||
fromCounter,
|
||||
ChangeType.Create,
|
||||
ChangeType.Delete,
|
||||
userId,
|
||||
];
|
||||
|
||||
if (!doCountQuery) subParams1.push(limit);
|
||||
|
||||
const subQuery2 = `
|
||||
SELECT ${fieldsSql}
|
||||
FROM "changes"
|
||||
WHERE counter > ?
|
||||
AND type = ?
|
||||
AND item_id IN (SELECT item_id FROM user_items WHERE user_id = ?)
|
||||
ORDER BY "counter" ASC
|
||||
${doCountQuery ? '' : 'LIMIT ?'}
|
||||
`;
|
||||
|
||||
const subParams2 = [
|
||||
fromCounter,
|
||||
ChangeType.Update,
|
||||
userId,
|
||||
];
|
||||
|
||||
if (!doCountQuery) subParams2.push(limit);
|
||||
|
||||
let query: Knex.Raw<any> = null;
|
||||
|
||||
const finalParams = subParams1.concat(subParams2);
|
||||
|
||||
if (!doCountQuery) {
|
||||
finalParams.push(limit);
|
||||
|
||||
query = this.db.raw(`
|
||||
SELECT ${fieldsSql} FROM (${subQuery1}) as sub1
|
||||
UNION ALL
|
||||
SELECT ${fieldsSql} FROM (${subQuery2}) as sub2
|
||||
ORDER BY counter ASC
|
||||
LIMIT ?
|
||||
`, finalParams);
|
||||
} else {
|
||||
void query.select([
|
||||
'id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'type',
|
||||
'updated_time',
|
||||
]);
|
||||
query = this.db.raw(`
|
||||
SELECT count(*) as total
|
||||
FROM (
|
||||
(${subQuery1})
|
||||
UNION ALL
|
||||
(${subQuery2})
|
||||
) AS merged
|
||||
`, finalParams);
|
||||
}
|
||||
|
||||
return query;
|
||||
const results = await query;
|
||||
|
||||
// Because it's a raw query, we need to handle the results manually:
|
||||
// Postgres returns an object with a "rows" property, while SQLite
|
||||
// returns the rows directly;
|
||||
const output: Change[] = results.rows ? results.rows : results;
|
||||
|
||||
// This property is present only for the purpose of ordering the results
|
||||
// and can be removed afterwards.
|
||||
for (const change of output) delete change.counter;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async allByUser(userId: Uuid, pagination: Pagination = null): Promise<PaginatedDeltaChanges> {
|
||||
pagination = {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
order: [{ by: 'counter', dir: PaginationOrderDir.ASC }],
|
||||
...pagination,
|
||||
};
|
||||
// public async allByUser(userId: Uuid, pagination: Pagination = null): Promise<PaginatedDeltaChanges> {
|
||||
// pagination = {
|
||||
// page: 1,
|
||||
// limit: 100,
|
||||
// order: [{ by: 'counter', dir: PaginationOrderDir.ASC }],
|
||||
// ...pagination,
|
||||
// };
|
||||
|
||||
const query = this.changesForUserQuery(userId, false);
|
||||
const countQuery = this.changesForUserQuery(userId, true);
|
||||
const itemCount = (await countQuery.first()).total;
|
||||
// const query = this.changesForUserQuery(userId, false);
|
||||
// const countQuery = this.changesForUserQuery(userId, true);
|
||||
// const itemCount = (await countQuery.first()).total;
|
||||
|
||||
void query
|
||||
.orderBy(pagination.order[0].by, pagination.order[0].dir)
|
||||
.offset((pagination.page - 1) * pagination.limit)
|
||||
.limit(pagination.limit) as any[];
|
||||
// void query
|
||||
// .orderBy(pagination.order[0].by, pagination.order[0].dir)
|
||||
// .offset((pagination.page - 1) * pagination.limit)
|
||||
// .limit(pagination.limit) as any[];
|
||||
|
||||
const changes = await query;
|
||||
// const changes = await query;
|
||||
|
||||
return {
|
||||
items: changes,
|
||||
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
||||
// If there's no change, we return the previous cursor.
|
||||
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
||||
has_more: changes.length >= pagination.limit,
|
||||
page_count: itemCount !== null ? Math.ceil(itemCount / pagination.limit) : undefined,
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// items: changes,
|
||||
// // If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
||||
// // If there's no change, we return the previous cursor.
|
||||
// cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
||||
// has_more: changes.length >= pagination.limit,
|
||||
// page_count: itemCount !== null ? Math.ceil(itemCount / pagination.limit) : undefined,
|
||||
// };
|
||||
// }
|
||||
|
||||
public async delta(userId: Uuid, pagination: ChangePagination = null): Promise<PaginatedDeltaChanges> {
|
||||
pagination = {
|
||||
@@ -167,18 +268,12 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
if (!changeAtCursor) throw new ErrorResyncRequired();
|
||||
}
|
||||
|
||||
const query = this.changesForUserQuery(userId, false);
|
||||
|
||||
// If a cursor was provided, apply it to the query.
|
||||
if (changeAtCursor) {
|
||||
void query.where('counter', '>', changeAtCursor.counter);
|
||||
}
|
||||
|
||||
void query
|
||||
.orderBy('counter', 'asc')
|
||||
.limit(pagination.limit) as any[];
|
||||
|
||||
const changes: Change[] = await query;
|
||||
const changes = await this.changesForUserQuery(
|
||||
userId,
|
||||
changeAtCursor ? changeAtCursor.counter : -1,
|
||||
pagination.limit,
|
||||
false,
|
||||
);
|
||||
|
||||
const items: Item[] = await this.db('items').select('id', 'jop_updated_time').whereIn('items.id', changes.map(c => c.item_id));
|
||||
|
||||
|
@@ -21,13 +21,15 @@ export default class TaskStateModel extends BaseModel<TaskState> {
|
||||
}
|
||||
|
||||
public async init(taskId: TaskId) {
|
||||
const taskState: TaskState = await this.loadByTaskId(taskId);
|
||||
if (taskState) return taskState;
|
||||
return this.withTransaction(async () => {
|
||||
const taskState: TaskState = await this.loadByTaskId(taskId);
|
||||
if (taskState) return taskState;
|
||||
|
||||
return this.save({
|
||||
task_id: taskId,
|
||||
enabled: 1,
|
||||
running: 0,
|
||||
return this.save({
|
||||
task_id: taskId,
|
||||
enabled: 1,
|
||||
running: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -186,6 +186,9 @@ export default class UserItemModel extends BaseModel<UserItem> {
|
||||
for (const userItem of userItems) {
|
||||
const item = items.find(i => i.id === userItem.item_id);
|
||||
|
||||
// The item may have been deleted between the async calls above
|
||||
if (!item) continue;
|
||||
|
||||
if (options.recordChanges && this.models().item().shouldRecordChange(item.name)) {
|
||||
await this.models().change().save({
|
||||
item_type: ItemType.UserItem,
|
||||
|
@@ -428,9 +428,14 @@ describe('UserModel', () => {
|
||||
test('should throw an error if the password being saved seems to be hashed', async () => {
|
||||
const passwordSimilarToHash = '$2a$10';
|
||||
|
||||
const error = await checkThrowAsync(async () => await models().user().save({ password: passwordSimilarToHash }));
|
||||
const user = await models().user().save({
|
||||
email: 'test@example.com',
|
||||
password: '111111',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Unable to save user because password already seems to be hashed. User id: undefined');
|
||||
const error = await checkThrowAsync(async () => await models().user().save({ id: user.id, password: passwordSimilarToHash }));
|
||||
|
||||
expect(error.message).toBe(`Unable to save user because password already seems to be hashed. User id: ${user.id}`);
|
||||
expect(error instanceof ErrorBadRequest).toBe(true);
|
||||
});
|
||||
|
||||
|
@@ -125,7 +125,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
public async login(email: string, password: string): Promise<User> {
|
||||
const user = await this.loadByEmail(email);
|
||||
if (!user) return null;
|
||||
if (!checkPassword(password, user.password)) return null;
|
||||
if (!(await checkPassword(password, user.password))) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -635,16 +635,26 @@ export default class UserModel extends BaseModel<User> {
|
||||
public async save(object: User, options: SaveOptions = {}): Promise<User> {
|
||||
const user = this.formatValues(object);
|
||||
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
if (user.password) {
|
||||
if (isHashedPassword(user.password)) {
|
||||
throw new ErrorBadRequest(`Unable to save user because password already seems to be hashed. User id: ${user.id}`);
|
||||
if (!isNew) {
|
||||
// We have this check because if an existing user is loaded,
|
||||
// then saved again, the "password" field will be hashed a
|
||||
// second time, and we don't want this.
|
||||
throw new ErrorBadRequest(`Unable to save user because password already seems to be hashed. User id: ${user.id}`);
|
||||
} else {
|
||||
// OK - We allow supplying an already hashed password for
|
||||
// new users. This is mostly used for testing, because
|
||||
// generating a bcrypt hash for each user is slow.
|
||||
}
|
||||
} else {
|
||||
if (!options.skipValidation) this.validatePassword(user.password);
|
||||
user.password = await hashPassword(user.password);
|
||||
}
|
||||
if (!options.skipValidation) this.validatePassword(user.password);
|
||||
user.password = hashPassword(user.password);
|
||||
}
|
||||
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
return this.withTransaction(async () => {
|
||||
const savedUser = await super.save(user, options);
|
||||
|
||||
|
@@ -1,52 +1,62 @@
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, createItemTree, createUserAndSession, parseHtml } from '../../utils/testing/testUtils';
|
||||
import { execRequest } from '../../utils/testing/apiUtils';
|
||||
// Disabled for now
|
||||
|
||||
describe('index_changes', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('index_changes');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should list changes', async () => {
|
||||
const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
|
||||
const items: any = {};
|
||||
for (let i = 1; i <= 150; i++) {
|
||||
items[(`${i}`).padStart(32, '0')] = {};
|
||||
}
|
||||
|
||||
await createItemTree(user1.id, '', items);
|
||||
|
||||
// Just some basic tests to check that we're seeing at least the first
|
||||
// and last item of each page.
|
||||
|
||||
{
|
||||
const response: string = await execRequest(session1.id, 'GET', 'changes');
|
||||
const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
expect(response.includes('00000000000000000000000000000150.md')).toBe(true);
|
||||
expect(response.includes('00000000000000000000000000000051.md')).toBe(true);
|
||||
expect(navLinks.length).toBe(2);
|
||||
expect(navLinks[0].getAttribute('class')).toContain('is-current');
|
||||
expect(navLinks[1].getAttribute('class')).not.toContain('is-current');
|
||||
}
|
||||
|
||||
{
|
||||
const response: string = await execRequest(session1.id, 'GET', 'changes', null, { query: { page: 2 } });
|
||||
const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
expect(response.includes('00000000000000000000000000000050.md')).toBe(true);
|
||||
expect(response.includes('00000000000000000000000000000001.md')).toBe(true);
|
||||
expect(navLinks.length).toBe(2);
|
||||
expect(navLinks[0].getAttribute('class')).not.toContain('is-current');
|
||||
expect(navLinks[1].getAttribute('class')).toContain('is-current');
|
||||
}
|
||||
it('should pass', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// import { beforeAllDb, afterAllTests, beforeEachDb, createItemTree, createUserAndSession, parseHtml } from '../../utils/testing/testUtils';
|
||||
// import { execRequest } from '../../utils/testing/apiUtils';
|
||||
|
||||
// describe('index_changes', () => {
|
||||
|
||||
// beforeAll(async () => {
|
||||
// await beforeAllDb('index_changes');
|
||||
// });
|
||||
|
||||
// afterAll(async () => {
|
||||
// await afterAllTests();
|
||||
// });
|
||||
|
||||
// beforeEach(async () => {
|
||||
// await beforeEachDb();
|
||||
// });
|
||||
|
||||
// test('should list changes', async () => {
|
||||
// const { user: user1, session: session1 } = await createUserAndSession(1, true);
|
||||
|
||||
// const items: any = {};
|
||||
// for (let i = 1; i <= 150; i++) {
|
||||
// items[(`${i}`).padStart(32, '0')] = {};
|
||||
// }
|
||||
|
||||
// await createItemTree(user1.id, '', items);
|
||||
|
||||
// // Just some basic tests to check that we're seeing at least the first
|
||||
// // and last item of each page.
|
||||
|
||||
// {
|
||||
// const response: string = await execRequest(session1.id, 'GET', 'changes');
|
||||
// const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
// expect(response.includes('00000000000000000000000000000150.md')).toBe(true);
|
||||
// expect(response.includes('00000000000000000000000000000051.md')).toBe(true);
|
||||
// expect(navLinks.length).toBe(2);
|
||||
// expect(navLinks[0].getAttribute('class')).toContain('is-current');
|
||||
// expect(navLinks[1].getAttribute('class')).not.toContain('is-current');
|
||||
// }
|
||||
|
||||
// {
|
||||
// const response: string = await execRequest(session1.id, 'GET', 'changes', null, { query: { page: 2 } });
|
||||
// const navLinks = parseHtml(response).querySelectorAll('.pagination-link');
|
||||
// expect(response.includes('00000000000000000000000000000050.md')).toBe(true);
|
||||
// expect(response.includes('00000000000000000000000000000001.md')).toBe(true);
|
||||
// expect(navLinks.length).toBe(2);
|
||||
// expect(navLinks[0].getAttribute('class')).not.toContain('is-current');
|
||||
// expect(navLinks[1].getAttribute('class')).toContain('is-current');
|
||||
// }
|
||||
// });
|
||||
|
||||
// });
|
||||
|
@@ -2,69 +2,74 @@ import { SubPath } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { changeTypeToString } from '../../services/database/types';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import { makeTablePagination, Table, Row, makeTableView } from '../../utils/views/table';
|
||||
import config, { showItemUrls } from '../../config';
|
||||
// import { changeTypeToString } from '../../services/database/types';
|
||||
// import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
// import { formatDateTime } from '../../utils/time';
|
||||
// import defaultView from '../../utils/defaultView';
|
||||
// import { View } from '../../services/MustacheService';
|
||||
// import { makeTablePagination, Table, Row, makeTableView } from '../../utils/views/table';
|
||||
// import config, { showItemUrls } from '../../config';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
router.get('changes', async (_path: SubPath, ctx: AppContext) => {
|
||||
if (!ctx.joplin.owner.is_admin) throw new ErrorForbidden();
|
||||
router.get('changes', async (_path: SubPath, _ctx: AppContext) => {
|
||||
// We disable this because it is too slow to retrieve all the changes and
|
||||
// could easily lock a database. If we need a way to inspect the log there
|
||||
// would have to be a different, more efficient way to do it.
|
||||
throw new ErrorForbidden('Disabled');
|
||||
|
||||
const pagination = makeTablePagination(ctx.query, 'updated_time', PaginationOrderDir.DESC);
|
||||
const paginatedChanges = await ctx.joplin.models.change().allByUser(ctx.joplin.owner.id, pagination);
|
||||
const items = await ctx.joplin.models.item().loadByIds(paginatedChanges.items.map(i => i.item_id), { fields: ['id'] });
|
||||
// if (!ctx.joplin.owner.is_admin) throw new ErrorForbidden();
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: ctx.joplin.models.change().changeUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: paginatedChanges.page_count,
|
||||
pagination,
|
||||
headers: [
|
||||
{
|
||||
name: 'item_name',
|
||||
label: 'Name',
|
||||
stretch: true,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
},
|
||||
{
|
||||
name: 'updated_time',
|
||||
label: 'Timestamp',
|
||||
},
|
||||
],
|
||||
rows: paginatedChanges.items.map(change => {
|
||||
const row: Row = {
|
||||
items: [
|
||||
{
|
||||
value: change.item_name,
|
||||
stretch: true,
|
||||
url: showItemUrls(config()) ? (items.find(i => i.id === change.item_id) ? ctx.joplin.models.item().itemContentUrl(change.item_id) : '') : null,
|
||||
},
|
||||
{
|
||||
value: changeTypeToString(change.type),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(change.updated_time),
|
||||
},
|
||||
],
|
||||
};
|
||||
// const pagination = makeTablePagination(ctx.query, 'updated_time', PaginationOrderDir.DESC);
|
||||
// const paginatedChanges = await ctx.joplin.models.change().allByUser(ctx.joplin.owner.id, pagination);
|
||||
// const items = await ctx.joplin.models.item().loadByIds(paginatedChanges.items.map(i => i.item_id), { fields: ['id'] });
|
||||
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
// const table: Table = {
|
||||
// baseUrl: ctx.joplin.models.change().changeUrl(),
|
||||
// requestQuery: ctx.query,
|
||||
// pageCount: paginatedChanges.page_count,
|
||||
// pagination,
|
||||
// headers: [
|
||||
// {
|
||||
// name: 'item_name',
|
||||
// label: 'Name',
|
||||
// stretch: true,
|
||||
// },
|
||||
// {
|
||||
// name: 'type',
|
||||
// label: 'Type',
|
||||
// },
|
||||
// {
|
||||
// name: 'updated_time',
|
||||
// label: 'Timestamp',
|
||||
// },
|
||||
// ],
|
||||
// rows: paginatedChanges.items.map(change => {
|
||||
// const row: Row = {
|
||||
// items: [
|
||||
// {
|
||||
// value: change.item_name,
|
||||
// stretch: true,
|
||||
// url: showItemUrls(config()) ? (items.find(i => i.id === change.item_id) ? ctx.joplin.models.item().itemContentUrl(change.item_id) : '') : null,
|
||||
// },
|
||||
// {
|
||||
// value: changeTypeToString(change.type),
|
||||
// },
|
||||
// {
|
||||
// value: formatDateTime(change.updated_time),
|
||||
// },
|
||||
// ],
|
||||
// };
|
||||
|
||||
const view: View = defaultView('changes', 'Log');
|
||||
view.content.changeTable = makeTableView(table),
|
||||
view.cssFiles = ['index/changes'];
|
||||
return view;
|
||||
// return row;
|
||||
// }),
|
||||
// };
|
||||
|
||||
// const view: View = defaultView('changes', 'Log');
|
||||
// view.content.changeTable = makeTableView(table),
|
||||
// view.cssFiles = ['index/changes'];
|
||||
// return view;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@@ -9,7 +9,7 @@ import { makeUrl, SubPath, UrlType } from '../utils/routeUtils';
|
||||
import MarkdownIt = require('markdown-it');
|
||||
import { headerAnchor } from '@joplin/renderer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl } from '../utils/urlUtils';
|
||||
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, homeUrl, itemsUrl } from '../utils/urlUtils';
|
||||
import { MenuItem, setSelectedMenu } from '../utils/views/menu';
|
||||
|
||||
export interface RenderOptions {
|
||||
@@ -150,10 +150,6 @@ export default class MustacheService {
|
||||
title: _('Items'),
|
||||
url: itemsUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Logs'),
|
||||
url: changesUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Admin'),
|
||||
url: adminDashboardUrl(),
|
||||
|
@@ -1,7 +1,16 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export function unique(array: any[]): any[] {
|
||||
return array.filter((elem, index, self) => {
|
||||
return index === self.indexOf(elem);
|
||||
});
|
||||
}
|
||||
|
||||
export const randomElement = <T>(array: T[]): T => {
|
||||
if (!array || !array.length) return null;
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
};
|
||||
|
||||
export const removeElement = (array: any[], element: any) => {
|
||||
const index = array.indexOf(element);
|
||||
if (index < 0) return;
|
||||
array.splice(index, 1);
|
||||
};
|
||||
|
@@ -12,7 +12,7 @@ describe('hashPassword', () => {
|
||||
'$2a$10$LMKVPiNOWDZhtw9NizNIEuNGLsjOxQAcrwQJ0lnKuiaOtyFgZEnwO',
|
||||
],
|
||||
)('should return a string that starts with $2a$10 for the password: %', async (plainText) => {
|
||||
expect(hashPassword(plainText).startsWith('$2a$10')).toBe(true);
|
||||
expect((await hashPassword(plainText)).startsWith('$2a$10')).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,12 +1,12 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
return bcrypt.hashSync(password, salt);
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
export function checkPassword(password: string, hash: string): boolean {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
export async function checkPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export const isHashedPassword = (password: string) => {
|
||||
|
371
packages/server/src/utils/testing/populateDatabase.ts
Normal file
371
packages/server/src/utils/testing/populateDatabase.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
|
||||
import { User } from '../../services/database/types';
|
||||
import { randomElement } from '../array';
|
||||
import { CustomErrorCode } from '../errors';
|
||||
import { randomWords } from './randomWords';
|
||||
import { afterAllTests, beforeAllDb, createdDbPath, makeFolderSerializedBody, makeNoteSerializedBody, makeResourceSerializedBody, models, randomHash } from './testUtils';
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
|
||||
let logger_: Logger = null;
|
||||
|
||||
const logger = () => {
|
||||
if (!logger_) {
|
||||
logger_ = new Logger();
|
||||
logger_.addTarget(TargetType.Console);
|
||||
logger_.setLevel(LogLevel.Debug);
|
||||
}
|
||||
return logger_;
|
||||
};
|
||||
|
||||
export interface Options {
|
||||
userCount?: number;
|
||||
minNoteCountPerUser?: number;
|
||||
maxNoteCountPerUser?: number;
|
||||
minFolderCountPerUser?: number;
|
||||
maxFolderCountPerUser?: number;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
createdFolderIds: Record<string, string[]>;
|
||||
createdNoteIds: Record<string, string[]>;
|
||||
createdResourceIds: Record<string, string[]>;
|
||||
}
|
||||
|
||||
enum Action {
|
||||
CreateNote = 'createNote',
|
||||
CreateFolder = 'createFolder',
|
||||
CreateNoteAndResource = 'createNoteAndResource',
|
||||
UpdateNote = 'updateNote',
|
||||
UpdateFolder = 'updateFolder',
|
||||
DeleteNote = 'deleteNote',
|
||||
DeleteFolder = 'deleteFolder',
|
||||
}
|
||||
|
||||
const createActions = [Action.CreateNote, Action.CreateFolder, Action.CreateNoteAndResource];
|
||||
const updateActions = [Action.UpdateNote, Action.UpdateFolder];
|
||||
const deleteActions = [Action.DeleteNote, Action.DeleteFolder];
|
||||
|
||||
const isCreateAction = (action: Action) => {
|
||||
return createActions.includes(action);
|
||||
};
|
||||
|
||||
const isUpdateAction = (action: Action) => {
|
||||
return updateActions.includes(action);
|
||||
};
|
||||
|
||||
const isDeleteAction = (action: Action) => {
|
||||
return deleteActions.includes(action);
|
||||
};
|
||||
|
||||
type Reaction = (context: Context, user: User)=> Promise<boolean>;
|
||||
|
||||
const randomInt = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
};
|
||||
|
||||
const createRandomNote = async (user: User, note: NoteEntity = null) => {
|
||||
const id = randomHash();
|
||||
const itemName = `${id}.md`;
|
||||
|
||||
const serializedBody = makeNoteSerializedBody({
|
||||
id,
|
||||
title: randomWords(randomInt(1, 10)),
|
||||
...note,
|
||||
});
|
||||
|
||||
const result = await models().item().saveFromRawContent(user, {
|
||||
name: itemName,
|
||||
body: Buffer.from(serializedBody),
|
||||
});
|
||||
|
||||
if (result[itemName].error) throw result[itemName].error;
|
||||
|
||||
return result[itemName].item;
|
||||
};
|
||||
|
||||
const createRandomFolder = async (user: User, folder: FolderEntity = null) => {
|
||||
const id = randomHash();
|
||||
const itemName = `${id}.md`;
|
||||
|
||||
const serializedBody = makeFolderSerializedBody({
|
||||
id,
|
||||
title: randomWords(randomInt(1, 5)),
|
||||
...folder,
|
||||
});
|
||||
|
||||
const result = await models().item().saveFromRawContent(user, {
|
||||
name: itemName,
|
||||
body: Buffer.from(serializedBody),
|
||||
});
|
||||
|
||||
if (result[itemName].error) throw result[itemName].error;
|
||||
|
||||
return result[itemName].item;
|
||||
};
|
||||
|
||||
const reactions: Record<Action, Reaction> = {
|
||||
[Action.CreateNote]: async (context, user) => {
|
||||
const item = await createRandomNote(user);
|
||||
if (!context.createdNoteIds[user.id]) context.createdNoteIds[user.id] = [];
|
||||
context.createdNoteIds[user.id].push(item.jop_id);
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.CreateFolder]: async (context, user) => {
|
||||
const item = await createRandomFolder(user);
|
||||
if (!context.createdFolderIds[user.id]) context.createdFolderIds[user.id] = [];
|
||||
context.createdFolderIds[user.id].push(item.jop_id);
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.CreateNoteAndResource]: async (context, user) => {
|
||||
const resourceContent = randomWords(20);
|
||||
const resourceId = randomHash();
|
||||
|
||||
const metadataBody = makeResourceSerializedBody({
|
||||
id: resourceId,
|
||||
title: randomWords(5),
|
||||
size: resourceContent.length,
|
||||
});
|
||||
|
||||
await models().item().saveFromRawContent(user, {
|
||||
name: `${resourceId}.md`,
|
||||
body: Buffer.from(metadataBody),
|
||||
});
|
||||
|
||||
await models().item().saveFromRawContent(user, {
|
||||
name: `.resource/${resourceId}`,
|
||||
body: Buffer.from(resourceContent),
|
||||
});
|
||||
|
||||
if (!context.createdResourceIds[user.id]) context.createdResourceIds[user.id] = [];
|
||||
context.createdResourceIds[user.id].push(resourceId);
|
||||
|
||||
const noteItem = await createRandomNote(user, {
|
||||
body: `[](:/${resourceId})`,
|
||||
});
|
||||
|
||||
if (!context.createdNoteIds[user.id]) context.createdNoteIds[user.id] = [];
|
||||
context.createdNoteIds[user.id].push(noteItem.jop_id);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.UpdateNote]: async (context, user) => {
|
||||
const noteId = randomElement(context.createdNoteIds[user.id]);
|
||||
if (!noteId) return false;
|
||||
|
||||
try {
|
||||
const noteItem = await models().item().loadByJopId(user.id, noteId);
|
||||
const note = await models().item().loadAsJoplinItem(noteItem.id);
|
||||
const serialized = makeNoteSerializedBody({
|
||||
title: randomWords(10),
|
||||
...note,
|
||||
});
|
||||
|
||||
await models().item().saveFromRawContent(user, {
|
||||
name: `${note.id}.md`,
|
||||
body: Buffer.from(serialized),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === CustomErrorCode.NotFound) return false;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.UpdateFolder]: async (context, user) => {
|
||||
const folderId = randomElement(context.createdFolderIds[user.id]);
|
||||
if (!folderId) return false;
|
||||
|
||||
try {
|
||||
const folderItem = await models().item().loadByJopId(user.id, folderId);
|
||||
const folder = await models().item().loadAsJoplinItem(folderItem.id);
|
||||
const serialized = makeFolderSerializedBody({
|
||||
title: randomWords(5),
|
||||
...folder,
|
||||
});
|
||||
|
||||
await models().item().saveFromRawContent(user, {
|
||||
name: `${folder.id}.md`,
|
||||
body: Buffer.from(serialized),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === CustomErrorCode.NotFound) return false;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.DeleteNote]: async (context, user) => {
|
||||
const noteId = randomElement(context.createdNoteIds[user.id]);
|
||||
if (!noteId) return false;
|
||||
const item = await models().item().loadByJopId(user.id, noteId, { fields: ['id'] });
|
||||
await models().item().delete(item.id, { allowNoOp: true });
|
||||
return true;
|
||||
},
|
||||
|
||||
[Action.DeleteFolder]: async (context, user) => {
|
||||
const folderId = randomElement(context.createdFolderIds[user.id]);
|
||||
if (!folderId) return false;
|
||||
const item = await models().item().loadByJopId(user.id, folderId, { fields: ['id'] });
|
||||
await models().item().delete(item.id, { allowNoOp: true });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const randomActionKey = () => {
|
||||
const r = Math.random();
|
||||
if (r <= .5) {
|
||||
return randomElement(createActions);
|
||||
} else if (r <= .8) {
|
||||
return randomElement(updateActions);
|
||||
} else {
|
||||
return randomElement(deleteActions);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async (_options?: Options) => {
|
||||
// options = {
|
||||
// userCount: 10,
|
||||
// minNoteCountPerUser: 0,
|
||||
// maxNoteCountPerUser: 1000,
|
||||
// minFolderCountPerUser: 0,
|
||||
// maxFolderCountPerUser: 50,
|
||||
// ...options,
|
||||
// };
|
||||
|
||||
shimInit({ nodeSqlite });
|
||||
await beforeAllDb('populateDatabase');
|
||||
|
||||
logger().info(`Populating database: ${createdDbPath()}`);
|
||||
|
||||
const context: Context = {
|
||||
createdNoteIds: {},
|
||||
createdFolderIds: {},
|
||||
createdResourceIds: {},
|
||||
};
|
||||
|
||||
const report = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
};
|
||||
|
||||
const updateReport = (action: Action) => {
|
||||
if (isCreateAction(action)) report.created++;
|
||||
if (isUpdateAction(action)) report.updated++;
|
||||
if (isDeleteAction(action)) report.deleted++;
|
||||
};
|
||||
|
||||
let users: User[] = [];
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CREATE USERS
|
||||
// -------------------------------------------------------------
|
||||
|
||||
{
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
promises.push((async () => {
|
||||
const user = await models().user().save({
|
||||
full_name: `Toto ${i}`,
|
||||
email: `toto${i}@example.com`,
|
||||
password: '$2a$10$/2DMDnrx0PAspJ2DDnW/PO5x5M9H1abfSPsqxlPMhYiXgDi25751u', // Password = 111111
|
||||
});
|
||||
|
||||
users.push(user);
|
||||
|
||||
logger().info(`Created user ${i}`);
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
users = await models().user().loadByIds(users.map(u => u.id));
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CREATE NOTES, FOLDERS AND RESOURCES
|
||||
// -------------------------------------------------------------
|
||||
|
||||
{
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
promises.push((async () => {
|
||||
const user = randomElement(users);
|
||||
const action = randomElement(createActions);
|
||||
await reactions[action](context, user);
|
||||
updateReport(action);
|
||||
logger().info(`Done action ${i}: ${action}. User: ${user.email}`);
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CREATE/UPDATE/DELETE NOTES, FOLDERS AND RESOURCES
|
||||
// -------------------------------------------------------------
|
||||
|
||||
{
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
promises.push((async () => {
|
||||
const user = randomElement(users);
|
||||
const action = randomActionKey();
|
||||
try {
|
||||
const done = await reactions[action](context, user);
|
||||
if (done) updateReport(action);
|
||||
logger().info(`Done action ${i}: ${action}. User: ${user.email}${!done ? ' (Skipped)' : ''}`);
|
||||
} catch (error) {
|
||||
error.message = `Could not do action ${i}: ${action}. User: ${user.email}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// const changeIds = (await models().change().all()).map(c => c.id);
|
||||
|
||||
// const serverDir = (await getRootDir()) + '/packages/server';
|
||||
|
||||
// for (let i = 0; i < 100000; i++) {
|
||||
// const user = randomElement(users);
|
||||
// const cursor = Math.random() < .3 ? '' : randomElement(changeIds);
|
||||
|
||||
// try {
|
||||
// const result1 = await models().change().delta(user.id, { cursor, limit: 1000 }, 1);
|
||||
// const result2 = await models().change().delta(user.id, { cursor, limit: 1000 }, 2);
|
||||
|
||||
// logger().info('Test ' + i + ': Found ' + result1.items.length + ' and ' + result2.items.length + ' items');
|
||||
|
||||
// if (JSON.stringify(result1) !== JSON.stringify(result2)) {
|
||||
// await writeFile(serverDir + '/result1.json', JSON.stringify(result1.items, null, '\t'));
|
||||
// await writeFile(serverDir + '/result2.json', JSON.stringify(result2.items, null, '\t'));
|
||||
// throw new Error('Found different results');
|
||||
// }
|
||||
// } catch (error) {
|
||||
// error.message = 'User ' + user.id + ', Cursor ' + cursor + ': ' + error.message;
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
await afterAllTests();
|
||||
|
||||
logger().info(report);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
logger().error('Fatal error', error);
|
||||
process.exit(1);
|
||||
});
|
2015
packages/server/src/utils/testing/randomWords.ts
Normal file
2015
packages/server/src/utils/testing/randomWords.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import * as fs from 'fs-extra';
|
||||
import * as jsdom from 'jsdom';
|
||||
import setupAppContext from '../setupAppContext';
|
||||
import { ApiError } from '../errors';
|
||||
import { getApi, putApi } from './apiUtils';
|
||||
import { deleteApi, getApi, putApi } from './apiUtils';
|
||||
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { initializeJoplinUtils } from '../joplinUtils';
|
||||
@@ -73,6 +73,7 @@ export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOpt
|
||||
unitName = unitName.replace(/\//g, '_');
|
||||
|
||||
createdDbPath_ = `${packageRootDir}/db-test-${unitName}.sqlite`;
|
||||
await fs.remove(createdDbPath_);
|
||||
|
||||
const tempDir = `${packageRootDir}/temp/test-${unitName}`;
|
||||
await fs.mkdirp(tempDir);
|
||||
@@ -111,6 +112,10 @@ export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOpt
|
||||
await initializeJoplinUtils(config(), models(), mustache);
|
||||
}
|
||||
|
||||
export const createdDbPath = () => {
|
||||
return createdDbPath_;
|
||||
};
|
||||
|
||||
export async function afterAllTests() {
|
||||
if (db_) {
|
||||
await disconnectDb(db_);
|
||||
@@ -237,7 +242,7 @@ export function koaNext(): Promise<void> {
|
||||
|
||||
export const testAssetDir = `${packageRootDir}/assets/tests`;
|
||||
|
||||
interface UserAndSession {
|
||||
export interface UserAndSession {
|
||||
user: User;
|
||||
session: Session;
|
||||
password: string;
|
||||
@@ -352,6 +357,10 @@ export async function updateItem(sessionId: string, path: string, content: strin
|
||||
return models().item().load(item.id);
|
||||
}
|
||||
|
||||
export async function deleteItem(sessionId: string, jopId: string): Promise<void> {
|
||||
await deleteApi(sessionId, `items/root:/${jopId}.md:`);
|
||||
}
|
||||
|
||||
export async function createNote(sessionId: string, note: NoteEntity): Promise<Item> {
|
||||
note = {
|
||||
id: '00000000000000000000000000000001',
|
||||
@@ -561,7 +570,16 @@ type_: 2`;
|
||||
}
|
||||
|
||||
export function makeResourceSerializedBody(resource: ResourceEntity = {}): string {
|
||||
return `Test Resource
|
||||
resource = {
|
||||
id: randomHash(),
|
||||
mime: 'plain/text',
|
||||
file_extension: 'txt',
|
||||
size: 0,
|
||||
title: 'Test Resource',
|
||||
...resource,
|
||||
};
|
||||
|
||||
return `${resource.title}
|
||||
|
||||
id: ${resource.id}
|
||||
mime: ${resource.mime}
|
||||
|
@@ -46,17 +46,17 @@
|
||||
"@rmp135/sql-ts": "1.18.0",
|
||||
"@types/fs-extra": "11.0.2",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/markdown-it": "13.0.1",
|
||||
"@types/mustache": "4.2.2",
|
||||
"@types/markdown-it": "13.0.2",
|
||||
"@types/mustache": "4.2.3",
|
||||
"@types/node": "18.17.19",
|
||||
"@types/node-fetch": "2.6.6",
|
||||
"@types/yargs": "17.0.25",
|
||||
"@types/yargs": "17.0.26",
|
||||
"gettext-extractor": "3.8.0",
|
||||
"gulp": "4.0.2",
|
||||
"html-entities": "1.4.0",
|
||||
"jest": "29.6.4",
|
||||
"rss": "1.2.2",
|
||||
"sass": "1.66.1",
|
||||
"sass": "1.67.0",
|
||||
"sqlite3": "5.1.6",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
|
@@ -1 +1 @@
|
||||
{"processedReleases":{"v2.13.1":true,"v2.13.2":true}}
|
||||
{"processedReleases":{"v2.13.1":true,"v2.13.2":true,"v2.13.3":true}}
|
@@ -85,6 +85,14 @@ class Logger {
|
||||
this.enabled_ = v;
|
||||
}
|
||||
|
||||
public status(): string {
|
||||
const output: string[] = [];
|
||||
output.push(`Enabled: ${this.enabled}`);
|
||||
output.push(`Level: ${this.level()}`);
|
||||
output.push(`Targets: ${this.targets().map(t => t.type).join(', ')}`);
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
public static initializeGlobalLogger(logger: Logger) {
|
||||
this.globalLogger_ = logger;
|
||||
}
|
||||
|
@@ -309,6 +309,7 @@ Deletes the folder with ID :id
|
||||
| share_id | text | |
|
||||
| master_key_id | text | |
|
||||
| user_data | text | |
|
||||
| blob_updated_time | int | |
|
||||
|
||||
## GET /resources
|
||||
|
||||
|
@@ -1,5 +1,23 @@
|
||||
# Joplin changelog
|
||||
|
||||
## [v2.13.3](https://github.com/laurent22/joplin/releases/tag/v2.13.3) (Pre-release) - 2023-10-24T09:25:33Z
|
||||
|
||||
- Improved: Support for plural translations ([#9033](https://github.com/laurent22/joplin/issues/9033))
|
||||
- Improved: Update Electron to 25.9.0 ([90832da](https://github.com/laurent22/joplin/commit/90832da))
|
||||
- Improved: Updated packages dayjs (v1.11.10), follow-redirects (v1.15.3), glob (v10.3.6), katex (v0.16.9), markdown-it (v13.0.2), react, react-redux (v8.1.3), react-select (v5.7.7), sharp (v0.32.6), tar (v6.2.0)
|
||||
- Improved: Use plain text editor in safe mode ([#8750](https://github.com/laurent22/joplin/issues/8750)) ([#8749](https://github.com/laurent22/joplin/issues/8749) by Henry Heino)
|
||||
- Fixed: Added Note Properties to Note menu bar items ([#9119](https://github.com/laurent22/joplin/issues/9119)) ([#9108](https://github.com/laurent22/joplin/issues/9108) by [@CptMeetKat](https://github.com/CptMeetKat))
|
||||
- Fixed: Beta editor: Allow tab key to insert tabs at cursor rather than indent in some cases ([#9107](https://github.com/laurent22/joplin/issues/9107)) ([#9104](https://github.com/laurent22/joplin/issues/9104) by Henry Heino)
|
||||
- Fixed: Fix external links in PDFs break Joplin ([#9094](https://github.com/laurent22/joplin/issues/9094)) ([#9070](https://github.com/laurent22/joplin/issues/9070) by Henry Heino)
|
||||
- Fixed: Fix markdown editor context menu not displaying on some devices ([#9030](https://github.com/laurent22/joplin/issues/9030)) ([#8881](https://github.com/laurent22/joplin/issues/8881) by Henry Heino)
|
||||
- Fixed: Fixed issues related to sharing notes on read-only notebooks ([1c7d22e](https://github.com/laurent22/joplin/commit/1c7d22e))
|
||||
- Fixed: Plugins: Fix building plugins on Windows ([3ac2fe9](https://github.com/laurent22/joplin/commit/3ac2fe9))
|
||||
|
||||
## [v2.12.19](https://github.com/laurent22/joplin/releases/tag/v2.12.19) - 2023-10-21T09:39:18Z
|
||||
|
||||
- Security: Update Electron to 25.9.0 ([#9049](https://github.com/laurent22/joplin/issues/9049) by Henry Heino)
|
||||
- Fixed: Fixed issues related to sharing notes on read-only notebooks ([afaa2a7](https://github.com/laurent22/joplin/commit/afaa2a7))
|
||||
|
||||
## [v2.13.2](https://github.com/laurent22/joplin/releases/tag/v2.13.2) (Pre-release) - 2023-10-06T17:00:07Z
|
||||
|
||||
- New: Add new beta Markdown editor based on CodeMirror 6 ([#8793](https://github.com/laurent22/joplin/issues/8793) by Henry Heino)
|
||||
|
@@ -1,5 +1,15 @@
|
||||
# Joplin Android app changelog
|
||||
|
||||
## [android-v2.13.4](https://github.com/laurent22/joplin/releases/tag/android-v2.13.4) (Pre-release) - 2023-10-24T18:29:09Z
|
||||
|
||||
- Improved: Allow modifying a resource metadata only when synchronising (#9114)
|
||||
- Improved: Support for plural translations (#9033)
|
||||
- Improved: Updated packages @react-native-community/datetimepicker (v7.5.0), @react-native-community/geolocation (v3.1.0), @testing-library/react-native (v12.3.0), dayjs (v1.11.10), follow-redirects (v1.15.3), glob (v10.3.6), katex (v0.16.9), markdown-it (v13.0.2), react, react-native-device-info (v10.9.0), react-native-dropdownalert (v5), react-native-image-picker (v5.7.0), react-native-paper (v5.10.6), react-native-share (v9.4.1), react-native-webview (v13.6.0), react-native-zip-archive (v6.1.0), react-redux (v8.1.3), sass (v1.67.0), sharp (v0.32.6), tar (v6.2.0)
|
||||
- Fixed: Fix sidebar folder icon (cd55a9a)
|
||||
- Fixed: Fix writing UTF-8 data to a file replaces non-ASCII characters with ?s (#9076) (#9069 by Henry Heino)
|
||||
- Fixed: Fixed issues related to sharing notes on read-only notebooks (1c7d22e)
|
||||
- Fixed: Improve list toggle logic (#9103) (#9066 by Henry Heino)
|
||||
|
||||
## [android-v2.13.2](https://github.com/laurent22/joplin/releases/tag/android-v2.13.2) (Pre-release) - 2023-10-07T16:42:16Z
|
||||
|
||||
- New: Add share button to log screen (#8364 by Henry Heino)
|
||||
|
@@ -1,5 +1,33 @@
|
||||
# Joplin iOS app changelog
|
||||
|
||||
## [ios-v12.13.1](https://github.com/laurent22/joplin/releases/tag/ios-v12.13.1) - 2023-10-24T14:50:37Z
|
||||
|
||||
- New: Add share button to log screen (#8364 by Henry Heino)
|
||||
- New: Add support for drawing pictures (#7588 by Henry Heino)
|
||||
- Improved: Allow modifying a resource metadata only when synchronising (#9114)
|
||||
- Improved: Apply correct size to images imported from ENEX files (#8684)
|
||||
- Improved: Bump mermaid version to 10.4.0 to support new chart types (#8890) (#8728 by [@oj-lappi](https://github.com/oj-lappi))
|
||||
- Improved: Enable ignoreTlsErrors and custom certificates for S3 sync (#8980 by Jens Böttge)
|
||||
- Improved: Fix random crash due to sidebar animation (#8792) (#8791 by Henry Heino)
|
||||
- Improved: Improved handling of invalid sync info (#6978)
|
||||
- Improved: Remember whether "All notes", a notebook or a tag was opened when re-opening the app (#8021)
|
||||
- Improved: Support for plural translations (#9033)
|
||||
- Improved: Updated packages @bam.tech/react-native-image-resizer (v3.0.7), @react-native-community/datetimepicker (v7.5.0), @react-native-community/geolocation (v3.1.0), @react-native-community/slider (v4.4.3), @testing-library/jest-native (v5.4.3), @testing-library/react-native (v12.3.0), compare-versions (v6.1.0), dayjs (v1.11.10), deprecated-react-native-prop-types (v4.2.1), follow-redirects (v1.15.3), glob (v10.3.6), katex (v0.16.9), markdown-it (v13.0.2), markdown-it-multimd-table (v4.2.3), nodemon (v3.0.1), react, react-native-device-info (v10.9.0), react-native-dropdownalert (v5), react-native-exit-app (v2), react-native-gesture-handler (v2.12.1), react-native-image-picker (v5.7.0), react-native-modal-datetime-picker (v17.1.0), react-native-paper (v5.10.6), react-native-safe-area-context (v4.7.2), react-native-share (v9.4.1), react-native-url-polyfill (v2), react-native-vector-icons (v10), react-native-webview (v13.6.0), react-native-zip-archive (v6.1.0), react-redux (v8.1.3), sass (v1.67.0), sharp (v0.32.6), sprintf-js (v1.1.3), tar (v6.2.0), url (v0.11.3), uuid (v9.0.1)
|
||||
- Fixed: Fix complex queries that contain quotes or filters (#8050)
|
||||
- Fixed: Fix icon after react-native-vector-icon upgrade (0e0c1d8)
|
||||
- Fixed: Fix not all dropdown items focusable with VoiceOver (#8714) (#8707 by Henry Heino)
|
||||
- Fixed: Fix search engine ranking algorithm (f504cf1)
|
||||
- Fixed: Fix sidebar folder icon (cd55a9a)
|
||||
- Fixed: Fix sync issue with Stackstorage (#2153)
|
||||
- Fixed: Fix unordered list button creates checklists (#8957) (#8956 by Henry Heino)
|
||||
- Fixed: Fix writing UTF-8 data to a file replaces non-ASCII characters with ?s (#9076) (#9069 by Henry Heino)
|
||||
- Fixed: Fixed code block not default line wrap in pdf view (#8626) (#8517 by [@wljince007](https://github.com/wljince007))
|
||||
- Fixed: Fixed issues related to sharing notes on read-only notebooks (1c7d22e)
|
||||
- Fixed: Hide the keyboard when showing the attach dialog (#8911) (#8774 by Henry Heino)
|
||||
- Fixed: Improve list toggle logic (#9103) (#9066 by Henry Heino)
|
||||
- Fixed: Prevent accessibility tools from focusing the notes list when it's invisible (#8799) (#8798 by Henry Heino)
|
||||
- Fixed: Prevent application from being stuck when importing an invalid ENEX file (#8699)
|
||||
|
||||
## [ios-v12.12.3](https://github.com/laurent22/joplin/releases/tag/ios-v12.12.3) - 2023-09-11T20:05:19Z
|
||||
|
||||
- Improved: Add screen reader labels to search/note actions buttons (#8797) (#8796 by Henry Heino)
|
||||
|
@@ -1,5 +1,11 @@
|
||||
# Joplin Server Changelog
|
||||
|
||||
## [server-v2.13.2](https://github.com/laurent22/joplin/releases/tag/server-v2.13.2) - 2023-10-19T19:49:44Z
|
||||
|
||||
- Improved: Significantly improve sync performances, especially when there are many changes (5986710)
|
||||
- Improved: Updated packages compare-versions (v6.1.0), dayjs (v1.11.10), follow-redirects (v1.15.3), glob (v10.3.6), katex (v0.16.8), markdown-it (v13.0.2), node-mocks-http (v1.13.0), nodemailer (v6.9.5), nodemon (v3.0.1), react, sass (v1.66.1), sharp (v0.32.6), sprintf-js (v1.1.3), tar (v6.2.0), uuid (v9.0.1)
|
||||
- Fixed: Fixed publishing logo (01f37df)
|
||||
|
||||
## [server-v2.13.1](https://github.com/laurent22/joplin/releases/tag/server-v2.13.1) - 2023-09-20T15:15:32Z
|
||||
|
||||
- New: Add Joplin Server and Joplin Cloud favicons (1b00445)
|
||||
|
@@ -743,6 +743,14 @@
|
||||
"created_at": "2023-09-28T16:40:13Z",
|
||||
"repoId": 79162682,
|
||||
"pullRequestNo": 8980
|
||||
},
|
||||
{
|
||||
"name": "PiotrNarel",
|
||||
"id": 22528145,
|
||||
"comment_id": 1771460591,
|
||||
"created_at": "2023-10-19T17:55:59Z",
|
||||
"repoId": 79162682,
|
||||
"pullRequestNo": 9095
|
||||
}
|
||||
]
|
||||
}
|
@@ -10,7 +10,7 @@ Your download of <span class="downloaded-filename">Joplin</span> is in progress.
|
||||
|
||||
Access your notes on Windows, macOS or Linux.
|
||||
|
||||
<!-- DESKTOP-DOWNLOAD-LINKS --><a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-Setup-2.12.18.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a> <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a> <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a><!-- DESKTOP-DOWNLOAD-LINKS -->
|
||||
<!-- DESKTOP-DOWNLOAD-LINKS --><a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-Setup-2.12.19.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a> <a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-2.12.19.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a> <a href='https://objects.joplinusercontent.com/v2.12.19/Joplin-2.12.19.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a><!-- DESKTOP-DOWNLOAD-LINKS -->
|
||||
|
||||
</div>
|
||||
|
||||
|
32
readme/news/20231023-white-hat-hackers.md
Normal file
32
readme/news/20231023-white-hat-hackers.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
tweet: Working in the shadows with white-hat hackers
|
||||
forum_url: https://discourse.joplinapp.org/t/33283
|
||||
---
|
||||
|
||||
# Working in the shadows with white-hat hackers
|
||||
|
||||
The majority of Joplin's development is carried out in the public domain. This includes the discussion of issues on GitHub, as well as the submission of pull requests and related discussions. The transparency of these processes allows for collaborative problem-solving and shared insights.
|
||||
|
||||
However, there is one aspect that operates behind closed doors, and for good reason: addressing cybersecurity vulnerabilities. It is imperative that these issues remain undisclosed until they have been resolved. Once a solution is implemented, it is usually accompanied by discreet commits and a message in the changelog to signify the progress made.
|
||||
|
||||
Typically, the process begins with an email from a security researcher. They provide valuable insights, such as a specially crafted note that triggers a bug, or an API call, along with an explanation of how the application's security can be circumvented. We examine the vulnerability, create a fix, and create automated test units to prevent any accidental reintroduction of the vulnerability in future code updates. An example of such a commit is: [9e90d9016daf79b5414646a93fd369aedb035071](https://github.com/laurent22/joplin/commit/9e90d9016daf79b5414646a93fd369aedb035071)
|
||||
|
||||
We then share our fix with the researcher for validation. Additionally, we often apply the fix to previous versions of Joplin, depending on the severity of the vulnerability.
|
||||
|
||||
The contribution of security researchers in this regard is immeasurable. They employ their ingenuity to identify inventive methods of bypassing existing security measures and often discover subtle flaws in the code that might otherwise go unnoticed.
|
||||
|
||||
We would like to express our sincere gratitude to the security researchers who have assisted us throughout the years in identifying and rectifying security vulnerabilities!
|
||||
|
||||
- [@Alise](https://github.com/a1ise)
|
||||
- @hexodotsh
|
||||
- [@ly1g3](https://github.com/ly1g3)
|
||||
- [@maple3142](https://twitter.com/maple3142)
|
||||
- Ademar Nowasky Junior
|
||||
- [Benjamin Harris](mailto:ben@mayhem.sg)
|
||||
- [Javier Olmedo](https://github.com/JavierOlmedo)
|
||||
- [Jubair Rehman Yousafzai](https://twitter.com/newfolderj)
|
||||
- lin@UCCU Hacker
|
||||
- [personalizedrefrigerator](https://github.com/personalizedrefrigerator)
|
||||
- [Phil Holbrook](https://twitter.com/fhlipZero)
|
||||
- [RyotaK](https://ryotak.net/)
|
||||
- [Yaniv Nizry](https://twitter.com/YNizry)
|
@@ -32,6 +32,14 @@ Additionally, every few minutes, the client is going to poll the server and down
|
||||
|
||||
- `packages/lib/*Api.ts`: The `file-api-driver` will call some low-level API to perform its operations. For example `file-api-driver-local` will use the `fs` package to read/write files, `file-api-driver-amazon-s3` will use the AWS API to work with S3. In some cases however such a low-level API is not available - in that case, we usually create an `*Api.ts` file, which is used by the file API driver to perform its operations. For example, there is a `JoplinServerApi.ts`, which is used to connect to Joplin Server.
|
||||
|
||||
- In general, each object in the database is represented by a `BaseModel` class. Then each object than can be synced is represented by a `BaseItem` class that inherits from `BaseModel`. This class is where many sync-related utilities can be found such as `itemsThatNeedSync()` or methods that encrypt items so that they can be uploaded when E2EE is enabled.
|
||||
|
||||
- The state of each item is saved to the `sync_items` table. There is saved in particular the `sync_time` property which tells when the item was last synced. It is then used to decide what needs to be synced or not. Additional sync-related properties include `sync_disabled`, which is used in the rare case an item cannot be synced at all - for example if blocked by Dropbox for being "restricted content" (copyrighted), or is over the limit on Joplin Cloud. Each entry in `sync_items` is scoped to a sync target (`sync_target` property), so theoretically it's possible to sync the same items to multiple sync targets.
|
||||
|
||||
## Testing
|
||||
|
||||
By default, the test units synchronise with an in-memory sync target, which is fast and is usually enough to verify most behaviours. The test units however can be configured to sync with a specific sync target, such as the file system, Nextcloud, Joplin Server, etc. To do so, modify `packages/lib/testing/test-utils.ts` and change `setSyncTargetName()` to the relevant sync target. You may also need to add or modify the relevant files in `~/joplin-credentials/*`. See the `initFileApi()` method in `test-utils.ts` for more details.
|
||||
|
||||
## See also
|
||||
|
||||
- [Synchronisation lock](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md)
|
||||
|
269
yarn.lock
269
yarn.lock
@@ -4664,10 +4664,10 @@ __metadata:
|
||||
"@playwright/test": 1.38.1
|
||||
"@testing-library/react-hooks": 8.0.1
|
||||
"@types/jest": 29.5.4
|
||||
"@types/mustache": 4.2.2
|
||||
"@types/mustache": 4.2.3
|
||||
"@types/node": 18.17.19
|
||||
"@types/react": 18.2.23
|
||||
"@types/react-redux": 7.1.26
|
||||
"@types/react": 18.2.31
|
||||
"@types/react-redux": 7.1.27
|
||||
"@types/styled-components": 5.1.28
|
||||
async-mutex: 0.4.0
|
||||
codemirror: 5.65.9
|
||||
@@ -4675,7 +4675,7 @@ __metadata:
|
||||
compare-versions: 6.1.0
|
||||
countable: 3.0.1
|
||||
debounce: 1.2.1
|
||||
electron: 25.8.1
|
||||
electron: 25.9.0
|
||||
electron-builder: 24.4.0
|
||||
electron-window-state: 5.0.3
|
||||
formatcoords: 1.1.3
|
||||
@@ -4692,7 +4692,7 @@ __metadata:
|
||||
md5: 2.3.0
|
||||
moment: 2.29.4
|
||||
mustache: 4.2.0
|
||||
nan: 2.17.0
|
||||
nan: 2.18.0
|
||||
node-fetch: 2.6.7
|
||||
node-notifier: 10.0.1
|
||||
node-rsa: 1.1.1
|
||||
@@ -4701,8 +4701,8 @@ __metadata:
|
||||
react: 18.2.0
|
||||
react-datetime: 3.2.0
|
||||
react-dom: 18.2.0
|
||||
react-redux: 8.1.2
|
||||
react-select: 5.7.5
|
||||
react-redux: 8.1.3
|
||||
react-select: 5.7.7
|
||||
react-test-renderer: 18.2.0
|
||||
react-toggle-button: 2.2.0
|
||||
react-tooltip: 4.5.1
|
||||
@@ -4745,7 +4745,7 @@ __metadata:
|
||||
"@lezer/highlight": 1.1.4
|
||||
"@react-native-community/clipboard": 1.5.1
|
||||
"@react-native-community/datetimepicker": 7.5.0
|
||||
"@react-native-community/geolocation": 3.0.6
|
||||
"@react-native-community/geolocation": 3.1.0
|
||||
"@react-native-community/netinfo": 9.4.1
|
||||
"@react-native-community/push-notification-ios": 1.11.0
|
||||
"@react-native-community/slider": 4.4.3
|
||||
@@ -4754,9 +4754,9 @@ __metadata:
|
||||
"@tsconfig/react-native": 2.0.2
|
||||
"@types/fs-extra": 11.0.2
|
||||
"@types/jest": 29.5.4
|
||||
"@types/react": 18.2.23
|
||||
"@types/react": 18.2.31
|
||||
"@types/react-native": 0.70.6
|
||||
"@types/react-redux": 7.1.26
|
||||
"@types/react-redux": 7.1.27
|
||||
"@types/tar-stream": 2.2.3
|
||||
assert-browserify: 2.0.0
|
||||
babel-jest: 29.6.4
|
||||
@@ -4789,7 +4789,7 @@ __metadata:
|
||||
react-native-device-info: 10.9.0
|
||||
react-native-dialogbox: 0.6.10
|
||||
react-native-document-picker: 9.0.1
|
||||
react-native-dropdownalert: 4.5.1
|
||||
react-native-dropdownalert: 5.1.0
|
||||
react-native-exit-app: 2.0.0
|
||||
react-native-file-viewer: 2.1.5
|
||||
react-native-fingerprint-scanner: 6.0.0
|
||||
@@ -4811,9 +4811,9 @@ __metadata:
|
||||
react-native-vector-icons: 10.0.0
|
||||
react-native-version-info: 1.1.1
|
||||
react-native-vosk: 0.1.12
|
||||
react-native-webview: 13.5.1
|
||||
react-native-webview: 13.6.0
|
||||
react-native-zip-archive: 6.1.0
|
||||
react-redux: 8.1.2
|
||||
react-redux: 8.1.3
|
||||
react-test-renderer: 18.2.0
|
||||
redux: 4.2.1
|
||||
rn-fetch-blob: 0.12.0
|
||||
@@ -4856,8 +4856,8 @@ __metadata:
|
||||
"@replit/codemirror-vim": 6.0.14
|
||||
"@testing-library/react-hooks": 8.0.1
|
||||
"@types/jest": 29.5.4
|
||||
"@types/react": 18.2.23
|
||||
"@types/react-redux": 7.1.26
|
||||
"@types/react": 18.2.31
|
||||
"@types/react-redux": 7.1.27
|
||||
"@types/styled-components": 5.1.28
|
||||
jest: 29.6.3
|
||||
jest-environment-jsdom: 29.6.3
|
||||
@@ -4892,7 +4892,7 @@ __metadata:
|
||||
resolution: "@joplin/fork-sax@workspace:packages/fork-sax"
|
||||
dependencies:
|
||||
standard: 17.1.0
|
||||
tap: 16.3.8
|
||||
tap: 16.3.9
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -4939,7 +4939,7 @@ __metadata:
|
||||
"@types/nanoid": 3.0.0
|
||||
"@types/node": 18.17.19
|
||||
"@types/node-rsa": 1.1.2
|
||||
"@types/react": 18.2.23
|
||||
"@types/react": 18.2.31
|
||||
"@types/uuid": 9.0.4
|
||||
async-mutex: 0.4.0
|
||||
base-64: 1.0.0
|
||||
@@ -5011,8 +5011,8 @@ __metadata:
|
||||
"@joplin/lib": ~2.13
|
||||
"@types/jest": 29.5.4
|
||||
"@types/pdfjs-dist": 2.10.378
|
||||
"@types/react": 18.2.23
|
||||
"@types/react-dom": 18.2.7
|
||||
"@types/react": 18.2.31
|
||||
"@types/react-dom": 18.2.14
|
||||
"@types/styled-components": 5.1.28
|
||||
async-mutex: 0.4.0
|
||||
babel-jest: 29.6.4
|
||||
@@ -5103,7 +5103,7 @@ __metadata:
|
||||
jest: 29.6.4
|
||||
jest-environment-jsdom: 29.6.4
|
||||
json-stringify-safe: 5.0.1
|
||||
katex: 0.16.8
|
||||
katex: 0.16.9
|
||||
markdown-it: 13.0.2
|
||||
markdown-it-abbr: 1.0.4
|
||||
markdown-it-anchor: 5.3.0
|
||||
@@ -5136,6 +5136,7 @@ __metadata:
|
||||
"@joplin/utils": ~2.13
|
||||
"@koa/cors": 3.4.3
|
||||
"@rmp135/sql-ts": 1.18.0
|
||||
"@types/bcryptjs": 2.4.5
|
||||
"@types/formidable": 3.4.3
|
||||
"@types/fs-extra": 11.0.2
|
||||
"@types/jest": 29.5.4
|
||||
@@ -5143,10 +5144,10 @@ __metadata:
|
||||
"@types/jsdom": 21.1.3
|
||||
"@types/koa": 2.13.9
|
||||
"@types/markdown-it": 12.2.3
|
||||
"@types/mustache": 4.2.2
|
||||
"@types/mustache": 4.2.3
|
||||
"@types/nodemailer": 6.4.11
|
||||
"@types/uuid": 9.0.4
|
||||
"@types/yargs": 17.0.25
|
||||
"@types/yargs": 17.0.26
|
||||
"@types/zxcvbn": 4.4.2
|
||||
bcryptjs: 2.4.3
|
||||
bulma: 0.9.4
|
||||
@@ -5197,11 +5198,11 @@ __metadata:
|
||||
"@rmp135/sql-ts": 1.18.0
|
||||
"@types/fs-extra": 11.0.2
|
||||
"@types/jest": 29.5.4
|
||||
"@types/markdown-it": 13.0.1
|
||||
"@types/mustache": 4.2.2
|
||||
"@types/markdown-it": 13.0.2
|
||||
"@types/mustache": 4.2.3
|
||||
"@types/node": 18.17.19
|
||||
"@types/node-fetch": 2.6.6
|
||||
"@types/yargs": 17.0.25
|
||||
"@types/yargs": 17.0.26
|
||||
compare-versions: 6.1.0
|
||||
dayjs: 1.11.10
|
||||
execa: 4.1.0
|
||||
@@ -5220,7 +5221,7 @@ __metadata:
|
||||
relative: 3.0.2
|
||||
request: 2.88.2
|
||||
rss: 1.2.2
|
||||
sass: 1.66.1
|
||||
sass: 1.67.0
|
||||
sharp: 0.32.6
|
||||
source-map-support: 0.5.21
|
||||
sqlite3: 5.1.6
|
||||
@@ -7206,13 +7207,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-native-community/geolocation@npm:3.0.6":
|
||||
version: 3.0.6
|
||||
resolution: "@react-native-community/geolocation@npm:3.0.6"
|
||||
"@react-native-community/geolocation@npm:3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "@react-native-community/geolocation@npm:3.1.0"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: 813df03599c064639bd5fe90922ab80d20d6224deffc8c6bfb02b7ade3d20f80e1ef6ac19c5d4394e901cca9a9fdb36eceda334a0ea6213e47c3c066921d09a9
|
||||
checksum: defc42ed11d0a3cc697d5db56c17cc19af2ca35b32674dc7323b40a458f5066b051191e5dc58c367f2643a0ed70b47e8eb2ac87ab1566e642ca0cbab1a1f09a9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7721,6 +7722,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/bcryptjs@npm:2.4.5":
|
||||
version: 2.4.5
|
||||
resolution: "@types/bcryptjs@npm:2.4.5"
|
||||
checksum: f721d72d8e1374ee2a342ce90cc902e2308cd059317af6e663d752537e704ea73bb119a2d34a6a68475f80abc1342635f48570119e0381f83a202724974f1e9f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/body-parser@npm:*":
|
||||
version: 1.19.2
|
||||
resolution: "@types/body-parser@npm:1.19.2"
|
||||
@@ -8149,13 +8157,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/markdown-it@npm:13.0.1":
|
||||
version: 13.0.1
|
||||
resolution: "@types/markdown-it@npm:13.0.1"
|
||||
"@types/markdown-it@npm:13.0.2":
|
||||
version: 13.0.2
|
||||
resolution: "@types/markdown-it@npm:13.0.2"
|
||||
dependencies:
|
||||
"@types/linkify-it": "*"
|
||||
"@types/mdurl": "*"
|
||||
checksum: 184d383ac21903a9e6be1639cde2b0cc082d0366b423fd8a69d0f37d9d1d36338f66611226ba4ef1da6148f370a62e08f688e8147ead43d429d6ff213c38c062
|
||||
checksum: fe1f6a12ee8ad2246359376431a30d22c9b603e63e93e3e27d6920840934b9764034679a4d0b01ec54b0693c8d5c42012ec34715cba4f5b0736b8a4b66db4c74
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8203,10 +8211,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/mustache@npm:4.2.2":
|
||||
version: 4.2.2
|
||||
resolution: "@types/mustache@npm:4.2.2"
|
||||
checksum: 1fa67a519f4302c96615524be4c8248067da02ca047bae9d4c4bb79977135ac7c15dcc388e7c70b8a817b9497004d5ca5c77a155dcb096bea16d53d4cdbe75d2
|
||||
"@types/mustache@npm:4.2.3":
|
||||
version: 4.2.3
|
||||
resolution: "@types/mustache@npm:4.2.3"
|
||||
checksum: c2c7cf749a84a622648c7088fb10350b84ea935145514b6c51d17e808a4b4972fb137273339f4d93160a3c496a3943dab3be93251d74c185730daa300a299e2f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8338,12 +8346,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:18.2.7":
|
||||
version: 18.2.7
|
||||
resolution: "@types/react-dom@npm:18.2.7"
|
||||
"@types/react-dom@npm:18.2.14":
|
||||
version: 18.2.14
|
||||
resolution: "@types/react-dom@npm:18.2.14"
|
||||
dependencies:
|
||||
"@types/react": "*"
|
||||
checksum: e02ea908289a7ad26053308248d2b87f6aeafd73d0e2de2a3d435947bcea0422599016ffd1c3e38ff36c42f5e1c87c7417f05b0a157e48649e4a02f21727d54f
|
||||
checksum: 890289c70d1966c168037637c09cacefe6205bdd27a33252144a6b432595a2943775ac1a1accac0beddaeb67f8fdf721e076acb1adc990b08e51c3d9fd4e780c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8365,15 +8373,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-redux@npm:7.1.26":
|
||||
version: 7.1.26
|
||||
resolution: "@types/react-redux@npm:7.1.26"
|
||||
"@types/react-redux@npm:7.1.27":
|
||||
version: 7.1.27
|
||||
resolution: "@types/react-redux@npm:7.1.27"
|
||||
dependencies:
|
||||
"@types/hoist-non-react-statics": ^3.3.0
|
||||
"@types/react": "*"
|
||||
hoist-non-react-statics: ^3.3.0
|
||||
redux: ^4.0.0
|
||||
checksum: 7f299f15ca8790c2e2683ad776ea4cbd5c38247f7a9fbddbc64ff4b235883238c772dc0e2687e328ba690d3b0c6a51b026c5fad719183595df87a91a1060094c
|
||||
checksum: 38fcc56f013e81e9a3125fd75acdacb4cdb5f9fe49402330b4783923f236d2d12ccdd2240ffa42e5bbb75900acd55393c00e0ca5dd6cab91a7b7e39e74ac62b4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8397,14 +8405,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:18.2.23":
|
||||
version: 18.2.23
|
||||
resolution: "@types/react@npm:18.2.23"
|
||||
"@types/react@npm:18.2.31":
|
||||
version: 18.2.31
|
||||
resolution: "@types/react@npm:18.2.31"
|
||||
dependencies:
|
||||
"@types/prop-types": "*"
|
||||
"@types/scheduler": "*"
|
||||
csstype: ^3.0.2
|
||||
checksum: efb9d1ed1940c0e7ba08a21ffba5e266d8dbbb8fe618cfb97bc902dfc96385fdd8189e3f7f64b4aa13134f8e61947d60560deb23be151253c3a97b0d070897ca
|
||||
checksum: b11be8e39174d3303e308461400889e353e422d22b01d09795b2c35b7b99d5351716503d9ec5c58e4c2c871249603fa52840d45a34fb5901dd7a26e06129c716
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8535,12 +8543,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/yargs@npm:17.0.25":
|
||||
version: 17.0.25
|
||||
resolution: "@types/yargs@npm:17.0.25"
|
||||
"@types/yargs@npm:17.0.26":
|
||||
version: 17.0.26
|
||||
resolution: "@types/yargs@npm:17.0.26"
|
||||
dependencies:
|
||||
"@types/yargs-parser": "*"
|
||||
checksum: ef57926de514f5eb0a182167a63930bd7d2eb33d89b6041760f690d50b2019d7901b30c33ab7d03b3fa66a5004f0f81e36186d8f9e5e583a27b9ce331d2a5276
|
||||
checksum: 26611969674f4972080c3b22239d4579eaadc5287f95f7802f893c4a9bb292c141467bd70f1e66eb834486c63a23c4f10032618b3d2e7b1ddc05051d08db4078
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10158,7 +10166,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"asap@npm:^2.0.0, asap@npm:~2.0.3, asap@npm:~2.0.6":
|
||||
"asap@npm:^2.0.0, asap@npm:~2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "asap@npm:2.0.6"
|
||||
checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d
|
||||
@@ -13066,13 +13074,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"core-js@npm:^1.0.0":
|
||||
version: 1.2.7
|
||||
resolution: "core-js@npm:1.2.7"
|
||||
checksum: 0b76371bfa98708351cde580f9287e2360d2209920e738ae950ae74ad08639a2e063541020bf666c28778956fc356ed9fe56d962129c88a87a6a4a0612526c75
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"core-util-is@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "core-util-is@npm:1.0.2"
|
||||
@@ -15340,16 +15341,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"electron@npm:25.8.1":
|
||||
version: 25.8.1
|
||||
resolution: "electron@npm:25.8.1"
|
||||
"electron@npm:25.9.0":
|
||||
version: 25.9.0
|
||||
resolution: "electron@npm:25.9.0"
|
||||
dependencies:
|
||||
"@electron/get": ^2.0.0
|
||||
"@types/node": ^18.11.18
|
||||
extract-zip: ^2.0.1
|
||||
bin:
|
||||
electron: cli.js
|
||||
checksum: 3305f0d3e3d68d8921533b4fd42003812778bd90d1884b2baa859b3a7d900354298e63298fbfbfcac9267223e0d0d3de584137aec52b764081f3c68bf4b09efc
|
||||
checksum: 7d9bccf0af89ba7af1f904a58fdc623e6cade95e02956d4ccf6d998dadd2bac3d2a4f782a3d8a0f8bd92bdfc0d40c7a3b9b8af6bf50f104d9298c3ac4c642823
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17064,21 +17065,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fbjs@npm:^0.8.9":
|
||||
version: 0.8.18
|
||||
resolution: "fbjs@npm:0.8.18"
|
||||
dependencies:
|
||||
core-js: ^1.0.0
|
||||
isomorphic-fetch: ^2.1.1
|
||||
loose-envify: ^1.0.0
|
||||
object-assign: ^4.1.0
|
||||
promise: ^7.1.1
|
||||
setimmediate: ^1.0.5
|
||||
ua-parser-js: ^0.7.30
|
||||
checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fd-slicer@npm:~1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "fd-slicer@npm:1.1.0"
|
||||
@@ -20601,7 +20587,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-stream@npm:^1.0.1, is-stream@npm:^1.1.0":
|
||||
"is-stream@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "is-stream@npm:1.1.0"
|
||||
checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae
|
||||
@@ -20849,16 +20835,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isomorphic-fetch@npm:^2.1.1":
|
||||
version: 2.2.1
|
||||
resolution: "isomorphic-fetch@npm:2.2.1"
|
||||
dependencies:
|
||||
node-fetch: ^1.0.1
|
||||
whatwg-fetch: ">=0.10.0"
|
||||
checksum: bb5daa7c3785d6742f4379a81e55b549a469503f7c9bf9411b48592e86632cf5e8fe8ea878dba185c0f33eb7c510c23abdeb55aebfdf5d3c70f031ced68c5424
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isstream@npm:~0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "isstream@npm:0.1.2"
|
||||
@@ -22734,14 +22710,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"katex@npm:0.16.8":
|
||||
version: 0.16.8
|
||||
resolution: "katex@npm:0.16.8"
|
||||
"katex@npm:0.16.9":
|
||||
version: 0.16.9
|
||||
resolution: "katex@npm:0.16.9"
|
||||
dependencies:
|
||||
commander: ^8.3.0
|
||||
bin:
|
||||
katex: cli.js
|
||||
checksum: 4e75b4786101cc5eca0404bb814b2985bec506846f9015e9bf00207a3af14215e341ee62b6e7af2455a1032f8244e47a754642f250eea43d7b8007146ac01fae
|
||||
checksum: 861194dfd4d86505e657f688fb73048d46ac498edafce71199502a35b03c0ecc35ba930c631be79c4a09d90a0d23476673cd52f6bc367c7a161854d64005fa95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -23551,7 +23527,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.3.1, loose-envify@npm:^1.4.0":
|
||||
"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
dependencies:
|
||||
@@ -25851,7 +25827,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:2.17.0, nan@npm:^2.12.1":
|
||||
"nan@npm:2.18.0":
|
||||
version: 2.18.0
|
||||
resolution: "nan@npm:2.18.0"
|
||||
dependencies:
|
||||
node-gyp: latest
|
||||
checksum: 4fe42f58456504eab3105c04a5cffb72066b5f22bd45decf33523cb17e7d6abc33cca2a19829407b9000539c5cb25f410312d4dc5b30220167a3594896ea6a0a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:^2.12.1":
|
||||
version: 2.17.0
|
||||
resolution: "nan@npm:2.17.0"
|
||||
dependencies:
|
||||
@@ -26160,16 +26145,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:^1.0.1":
|
||||
version: 1.7.3
|
||||
resolution: "node-fetch@npm:1.7.3"
|
||||
dependencies:
|
||||
encoding: ^0.1.11
|
||||
is-stream: ^1.0.1
|
||||
checksum: 3bb0528c05d541316ebe52770d71ee25a6dce334df4231fd55df41a644143e07f068637488c18a5b0c43f05041dbd3346752f9e19b50df50569a802484544d5b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:^2.2.0, node-fetch@npm:^2.5.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1":
|
||||
version: 2.6.6
|
||||
resolution: "node-fetch@npm:2.6.6"
|
||||
@@ -28740,15 +28715,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"promise@npm:^7.1.1":
|
||||
version: 7.3.1
|
||||
resolution: "promise@npm:7.3.1"
|
||||
dependencies:
|
||||
asap: ~2.0.3
|
||||
checksum: 475bb069130179fbd27ed2ab45f26d8862376a137a57314cf53310bdd85cc986a826fd585829be97ebc0aaf10e9d8e68be1bfe5a4a0364144b1f9eedfa940cf1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prompts@npm:^2.0.1, prompts@npm:^2.4.0":
|
||||
version: 2.4.2
|
||||
resolution: "prompts@npm:2.4.2"
|
||||
@@ -28779,16 +28745,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:15.5.10":
|
||||
version: 15.5.10
|
||||
resolution: "prop-types@npm:15.5.10"
|
||||
dependencies:
|
||||
fbjs: ^0.8.9
|
||||
loose-envify: ^1.3.1
|
||||
checksum: 3e928ad5afa5124d52a341a706170628e7b0caa9340515782be6a767261e6eb0e473116188bb8efbe9d9b62cb3c9501c71bf4ab7d34f2507294ee34c90de6964
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.5.7, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
|
||||
version: 15.7.2
|
||||
resolution: "prop-types@npm:15.7.2"
|
||||
@@ -29348,12 +29304,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-dropdownalert@npm:4.5.1":
|
||||
version: 4.5.1
|
||||
resolution: "react-native-dropdownalert@npm:4.5.1"
|
||||
dependencies:
|
||||
prop-types: 15.5.10
|
||||
checksum: 16346105f130f1aefe8ed4c9171524ce931f2a924c6fa95b41291f99a5613a7c4fb9a3f75b19ef280ac8d47f8ba13ebadf596174d83a92cbbdd00c278c2e2b9f
|
||||
"react-native-dropdownalert@npm:5.1.0":
|
||||
version: 5.1.0
|
||||
resolution: "react-native-dropdownalert@npm:5.1.0"
|
||||
checksum: 595e409967a28e5305b7895407a801c6eb05091277eaa7362b30dce18213d695dcbf0811c69cbaa20f35ce9aa21f13d44235dd35717119e0020a3159bd06b0ef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29597,16 +29551,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-webview@npm:13.5.1":
|
||||
version: 13.5.1
|
||||
resolution: "react-native-webview@npm:13.5.1"
|
||||
"react-native-webview@npm:13.6.0":
|
||||
version: 13.6.0
|
||||
resolution: "react-native-webview@npm:13.6.0"
|
||||
dependencies:
|
||||
escape-string-regexp: 2.0.0
|
||||
invariant: 2.2.4
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: f7536d0832c401d75c6f92bb997daeb7fe9de82471bf50bbc895421b82cd355da476dc393b186e9256d5759c345f5c97f2f8185fd079d3cb6fc174ca8ee70ba5
|
||||
checksum: f7220fb18dcf5e9631674831ac8beb3bc4b99863f812676c25394a3cce487975b05c8fc795b0ee4ff1ead43da304ddea104f5e6683ea3f4c3a135b14ae193069
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29769,9 +29723,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:8.1.2":
|
||||
version: 8.1.2
|
||||
resolution: "react-redux@npm:8.1.2"
|
||||
"react-redux@npm:8.1.3":
|
||||
version: 8.1.3
|
||||
resolution: "react-redux@npm:8.1.3"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.12.1
|
||||
"@types/hoist-non-react-statics": ^3.3.1
|
||||
@@ -29797,7 +29751,7 @@ __metadata:
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
checksum: 4d5976b0f721e4148475871fcabce2fee875cc7f70f9a292f3370d63b38aa1dd474eb303c073c5555f3e69fc732f3bac05303def60304775deb28361e3f4b7cc
|
||||
checksum: 192ea6f6053148ec80a4148ec607bc259403b937e515f616a1104ca5ab357e97e98b8245ed505a17afee67a72341d4a559eaca9607968b4a422aa9b44ba7eb89
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29808,9 +29762,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-select@npm:5.7.5":
|
||||
version: 5.7.5
|
||||
resolution: "react-select@npm:5.7.5"
|
||||
"react-select@npm:5.7.7":
|
||||
version: 5.7.7
|
||||
resolution: "react-select@npm:5.7.7"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.12.0
|
||||
"@emotion/cache": ^11.4.0
|
||||
@@ -29824,7 +29778,7 @@ __metadata:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 88f2d94c4a6778df525a9fb5d7acac1bf34821f6efcfdc5927ec608f5f933cf3f47e1c4e4fd3b92d7b2ba1d91e44595d45ac4e2fd7528ba420086008ac5a81cf
|
||||
checksum: 6fd0c211d377addba6e6762a614ae674936df39a3f46ec19fd06e7acae8d6cadeb93d4723b10e25eff1ff8235077bae9459f293936334d82b28fe5071081c057
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -31190,16 +31144,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sass@npm:1.66.1":
|
||||
version: 1.66.1
|
||||
resolution: "sass@npm:1.66.1"
|
||||
"sass@npm:1.67.0":
|
||||
version: 1.67.0
|
||||
resolution: "sass@npm:1.67.0"
|
||||
dependencies:
|
||||
chokidar: ">=3.0.0 <4.0.0"
|
||||
immutable: ^4.0.0
|
||||
source-map-js: ">=0.6.2 <2.0.0"
|
||||
bin:
|
||||
sass: sass.js
|
||||
checksum: 74fc11d0fcd5e16c5331b57dd59865705a299c64e89f2b99646869caeb011dc8d0b6144a6c74a90c264e9ef70654207dbf44fc9b7e3393f8bd14809b904c8a52
|
||||
checksum: 9e7566e8b7386cf265dddcdb266a023fb5759c5a8f48a11da199c8bf419e5f08f4ff6404d85d6bf5eac01e1f7c7061fdb6b7b65cbfda164e59b0a06b72ac8567
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -33297,9 +33251,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tap@npm:16.3.8":
|
||||
version: 16.3.8
|
||||
resolution: "tap@npm:16.3.8"
|
||||
"tap@npm:16.3.9":
|
||||
version: 16.3.9
|
||||
resolution: "tap@npm:16.3.9"
|
||||
dependencies:
|
||||
"@isaacs/import-jsx": ^4.0.1
|
||||
"@types/react": ^17.0.52
|
||||
@@ -33343,7 +33297,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
tap: bin/run.js
|
||||
checksum: b63e064f1ea20aa4cbe8cd40fbe780def9757b637caaae8ee24d96b184d8627421045dd56168b21715f6ebff77e88db774cda0b80af113ae33432641aefcbb58
|
||||
checksum: 5d2f671681ad6199fd7a1abc48f1b2010e77fd5a757413d9c12e78fb5b841ae4639ee4913fd40c8bf7de7af5e7bab0453c8b7bb6174fd1e1d76948a248712e56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -34696,13 +34650,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ua-parser-js@npm:^0.7.30":
|
||||
version: 0.7.31
|
||||
resolution: "ua-parser-js@npm:0.7.31"
|
||||
checksum: e2f8324a83d1715601576af85b2b6c03890699aaa7272950fc77ea925c70c5e4f75060ae147dc92124e49f7f0e3d6dd2b0a91e7f40d267e92df8894be967ba8b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5":
|
||||
version: 1.0.6
|
||||
resolution: "uc.micro@npm:1.0.6"
|
||||
@@ -35865,7 +35812,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-fetch@npm:>=0.10.0, whatwg-fetch@npm:^3.0.0":
|
||||
"whatwg-fetch@npm:^3.0.0":
|
||||
version: 3.6.2
|
||||
resolution: "whatwg-fetch@npm:3.6.2"
|
||||
checksum: ee976b7249e7791edb0d0a62cd806b29006ad7ec3a3d89145921ad8c00a3a67e4be8f3fb3ec6bc7b58498724fd568d11aeeeea1f7827e7e1e5eae6c8a275afed
|
||||
|
Reference in New Issue
Block a user