mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-03 23:32:00 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0bed69a77 | ||
|
|
08bdff221f | ||
|
|
cce0dd864a | ||
|
|
dc21be248d | ||
|
|
d85f4305b6 | ||
|
|
945de8d17b | ||
|
|
a0b6f2a937 | ||
|
|
c3a71ba203 | ||
|
|
4072194e9a | ||
|
|
d9767bd2c6 | ||
|
|
3f2000a9dd | ||
|
|
fb82b929d9 | ||
|
|
45c8501b7e | ||
|
|
045b2d8c2b | ||
|
|
dd7acaff31 | ||
|
|
f251427d34 | ||
|
|
18e0b08c28 | ||
|
|
c615726603 | ||
|
|
a658e68276 | ||
|
|
f2d8f4fb18 | ||
|
|
34aebb46f2 | ||
|
|
d6d50ce650 | ||
|
|
38c94f318e | ||
|
|
9578125b22 | ||
|
|
871178fa7d | ||
|
|
fa2900709e | ||
|
|
7b219b7ab2 | ||
|
|
98eeb4bd01 | ||
|
|
1cc68739fb | ||
|
|
f7812d283e | ||
|
|
d4030c4225 | ||
|
|
364b22c5b6 | ||
|
|
d37b6f3751 | ||
|
|
7c36f326ac | ||
|
|
d7594a3bb0 | ||
|
|
4e737f397a | ||
|
|
42af5e7339 | ||
|
|
6701668e69 | ||
|
|
dafb6b05f1 | ||
|
|
6b8b8d0694 | ||
|
|
9853882787 | ||
|
|
324fa333d4 | ||
|
|
0818e7856e | ||
|
|
4020986f29 | ||
|
|
68543655ce | ||
|
|
1106aa85be | ||
|
|
e58fcf9402 | ||
|
|
5fb43fdfbc | ||
|
|
5051807374 | ||
|
|
62bab6ece5 | ||
|
|
8e0812de9d | ||
|
|
5216b47b0d |
No files matched your search
@@ -18,26 +18,29 @@ Examples of good titles:
|
||||
|
||||
- Desktop: Resolves #123: Added new setting to change font
|
||||
- Mobile, Desktop: Fixes #456: Fixed config screen error
|
||||
- All: Resolves #777: Made synchronisation faster
|
||||
- Mobile, Desktop, Cli: Resolves #777: Improved note search performance
|
||||
|
||||
Valid prefixes:
|
||||
|
||||
- `All` — change applies to all client apps (Desktop, Mobile and CLI)
|
||||
- `Desktop` — the Windows/macOS/Linux app (Electron app)
|
||||
- `Mobile` — the mobile app (both Android and iOS)
|
||||
- `Android` — only the Android app
|
||||
- `iOS` — only the iOS app
|
||||
- `Windows` — Windows-specific changes
|
||||
- `Linux` — Linux-specific changes
|
||||
- `macOS` — macOS-specific changes
|
||||
- `Cli` — the command line app
|
||||
- `Server` — the Joplin Server
|
||||
- `Clipper` — the web clipper browser extension
|
||||
- `Transcribe` — the Transcribe server
|
||||
- `Plugins` — the plugin API or built-in plugins
|
||||
- `Plugin Repo` — the plugin repository
|
||||
- `Api` — the data API
|
||||
- `Cloud` — Joplin Cloud
|
||||
- `Tools` — internal scripts and build tools
|
||||
- `CI` — continuous integration and GitHub workflows
|
||||
- `Doc` — documentation, README, website content
|
||||
- `Chore` — maintenance work that does not fit any of the above (dependency bumps, refactoring, cleanup)
|
||||
|
||||
If the change targets two areas, separate them with commas — for example "Desktop, Mobile". If it applies to all client apps, use "All".
|
||||
If the change targets several areas, separate them with commas — for example "Mobile, Desktop, Cli: Resolves #777: Improved note search performance".
|
||||
|
||||
-->
|
||||
@@ -20,7 +20,12 @@ module.exports = async ({ github, context, core }) => {
|
||||
const softCheckUsers = ['laurent22', 'personalizedrefrigerator', 'mrjo118', 'tessus', 'CalebJohn', 'Rygaa'];
|
||||
const autoClosedLabel = 'auto-closed: invalid-title';
|
||||
|
||||
const prefix = '(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Web|Transcribe|Plugins|CI|Plugin Repo|Doc)';
|
||||
// Product-area prefixes are kept in sync with the list recognised by
|
||||
// packages/tools/git-changelog.ts so that valid titles are picked up by
|
||||
// the changelog generator. Non-product prefixes (Tools, Chore, CI, Doc,
|
||||
// Plugin Repo) are administrative categories for changes that do not
|
||||
// ship to users and so do not appear in the changelog.
|
||||
const prefix = '(Desktop|Mobile|Android|iOS|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
|
||||
const prefixList = `${prefix}(,\\s*${prefix})*`;
|
||||
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
|
||||
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
|
||||
@@ -107,11 +112,11 @@ module.exports = async ({ github, context, core }) => {
|
||||
commentMarker,
|
||||
`@${author} the pull request title does not match the required format.`,
|
||||
'',
|
||||
'Please prefix the title with the area you are targeting, then add the issue you are addressing. For example:',
|
||||
'Please prefix the title with the area you are targeting, then add the issue you are addressing. If the change targets several areas, separate them with commas. For example:',
|
||||
'',
|
||||
'- `Desktop: Resolves #123: Added new setting to change font`',
|
||||
'- `Mobile, Desktop: Fixes #456: Fixed config screen error`',
|
||||
'- `All: Resolves #777: Made synchronisation faster`',
|
||||
'- `Mobile, Desktop, Cli: Resolves #777: Improved note search performance`',
|
||||
'',
|
||||
'See the [pull request template](https://github.com/laurent22/joplin/blob/dev/.github/PULL_REQUEST_TEMPLATE) for the list of valid prefixes and the full specification.',
|
||||
'',
|
||||
|
||||
@@ -54,7 +54,7 @@ runs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '24.15'
|
||||
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
|
||||
# environments and this breaks actions/setup-node.
|
||||
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
|
||||
|
||||
+28
@@ -388,6 +388,7 @@ packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
|
||||
packages/app-desktop/gui/NoteListItem/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/noteTitleTooltip.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
@@ -1269,6 +1270,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1442,6 +1446,7 @@ packages/lib/mimeUtils.test.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1489,6 +1494,7 @@ packages/lib/models/utils/paginatedFeed.js
|
||||
packages/lib/models/utils/paginationToSql.js
|
||||
packages/lib/models/utils/readOnly.test.js
|
||||
packages/lib/models/utils/readOnly.js
|
||||
packages/lib/models/utils/resourceUtils.test.js
|
||||
packages/lib/models/utils/resourceUtils.js
|
||||
packages/lib/models/utils/types.js
|
||||
packages/lib/models/utils/userData.test.js
|
||||
@@ -1591,6 +1597,7 @@ packages/lib/services/database/migrations/49.js
|
||||
packages/lib/services/database/migrations/50.js
|
||||
packages/lib/services/database/migrations/51.js
|
||||
packages/lib/services/database/migrations/52.js
|
||||
packages/lib/services/database/migrations/53.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
@@ -1653,6 +1660,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
|
||||
packages/lib/services/keychain/KeychainServiceDriver.electron.js
|
||||
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
packages/lib/services/noteList/defaultListRenderer.js
|
||||
@@ -1663,6 +1684,11 @@ packages/lib/services/noteList/renderTemplate.js
|
||||
packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1760,6 +1786,7 @@ packages/lib/services/rest/routes/events.js
|
||||
packages/lib/services/rest/routes/folders.test.js
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/master_keys.js
|
||||
packages/lib/services/rest/routes/mcp.js
|
||||
packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
@@ -1806,6 +1833,7 @@ packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/MigrationHandler.js
|
||||
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
|
||||
@@ -414,6 +414,7 @@ packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
|
||||
packages/app-desktop/gui/NoteListItem/NoteListItem.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/noteTitleTooltip.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
@@ -1295,6 +1296,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1468,6 +1472,7 @@ packages/lib/mimeUtils.test.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1515,6 +1520,7 @@ packages/lib/models/utils/paginatedFeed.js
|
||||
packages/lib/models/utils/paginationToSql.js
|
||||
packages/lib/models/utils/readOnly.test.js
|
||||
packages/lib/models/utils/readOnly.js
|
||||
packages/lib/models/utils/resourceUtils.test.js
|
||||
packages/lib/models/utils/resourceUtils.js
|
||||
packages/lib/models/utils/types.js
|
||||
packages/lib/models/utils/userData.test.js
|
||||
@@ -1617,6 +1623,7 @@ packages/lib/services/database/migrations/49.js
|
||||
packages/lib/services/database/migrations/50.js
|
||||
packages/lib/services/database/migrations/51.js
|
||||
packages/lib/services/database/migrations/52.js
|
||||
packages/lib/services/database/migrations/53.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
@@ -1679,6 +1686,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
|
||||
packages/lib/services/keychain/KeychainServiceDriver.electron.js
|
||||
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
packages/lib/services/noteList/defaultListRenderer.js
|
||||
@@ -1689,6 +1710,11 @@ packages/lib/services/noteList/renderTemplate.js
|
||||
packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1786,6 +1812,7 @@ packages/lib/services/rest/routes/events.js
|
||||
packages/lib/services/rest/routes/folders.test.js
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/master_keys.js
|
||||
packages/lib/services/rest/routes/mcp.js
|
||||
packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
@@ -1832,6 +1859,7 @@ packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/MigrationHandler.js
|
||||
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
|
||||
@@ -36,7 +36,18 @@
|
||||
{{#featureLabelsOff}}
|
||||
<p class="unchecked-text"><i class="fas fa-times feature feature-off"></i>{{label}}</p>
|
||||
{{/featureLabelsOff}}
|
||||
|
||||
|
||||
{{#pricingTable}}
|
||||
<strong translate>Pricing</strong>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{{#rows}}
|
||||
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
|
||||
{{/rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/pricingTable}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a>
|
||||
|
||||
@@ -58,29 +69,27 @@
|
||||
const buttonId = 'subscribeButton-' + planName;
|
||||
const buttonElement = document.getElementById(buttonId);
|
||||
|
||||
if (stripePricesIds.monthly) {
|
||||
function handleResult() {
|
||||
console.info('Redirected to checkout');
|
||||
function handleResult() {
|
||||
console.info('Redirected to checkout');
|
||||
}
|
||||
|
||||
buttonElement.addEventListener("click", function(evt) {
|
||||
const priceId = stripePricesIds[subscriptionPeriod];
|
||||
|
||||
if (!priceId) {
|
||||
console.error('Invalid period: ' + subscriptionPeriod);
|
||||
return;
|
||||
}
|
||||
|
||||
buttonElement.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
evt.preventDefault();
|
||||
|
||||
const priceId = stripePricesIds[subscriptionPeriod];
|
||||
|
||||
if (!priceId) {
|
||||
console.error('Invalid period: ' + subscriptionPeriod);
|
||||
return;
|
||||
}
|
||||
|
||||
createCheckoutSession(priceId).then(function(data) {
|
||||
stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId
|
||||
})
|
||||
.then(handleResult);
|
||||
});
|
||||
createCheckoutSession(priceId).then(function(data) {
|
||||
stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId
|
||||
})
|
||||
.then(handleResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll('button.action-toggleIncreaseStorage')) {
|
||||
button.onclick = () => {
|
||||
|
||||
@@ -250,6 +250,8 @@
|
||||
|
||||
$('.toggle-button-self').click((event) => {
|
||||
event.preventDefault();
|
||||
// Self-hosting is currently yearly-only
|
||||
applyPeriod('yearly');
|
||||
setHostingType('self');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- Tabs for indentation
|
||||
- Single quotes for strings
|
||||
- Proper TypeScript types (avoid `any`)
|
||||
- Don't annotate return types or const types when TypeScript can infer them. Annotate only when TypeScript would otherwise infer `any`.
|
||||
- Comments should be only with `//` and should not contain jsdoc syntax
|
||||
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
|
||||
- When creating Jest tests, there should be only one `describe()` statement in the file.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
<!-- joplin-metadata-print-title = false -->
|
||||
<div class="fountain joplin-editable">
|
||||
<pre class="joplin-source" hidden data-joplin-language="fountain" data-joplin-source-open="```fountain " data-joplin-source-close=" ``` "><img src=x onerror="something()"/>...
|
||||
</pre>
|
||||
|
||||
<div class="page">
|
||||
<p><img src="x"/>...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
```fountain
|
||||
<img src=x onerror="something()"/>...
|
||||
```
|
||||
@@ -54,6 +54,8 @@
|
||||
overflow: auto;
|
||||
padding: var(--joplin-config-screen-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
|
||||
@@ -1110,6 +1110,17 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// when the note content is updated externally.
|
||||
const offsetBookmarkId = 2;
|
||||
const bookmark = editor.selection.getBookmark(offsetBookmarkId);
|
||||
|
||||
// This is a workaround to inject missing ink style for OneNote imported notes.
|
||||
// See https://github.com/laurent22/joplin/issues/15578 for more details.
|
||||
const oneNoteInkContentStyleId = 'joplin-onenote-content-style';
|
||||
const hasOneNoteInkContentStyle = !!editor.getDoc().getElementById(oneNoteInkContentStyleId);
|
||||
if (!hasOneNoteInkContentStyle && new DOMParser().parseFromString(result.html, 'text/html').querySelector('.ink-text, .ink-space, .container-outline')) {
|
||||
const styleElement = editor.getDoc().createElement('style');
|
||||
styleElement.id = oneNoteInkContentStyleId;
|
||||
styleElement.textContent = '.ink-text, .ink-space { display: inline-block; position: relative; vertical-align: bottom; } .container-outline { font-family: Calibri, sans-serif; font-size: 6pt; font-weight: normal; }';
|
||||
editor.getDoc().head.appendChild(styleElement);
|
||||
}
|
||||
const htmlAndCss = [
|
||||
`<style>${result.cssStrings?.join('\n')}</style>`,
|
||||
preprocessHtml(result.html),
|
||||
@@ -1659,4 +1670,3 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
};
|
||||
|
||||
export default forwardRef(TinyMCE);
|
||||
|
||||
@@ -51,3 +51,21 @@
|
||||
.note-list-item-wrapper.-provisional {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.note-title-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
z-index: 10000;
|
||||
max-width: 480px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--joplin-background-color2);
|
||||
color: var(--joplin-color2);
|
||||
border: 1px solid var(--joplin-divider-color);
|
||||
border-radius: 4px;
|
||||
font-family: var(--joplin-font-family);
|
||||
font-size: var(--joplin-font-size);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -73,6 +73,8 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
|
||||
const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]);
|
||||
|
||||
const displayTitle = useMemo(() => Note.displayTitle(props.note), [props.note]);
|
||||
|
||||
useItemElement(
|
||||
rootElement,
|
||||
noteId,
|
||||
@@ -84,6 +86,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
props.onDoubleClick,
|
||||
props.flow,
|
||||
itemEventHandlers,
|
||||
displayTitle,
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Singleton custom tooltip for note list items. Shown when the rendered title
|
||||
// is truncated by overflow:hidden / text-overflow:ellipsis. A single tooltip
|
||||
// element is shared by all rows.
|
||||
|
||||
let tooltipElement: HTMLDivElement | null = null;
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeRow: HTMLElement | null = null;
|
||||
|
||||
const showDelayMs = 500;
|
||||
const viewportMargin = 4;
|
||||
|
||||
const getTooltipElement = () => {
|
||||
if (tooltipElement) return tooltipElement;
|
||||
tooltipElement = document.createElement('div');
|
||||
tooltipElement.className = 'note-title-tooltip';
|
||||
tooltipElement.setAttribute('role', 'tooltip');
|
||||
document.body.appendChild(tooltipElement);
|
||||
return tooltipElement;
|
||||
};
|
||||
|
||||
// Looks for the element that actually holds the title text. Covers both
|
||||
// built-in renderers (compact and detailed); returns null for plugin
|
||||
// renderers that don't use those class hooks.
|
||||
const findTitleElement = (rowElement: HTMLElement): HTMLElement | null => {
|
||||
const detailed = rowElement.querySelector<HTMLElement>('[data-name="note.title"] > .content');
|
||||
if (detailed) return detailed;
|
||||
const compact = rowElement.querySelector<HTMLElement>('.title');
|
||||
if (compact) return compact;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTruncated = (element: HTMLElement) => {
|
||||
// Check descendants too, since the title text may live in a child span
|
||||
// (e.g. mark.js wraps highlighted matches).
|
||||
if (element.scrollWidth > element.clientWidth) return true;
|
||||
for (const child of Array.from(element.children) as HTMLElement[]) {
|
||||
if (child.scrollWidth > child.clientWidth) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const positionTooltip = (tooltip: HTMLElement, anchor: HTMLElement) => {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
tooltip.style.visibility = 'hidden';
|
||||
tooltip.style.display = 'block';
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
let top = anchorRect.bottom + viewportMargin;
|
||||
if (top + tooltipRect.height > window.innerHeight - viewportMargin) {
|
||||
top = Math.max(viewportMargin, anchorRect.top - tooltipRect.height - viewportMargin);
|
||||
}
|
||||
|
||||
let left = anchorRect.left;
|
||||
if (left + tooltipRect.width > window.innerWidth - viewportMargin) {
|
||||
left = Math.max(viewportMargin, window.innerWidth - tooltipRect.width - viewportMargin);
|
||||
}
|
||||
|
||||
tooltip.style.top = `${Math.round(top)}px`;
|
||||
tooltip.style.left = `${Math.round(left)}px`;
|
||||
tooltip.style.visibility = 'visible';
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
activeRow = null;
|
||||
if (!tooltipElement) return;
|
||||
tooltipElement.style.display = 'none';
|
||||
tooltipElement.textContent = '';
|
||||
};
|
||||
|
||||
const scheduleShow = (anchor: HTMLElement, text: string) => {
|
||||
if (showTimer) clearTimeout(showTimer);
|
||||
showTimer = setTimeout(() => {
|
||||
const tooltip = getTooltipElement();
|
||||
tooltip.textContent = text;
|
||||
positionTooltip(tooltip, anchor);
|
||||
showTimer = null;
|
||||
}, showDelayMs);
|
||||
};
|
||||
|
||||
const attachNoteTitleTooltip = (rowElement: HTMLElement, displayTitle: string) => {
|
||||
const onPointerEnter = () => {
|
||||
const titleElement = findTitleElement(rowElement);
|
||||
if (!titleElement || !isTruncated(titleElement)) {
|
||||
if (activeRow === rowElement) hideTooltip();
|
||||
return;
|
||||
}
|
||||
activeRow = rowElement;
|
||||
scheduleShow(titleElement, displayTitle);
|
||||
};
|
||||
|
||||
const onPointerLeave = () => {
|
||||
if (activeRow === rowElement) hideTooltip();
|
||||
};
|
||||
|
||||
rowElement.addEventListener('mouseenter', onPointerEnter);
|
||||
rowElement.addEventListener('mouseleave', onPointerLeave);
|
||||
|
||||
return () => {
|
||||
rowElement.removeEventListener('mouseenter', onPointerEnter);
|
||||
rowElement.removeEventListener('mouseleave', onPointerLeave);
|
||||
if (activeRow === rowElement) hideTooltip();
|
||||
};
|
||||
};
|
||||
|
||||
export default attachNoteTitleTooltip;
|
||||
@@ -3,6 +3,7 @@ import { Size } from '@joplin/utils/types';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import { ItemEventHandlers } from './types';
|
||||
import attachNoteTitleTooltip from './noteTitleTooltip';
|
||||
|
||||
const addItemEventListeners = (
|
||||
element: HTMLElement,
|
||||
@@ -51,7 +52,7 @@ const addItemEventListeners = (
|
||||
};
|
||||
|
||||
const useItemElement = (
|
||||
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers,
|
||||
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers, displayTitle = '',
|
||||
) => {
|
||||
const itemElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -69,16 +70,18 @@ const useItemElement = (
|
||||
element.innerHTML = noteHtml;
|
||||
|
||||
const { cleanup } = addItemEventListeners(element, itemEventHandlers, onClick, onDoubleClick);
|
||||
const detachTooltip = displayTitle ? attachNoteTitleTooltip(element, displayTitle) : null;
|
||||
|
||||
rootElement.appendChild(element);
|
||||
itemElement.current = element;
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (detachTooltip) detachTooltip();
|
||||
itemElement.current = null;
|
||||
element.remove();
|
||||
};
|
||||
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers]);
|
||||
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers, displayTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = itemElement.current;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.7.1",
|
||||
"version": "3.7.3",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097808
|
||||
versionName "3.7.0"
|
||||
versionCode 2097811
|
||||
versionName "3.7.2"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const styles = StyleSheet.create({
|
||||
});
|
||||
|
||||
const isAppJoplinCloud = () => {
|
||||
return Platform.OS === 'web' && location.origin === 'https://app.joplincloud.com';
|
||||
return Setting.value('isJoplinCloudWebApp');
|
||||
};
|
||||
|
||||
const useShouldShowOtherButton = () => {
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 158;
|
||||
CURRENT_PROJECT_VERSION = 159;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 158;
|
||||
CURRENT_PROJECT_VERSION = 159;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -910,7 +910,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 158;
|
||||
CURRENT_PROJECT_VERSION = 159;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -953,7 +953,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 158;
|
||||
CURRENT_PROJECT_VERSION = 159;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Joplin</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -1629,10 +1629,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-vector-icons-fontawesome5 (12.3.2)
|
||||
- react-native-vector-icons-fontawesome5 (12.3.3)
|
||||
- react-native-vector-icons-ionicons (12.3.0)
|
||||
- react-native-vector-icons-material-design-icons (12.4.2)
|
||||
- react-native-vector-icons-material-icons (12.4.1)
|
||||
- react-native-vector-icons-material-design-icons (12.4.3)
|
||||
- react-native-vector-icons-material-icons (12.4.3)
|
||||
- react-native-version-info (1.1.1):
|
||||
- React-Core
|
||||
- react-native-webview (13.16.1):
|
||||
@@ -2077,7 +2077,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNDeviceInfo (14.1.1):
|
||||
- RNDeviceInfo (15.0.2):
|
||||
- React-Core
|
||||
- RNFileViewer (2.1.5):
|
||||
- React-Core
|
||||
@@ -2129,7 +2129,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNSVG (15.15.3):
|
||||
- RNSVG (15.15.4):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2150,9 +2150,9 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- RNSVG/common (= 15.15.3)
|
||||
- RNSVG/common (= 15.15.4)
|
||||
- Yoga
|
||||
- RNSVG/common (15.15.3):
|
||||
- RNSVG/common (15.15.4):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -2626,10 +2626,10 @@ SPEC CHECKSUMS:
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-vector-icons: 8215b0e62bf79d3be628cf658ec23777f9bec53c
|
||||
react-native-vector-icons-fontawesome5: 8187e7738d0db1b8373cf030f7140299a7216103
|
||||
react-native-vector-icons-fontawesome5: 61456ec9a12f6a1b7783fcc41102cdac24c9299b
|
||||
react-native-vector-icons-ionicons: ad07e944a092a5cf71b8b569d8f5ce2bf674c415
|
||||
react-native-vector-icons-material-design-icons: 12bc222b4cd6781a3cbbf4b537767d3d528011dd
|
||||
react-native-vector-icons-material-icons: 2fe75632937d07937bfe8ca953e1b5d06eef3a1d
|
||||
react-native-vector-icons-material-design-icons: fa15a032cbf6699a246edb98bff482c1dc69816a
|
||||
react-native-vector-icons-material-icons: dc9f5fd0eabeb46fcf177886e55488c8033b19fe
|
||||
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
|
||||
react-native-webview: 0ed52ddb2eafc64a502871218440b8b4d37961c3
|
||||
React-NativeModulesApple: 2f4d69fb420f671980452a14efacd7a2273c4fbb
|
||||
@@ -2668,13 +2668,13 @@ SPEC CHECKSUMS:
|
||||
RNCClipboard: 88d7eeb555d1183915f0885bdbc5c97eb6f7f3ba
|
||||
RNCPushNotificationIOS: 85957a0534fd1309b4c028ccbe5b1baf9ab5cf57
|
||||
RNDateTimePicker: 9c0a849bbe1c256f0854fea255734b715f5ea876
|
||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
RNLocalize: 44b09911588826d01c5b949e8e3f9ed5fae16b32
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: aad7f2a80ae80be5e2bf57583be28b1a18f95de1
|
||||
RNSVG: 7612f5bc575eab5da3364d44abe9f0d5db2cde03
|
||||
RNSVG: f2641bf17a3e397882659348d7668667a0805f00
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||
WhisperVoiceTyping: 29015e8468ff2fd2786d3aaace16fcbdd6a712d6
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ShareExtension</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.6",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-webview": "13.16.1",
|
||||
@@ -118,7 +118,7 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/serviceworker": "0.0.193",
|
||||
"@types/serviceworker": "0.0.194",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
|
||||
@@ -185,6 +185,7 @@ const buildStartupTasks = (
|
||||
Setting.setConstant('sync.9.apiKey', '');
|
||||
Setting.setConstant('sync.10.apiKey', '');
|
||||
Setting.setConstant('sync.11.apiKey', '');
|
||||
Setting.setConstant('isJoplinCloudWebApp', Platform.OS === 'web' && location.origin === 'https://app.joplincloud.com');
|
||||
});
|
||||
addTask('buildStartupTasks/make resource directory', async () => {
|
||||
await shim.fsDriver().mkdir(Setting.value('resourceDir'));
|
||||
|
||||
@@ -176,6 +176,11 @@ module.exports = class extends Generator {
|
||||
this.templatePath('api'),
|
||||
this.destinationPath('api'),
|
||||
);
|
||||
|
||||
this.fs.copy(
|
||||
this.templatePath('script'),
|
||||
this.destinationPath('script'),
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -45,10 +45,10 @@ export default class JoplinData {
|
||||
constructor(plugin: Plugin);
|
||||
private serializeApiBody;
|
||||
private pathToString;
|
||||
get(path: Path, query?: Record<string, unknown>): Promise<any>;
|
||||
post(path: Path, query?: Record<string, unknown>, body?: unknown, files?: RequestFile[]): Promise<any>;
|
||||
put(path: Path, query?: Record<string, unknown>, body?: unknown, files?: RequestFile[]): Promise<any>;
|
||||
delete(path: Path, query?: Record<string, unknown>): Promise<any>;
|
||||
get(path: Path, query?: any): Promise<any>;
|
||||
post(path: Path, query?: any, body?: any, files?: RequestFile[]): Promise<any>;
|
||||
put(path: Path, query?: any, body?: any, files?: RequestFile[]): Promise<any>;
|
||||
delete(path: Path, query?: any): Promise<any>;
|
||||
itemType(itemId: string): Promise<ModelType>;
|
||||
resourcePath(resourceId: string): Promise<string>;
|
||||
/**
|
||||
|
||||
@@ -39,5 +39,5 @@ export default class JoplinPlugins {
|
||||
/**
|
||||
* @deprecated Use joplin.require()
|
||||
*/
|
||||
require(_path: string): unknown;
|
||||
require(_path: string): any;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default class JoplinSettings {
|
||||
/**
|
||||
* Gets setting values (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
values(keys: string[] | string): Promise<Record<string, unknown>>;
|
||||
values(keys: string[] | string): Promise<Record<string, any>>;
|
||||
/**
|
||||
* Gets a setting value (only applies to setting you registered from your plugin).
|
||||
*
|
||||
@@ -48,11 +48,11 @@ export default class JoplinSettings {
|
||||
* it is recommended to use the `values()` function instead - it will be much faster than
|
||||
* calling `value()` multiple times.
|
||||
*/
|
||||
value(key: string): Promise<unknown>;
|
||||
value(key: string): Promise<any>;
|
||||
/**
|
||||
* Sets a setting value (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
setValue(key: string, value: unknown): Promise<void>;
|
||||
setValue(key: string, value: any): Promise<void>;
|
||||
/**
|
||||
* Gets global setting values, including app-specific settings and those set by other plugins.
|
||||
*
|
||||
@@ -60,11 +60,11 @@ export default class JoplinSettings {
|
||||
*
|
||||
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts
|
||||
*/
|
||||
globalValues(keys: string[]): Promise<unknown[]>;
|
||||
globalValues(keys: string[]): Promise<any[]>;
|
||||
/**
|
||||
* @deprecated Use joplin.settings.globalValues()
|
||||
*/
|
||||
globalValue(key: string): Promise<unknown>;
|
||||
globalValue(key: string): Promise<any>;
|
||||
/**
|
||||
* Called when one or multiple settings of your plugin have been changed.
|
||||
* - For performance reasons, this event is triggered with a delay.
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export default class JoplinViewsEditors {
|
||||
/**
|
||||
* See [[JoplinViewPanels]]
|
||||
*/
|
||||
postMessage(handle: ViewHandle, message: unknown): void;
|
||||
postMessage(handle: ViewHandle, message: any): void;
|
||||
/**
|
||||
* Tells whether the editor is active or not.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export default class JoplinViewsPanels {
|
||||
*
|
||||
* It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api.
|
||||
*/
|
||||
postMessage(handle: ViewHandle, message: unknown): void;
|
||||
postMessage(handle: ViewHandle, message: any): void;
|
||||
/**
|
||||
* Shows the panel
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ export declare enum ItemFlow {
|
||||
export type RenderNoteView = Record<string, any>;
|
||||
export interface OnChangeEvent {
|
||||
elementId: string;
|
||||
value: unknown;
|
||||
value: any;
|
||||
noteId: string;
|
||||
}
|
||||
export interface OnClickEvent {
|
||||
|
||||
@@ -16,7 +16,8 @@ export type RenderNoteView = Record<string, any>;
|
||||
|
||||
export interface OnChangeEvent {
|
||||
elementId: string;
|
||||
value: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: value depends on the input element type
|
||||
value: any;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,12 +186,14 @@ export interface ExportContext {
|
||||
/**
|
||||
* You can attach your own custom data using this property - it will then be passed to each event handler, allowing you to keep state from one event to the next.
|
||||
*/
|
||||
userData?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state
|
||||
userData?: any;
|
||||
}
|
||||
|
||||
export interface ImportContext {
|
||||
sourcePath: string;
|
||||
options: Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: import options are arbitrary per-importer
|
||||
options: any;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -200,7 +202,8 @@ export interface ImportContext {
|
||||
// =================================================================
|
||||
|
||||
export interface Script {
|
||||
onStart?(event: Record<string, unknown>): Promise<void>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: event payload shape depends on the host context
|
||||
onStart?(event: any): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Disposable {
|
||||
@@ -305,7 +308,8 @@ export interface MenuItem {
|
||||
* Arguments that should be passed to the command. They will be as rest
|
||||
* parameters.
|
||||
*/
|
||||
commandArgs?: unknown[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command args depend on the command
|
||||
commandArgs?: any[];
|
||||
|
||||
/**
|
||||
* Set to "separator" to create a divider line
|
||||
@@ -358,12 +362,14 @@ export type ViewHandle = string;
|
||||
|
||||
export interface EditorCommand {
|
||||
name: string;
|
||||
value?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command value depends on the command
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface DialogResult {
|
||||
id: ButtonId;
|
||||
formData?: Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: form data shape depends on the dialog
|
||||
formData?: any;
|
||||
}
|
||||
|
||||
export enum ToastType {
|
||||
@@ -655,7 +661,8 @@ export interface ContentScriptContext {
|
||||
}
|
||||
|
||||
export interface ContentScriptModuleLoadedEvent {
|
||||
userData?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state
|
||||
userData?: any;
|
||||
}
|
||||
|
||||
export interface ContentScriptModule {
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const distCommand = 'npm run dist';
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface Manifest {
|
||||
version?: string;
|
||||
repository_url?: string;
|
||||
}
|
||||
|
||||
interface PackageJson {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const verifyBuild = async () => {
|
||||
const metadata = await validateMetadata();
|
||||
await build();
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const validateMetadata = async () => {
|
||||
logger.info('Validating metadata...');
|
||||
|
||||
// process.cwd() is the plugin root dir when run via `npm run publish`
|
||||
const manifestPath = join(process.cwd(), 'src/manifest.json');
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
|
||||
let manifest: Manifest;
|
||||
let packageJson: PackageJson;
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
error.message = `manifest.json or package.json contains invalid JSON: ${error.message}`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { version } = manifest;
|
||||
const { name } = packageJson;
|
||||
const repositoryUrl = manifest.repository_url;
|
||||
|
||||
if (!name || !name.startsWith('joplin-plugin-')) {
|
||||
throw new Error('Plugin name must start with \'joplin-plugin-\' in package.json');
|
||||
}
|
||||
|
||||
const semverRegex = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/;
|
||||
if (!version || !semverRegex.test(version)) {
|
||||
throw new Error(`Invalid plugin version: '${version}'. Must follow semver format.`);
|
||||
}
|
||||
|
||||
const cleanUrl = typeof repositoryUrl === 'string'
|
||||
? repositoryUrl.trim().replace(/\.git$/, '').replace(/\/$/, '')
|
||||
: '';
|
||||
const githubPattern = /^https:\/\/github\.com\/[^/]+\/[^/]+$/;
|
||||
|
||||
if (!cleanUrl || !githubPattern.test(cleanUrl)) {
|
||||
throw new Error('Repository URL is missing or malformed in manifest.json. Valid format: https://github.com/username/repo');
|
||||
}
|
||||
|
||||
logger.success(`Metadata validated: ${name}@${version}`);
|
||||
return { name, version, repositoryUrl: cleanUrl };
|
||||
};
|
||||
|
||||
const build = async () => {
|
||||
try {
|
||||
logger.info(`Running '${distCommand}'...`);
|
||||
await execAsync(distCommand, { cwd: process.cwd() });
|
||||
logger.success('Build verified!');
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
error.message = `Build failed. Fix the errors above before publishing: ${error.message}`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default verifyBuild;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Verifies if the user has pushed the local code which he is going to publish on github or not
|
||||
const verifyGitState = async () => {
|
||||
|
||||
const runGit = async (command: string, errorMessage: string) => {
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { encoding: 'utf8', cwd: process.cwd() });
|
||||
return stdout.trim();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
error.message = `${errorMessage}: ${error.message}`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Checks if the current folder is a git repository or not
|
||||
await runGit('git rev-parse --is-inside-work-tree', 'The current directory is not a git repository or git is not installed.');
|
||||
|
||||
// Checks if there is any uncommitted changes
|
||||
const status = await runGit('git status --porcelain', 'Failed to check git status. Ensure git is installed and configured.');
|
||||
if (status !== '') {
|
||||
throw new Error('You have uncommitted changes. Please commit or stash them before publishing.');
|
||||
}
|
||||
logger.success('Working tree is clean.');
|
||||
|
||||
// Gets the current latest local commit hash
|
||||
const commitHash = await runGit('git rev-parse HEAD', 'Could not get commit hash. Ensure you are in a valid Git repository with at least one commit.');
|
||||
if (commitHash.length !== 40) {
|
||||
throw new Error('Failed to extract a valid commit hash. Ensure that git is properly initialized and you have made at least one local commit (git commit) before publishing.');
|
||||
}
|
||||
logger.success(`Commit hash extracted: ${commitHash}`);
|
||||
|
||||
// check if the local project is linked to github
|
||||
await runGit('git remote get-url origin', 'No remote named \'origin\' found. Make sure your plugin repository is hosted on GitHub.');
|
||||
|
||||
const currentBranch = await runGit('git rev-parse --abbrev-ref HEAD', 'Failed to retrieve current branch name. Ensure git is configured correctly.');
|
||||
if (currentBranch === 'HEAD') {
|
||||
throw new Error('You are in a detached HEAD state. Checkout a branch (e.g. git checkout main) and push before publishing.');
|
||||
}
|
||||
|
||||
const remoteHeadLine = await runGit(`git ls-remote origin ${currentBranch}`, 'Could not retrieve remote HEAD. Make sure you have pushed your changes and have an internet connection.');
|
||||
if (!remoteHeadLine) {
|
||||
throw new Error('Remote HEAD is empty. Make sure you have pushed your changes.');
|
||||
}
|
||||
|
||||
const parts = remoteHeadLine.split('\n')[0].split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Unexpected git ls-remote output: "${remoteHeadLine}". Make sure your git remote and branch are configured correctly.`);
|
||||
}
|
||||
|
||||
const remoteHash = parts[0];
|
||||
if (remoteHash.length !== 40) {
|
||||
throw new Error('Failed to extract a valid remote commit hash.');
|
||||
}
|
||||
|
||||
if (remoteHash !== commitHash) {
|
||||
throw new Error('Your local commit has not been pushed to GitHub. Run git push then try publishing again.');
|
||||
}
|
||||
logger.success('Local commit is synced with remote.');
|
||||
|
||||
return commitHash;
|
||||
};
|
||||
|
||||
export default verifyGitState;
|
||||
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable no-console */
|
||||
import { green, yellow, red } from 'chalk';
|
||||
|
||||
const logger = {
|
||||
info: (msg: string) => console.log(msg),
|
||||
success: (msg: string) => console.log(green(msg)),
|
||||
warn: (msg: string) => console.log(yellow(msg)),
|
||||
error: (msg: string) => console.log(red(msg)),
|
||||
};
|
||||
|
||||
export default logger;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "3.7.1",
|
||||
"version": "3.7.2",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
|
||||
"author": {
|
||||
@@ -38,4 +38,4 @@
|
||||
"repository": "https://github.com/laurent22/generator-joplin",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ import NavService from './services/NavService';
|
||||
import getAppName from './getAppName';
|
||||
import PerformanceLogger from './PerformanceLogger';
|
||||
import Synchronizer from './Synchronizer';
|
||||
import NoteLockKey from './services/noteLock/NoteLockKey';
|
||||
import NoteLockService from './services/noteLock/NoteLockService';
|
||||
|
||||
const appLogger: LoggerWrapper = Logger.create('App');
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
@@ -136,6 +138,8 @@ export default class BaseApplication {
|
||||
ResourceService.isRunningInBackground_ = false;
|
||||
// ResourceService.isRunningInBackground_ = false;
|
||||
ResourceFetcher.instance_ = null;
|
||||
NoteLockKey.destroyInstance();
|
||||
NoteLockService.destroyInstance();
|
||||
EncryptionService.instance_ = null;
|
||||
DecryptionWorker.instance_ = null;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum ModelType {
|
||||
SmartFilter = 15,
|
||||
Command = 16,
|
||||
NoteEmbedding = 17,
|
||||
ConflictNoteState = 18,
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
@@ -88,6 +89,7 @@ class BaseModel {
|
||||
['TYPE_SMART_FILTER', ModelType.SmartFilter],
|
||||
['TYPE_COMMAND', ModelType.Command],
|
||||
['TYPE_NOTE_EMBEDDING', ModelType.NoteEmbedding],
|
||||
['TYPE_CONFLICT_NOTE_STATE', ModelType.ConflictNoteState],
|
||||
];
|
||||
|
||||
private static uuidGenerator: ()=> string = uuid.create;
|
||||
@@ -109,6 +111,7 @@ class BaseModel {
|
||||
public static TYPE_SMART_FILTER = ModelType.SmartFilter;
|
||||
public static TYPE_COMMAND = ModelType.Command;
|
||||
public static TYPE_NOTE_EMBEDDING = ModelType.NoteEmbedding;
|
||||
public static TYPE_CONFLICT_NOTE_STATE = ModelType.ConflictNoteState;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Set by the app to redux dispatch; per-app action types diverge so the function is typed loosely here
|
||||
public static dispatch: Function = function() {};
|
||||
|
||||
@@ -354,6 +354,7 @@ export default class JoplinDatabase extends Database {
|
||||
if (tableName.indexOf('items_fts') === 0) continue;
|
||||
if (tableName === 'notes_spellfix') continue;
|
||||
if (tableName === 'search_aux') continue;
|
||||
if (tableName.startsWith('note_embeddings_vec')) continue;
|
||||
|
||||
pragmas = await this.selectAll(`PRAGMA table_info("${tableName}")`);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface SyncTargetInfo {
|
||||
|
||||
export default class SyncTargetRegistry {
|
||||
|
||||
private static reg_: Record<number, typeof BaseSyncTarget> = {};
|
||||
private static reg_: Record<string, typeof BaseSyncTarget> = {};
|
||||
|
||||
private static get reg() {
|
||||
return this.reg_;
|
||||
@@ -74,9 +74,11 @@ export default class SyncTargetRegistry {
|
||||
return this.reg[id].targetName();
|
||||
}
|
||||
|
||||
public static idAndLabelPlainObject(os: string) {
|
||||
public static idAndLabelPlainObject(os: string, includeKeys: string[]|null = null) {
|
||||
includeKeys ??= Object.keys(this.reg);
|
||||
|
||||
const output: Record<string, string> = {};
|
||||
for (const n in this.reg) {
|
||||
for (const n of includeKeys) {
|
||||
if (!this.reg.hasOwnProperty(n)) continue;
|
||||
const info = this.infoById(this.reg[n].id());
|
||||
if (info.classRef.unsupportedPlatforms().indexOf(os) >= 0) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import syncDeleteStep from './services/synchronizer/utils/syncDeleteStep';
|
||||
import { ErrorCode } from './errors';
|
||||
import { SyncAction } from './services/synchronizer/utils/types';
|
||||
import checkDisabledSyncItemsNotification from './services/synchronizer/utils/checkDisabledSyncItemsNotification';
|
||||
import { NoteEntity } from './services/database/types';
|
||||
import { reg } from './registry';
|
||||
import SyncTargetRegistry from './SyncTargetRegistry';
|
||||
import { Day } from '@joplin/utils/time';
|
||||
@@ -540,10 +541,14 @@ export default class Synchronizer {
|
||||
|
||||
// console.info('NEW', newInfo);
|
||||
|
||||
if (newInfo.revisionServiceEnabled !== localInfo.revisionServiceEnabled) {
|
||||
// Only copy a synced revisionService.* value back to the local
|
||||
// Setting when it carries a real timestamp; a timestamp of 0
|
||||
// means no client has explicitly set it, so we must not
|
||||
// overwrite a customised local value with a migration default.
|
||||
if (newInfo.revisionServiceEnabled !== localInfo.revisionServiceEnabled && newInfo.keyTimestamp('revisionServiceEnabled') > 0) {
|
||||
Setting.setValue('revisionService.enabled', newInfo.revisionServiceEnabled);
|
||||
}
|
||||
if (newInfo.revisionServiceTtlDays !== localInfo.revisionServiceTtlDays) {
|
||||
if (newInfo.revisionServiceTtlDays !== localInfo.revisionServiceTtlDays && newInfo.keyTimestamp('revisionServiceTtlDays') > 0) {
|
||||
Setting.setValue('revisionService.ttlDays', newInfo.revisionServiceTtlDays);
|
||||
}
|
||||
|
||||
@@ -693,18 +698,18 @@ export default class Synchronizer {
|
||||
// a few seconds ahead of what it was set with setTimestamp()
|
||||
try {
|
||||
remoteContent = await this.apiCall('get', path);
|
||||
if (!remoteContent) throw new Error(`Got metadata for path but could not fetch content: ${path}`);
|
||||
remoteContent = await BaseItem.unserialize(remoteContent);
|
||||
} catch (error) {
|
||||
if (error.code === 'rejectedByTarget') {
|
||||
if (error.code === 'rejectedByTarget' || error.code === 'malformedItem') {
|
||||
this.progressReport_.errors.push(error);
|
||||
logger.warn(`Rejected by target: ${path}: ${error.message}`);
|
||||
logger.warn(`Skipping item from sync target: ${path}: ${error.message}`);
|
||||
completeItemProcessing(path);
|
||||
continue;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!remoteContent) throw new Error(`Got metadata for path but could not fetch content: ${path}`);
|
||||
remoteContent = await BaseItem.unserialize(remoteContent);
|
||||
|
||||
if (remoteContent.updated_time > local.sync_time) {
|
||||
// Since, in this loop, we are only dealing with items that require sync, if the
|
||||
@@ -849,6 +854,11 @@ export default class Synchronizer {
|
||||
// get set there
|
||||
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
|
||||
|
||||
if (local.type_ === BaseModel.TYPE_NOTE) {
|
||||
const note = local as NoteEntity;
|
||||
await Note.saveSyncBaseContent(syncTargetId, note.id, note.body, note.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,9 +1030,9 @@ export default class Synchronizer {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'rejectedByTarget') {
|
||||
if (error.code === 'rejectedByTarget' || error.code === 'malformedItem') {
|
||||
this.progressReport_.errors.push(error);
|
||||
logger.warn(`Rejected by target: ${path}: ${error.message}`);
|
||||
logger.warn(`Skipping item from sync target: ${path}: ${error.message}`);
|
||||
action = null;
|
||||
} else {
|
||||
error.message = `On file ${path}: ${error.message}`;
|
||||
|
||||
@@ -191,4 +191,42 @@ three line \\n no escape`)).toBe(0);
|
||||
const note = await Note.save({ id }, { isNew: true });
|
||||
expect(await BaseItem.loadItemById(note.id)).toMatchObject({ id });
|
||||
});
|
||||
|
||||
// Sync ingestion concatenates resource.id and resource.file_extension into
|
||||
// a local file path; a malformed id like `../../foo` would escape the
|
||||
// resource directory. unserialize() must reject these.
|
||||
it.each([
|
||||
'../../escape',
|
||||
'../foo',
|
||||
'foo/bar',
|
||||
'foo\\bar',
|
||||
'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
'short',
|
||||
])('should reject items with malformed IDs during unserialize (id: %j)', async (id) => {
|
||||
const serialized = `poc-resource\n\nid: ${id}\ntype_: 4`;
|
||||
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
|
||||
code: 'malformedItem',
|
||||
message: expect.stringMatching(/Invalid item ID/),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'../foo',
|
||||
'foo/bar',
|
||||
'foo\\bar',
|
||||
'..',
|
||||
])('should reject items with malformed file_extension during unserialize (ext: %j)', async (ext) => {
|
||||
const serialized = `poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: ${ext}\ntype_: 4`;
|
||||
await expect(BaseItem.unserialize(serialized)).rejects.toMatchObject({
|
||||
code: 'malformedItem',
|
||||
message: expect.stringMatching(/Invalid file extension/),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept well-formed resource items', async () => {
|
||||
const serialized = 'poc-resource\n\nid: 00000000000000000000000000000001\nfile_extension: txt\ntype_: 4';
|
||||
const out = await BaseItem.unserialize(serialized);
|
||||
expect(out.id).toBe('00000000000000000000000000000001');
|
||||
expect(out.file_extension).toBe('txt');
|
||||
});
|
||||
});
|
||||
@@ -621,6 +621,19 @@ export default class BaseItem extends BaseModel {
|
||||
const ItemClass = this.itemClass(output.type_);
|
||||
output = ItemClass.removeUnknownFields(output);
|
||||
|
||||
// Reject any field that could be used to escape the resource directory
|
||||
// when concatenated into a file path (resourceFullPath uses raw string
|
||||
// concat on id and file_extension). The id format is universally a 32
|
||||
// char hex string; the extension must not contain path separators.
|
||||
// Throws a malformedItem JoplinError so the sync loop can log+skip the
|
||||
// item rather than abort the whole batch.
|
||||
if ('id' in output && output.id !== '' && !/^[a-fA-F0-9]{32}$/.test(output.id)) {
|
||||
throw new JoplinError(`Invalid item ID format: ${JSON.stringify(output.id)}`, 'malformedItem');
|
||||
}
|
||||
if ('file_extension' in output && /[/\\]|\.\./.test(output.file_extension)) {
|
||||
throw new JoplinError(`Invalid file extension: ${JSON.stringify(output.file_extension)}`, 'malformedItem');
|
||||
}
|
||||
|
||||
for (const n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
output[n] = await this.unserialize_format(output.type_, n, output[n]);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import BaseModel from '../BaseModel';
|
||||
import { ConflictNoteStateEntity } from '../services/database/types';
|
||||
|
||||
// Stores the base and remote versions of a note at the moment a conflict was
|
||||
// created, keyed by the conflict note's id. This is what a future three-way
|
||||
// merge needs: the common ancestor (base) and the server version (remote).
|
||||
//
|
||||
// Rows here are written but not cleaned up when a conflict note is deleted - the
|
||||
// merge UI that consumes them will skip a missing conflict note, and cleanup can
|
||||
// be a follow-up.
|
||||
export default class ConflictNoteState extends BaseModel {
|
||||
public static tableName() {
|
||||
return 'conflict_note_states';
|
||||
}
|
||||
|
||||
public static modelType() {
|
||||
return BaseModel.TYPE_CONFLICT_NOTE_STATE;
|
||||
}
|
||||
|
||||
public static async byNoteId(noteId: string): Promise<ConflictNoteStateEntity> {
|
||||
return this.db().selectOne('SELECT * FROM conflict_note_states WHERE note_id = ?', [noteId]);
|
||||
}
|
||||
|
||||
public static async save(state: ConflictNoteStateEntity) {
|
||||
await this.db().exec(
|
||||
'INSERT OR REPLACE INTO conflict_note_states (note_id, base_body, base_title, remote_body, remote_title, remote_updated_time) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
state.note_id,
|
||||
state.base_body ?? '',
|
||||
state.base_title ?? '',
|
||||
state.remote_body ?? '',
|
||||
state.remote_title ?? '',
|
||||
state.remote_updated_time ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,20 @@ export default class Folder extends BaseItem {
|
||||
return r ? r.total : 0;
|
||||
}
|
||||
|
||||
// Returns a map of folder id → number of indexable notes (excluding trash
|
||||
// and conflicts). Folders with zero notes are omitted from the map.
|
||||
public static async noteCountsByFolderId() {
|
||||
const rows = await this.db().selectAll<{ parent_id: string; total: number }>(
|
||||
`SELECT parent_id, count(*) as total
|
||||
FROM notes
|
||||
WHERE is_conflict = 0 AND (deleted_time IS NULL OR deleted_time = 0)
|
||||
GROUP BY parent_id`,
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of rows) counts[r.parent_id] = r.total;
|
||||
return counts;
|
||||
}
|
||||
|
||||
public static markNotesAsConflict(parentId: string) {
|
||||
const query = Database.updateQuery('notes', { is_conflict: 1 }, { parent_id: parentId });
|
||||
return this.db().exec(query);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Setting from './Setting';
|
||||
import Setting, { Env } from './Setting';
|
||||
import BaseModel from '../BaseModel';
|
||||
import shim from '../shim';
|
||||
import markdownUtils from '../markdownUtils';
|
||||
import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv, msleep, db } from '../testing/test-utils';
|
||||
import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv, msleep, db, encryptionService, revisionService } from '../testing/test-utils';
|
||||
import Folder from './Folder';
|
||||
import Note from './Note';
|
||||
import Tag from './Tag';
|
||||
@@ -21,6 +21,9 @@ import { getTrashFolderId } from '../services/trash';
|
||||
import getConflictFolderId from './utils/getConflictFolderId';
|
||||
import Revision from './Revision';
|
||||
import RevisionService from '../services/RevisionService';
|
||||
import NoteLockKey from '../services/noteLock/NoteLockKey';
|
||||
import NoteLockService from '../services/noteLock/NoteLockService';
|
||||
import EncryptionService from '../services/e2ee/EncryptionService';
|
||||
|
||||
async function allItems() {
|
||||
const folders = await Folder.all();
|
||||
@@ -32,6 +35,9 @@ describe('models/Note', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
NoteLockKey.destroyInstance();
|
||||
NoteLockService.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
});
|
||||
|
||||
it('should find resource and note IDs', (async () => {
|
||||
@@ -243,6 +249,117 @@ describe('models/Note', () => {
|
||||
expect(Note.previewFields()).not.toContain('extracted_resource_ids');
|
||||
});
|
||||
|
||||
it('should store note lock ciphertext while decrypting only gated loads', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const resourceId1 = '06894e83b8f84d3d8cbe0f1587f9e226';
|
||||
const resourceId2 = '06894e83b8f84d3d8cbe0f1587f9e227';
|
||||
const plainTextBody = `secret [](:/${resourceId1}) [](:/${resourceId2})`;
|
||||
|
||||
const note = await Note.save({
|
||||
body: plainTextBody,
|
||||
is_locked: 1,
|
||||
}, { useNoteLock: true });
|
||||
const storedNote = await Note.load(note.id);
|
||||
|
||||
expect(storedNote.body).not.toBe(plainTextBody);
|
||||
expect(storedNote.extracted_resource_ids).toBe(`${resourceId1},${resourceId2}`);
|
||||
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
|
||||
|
||||
await expect(Note.load(note.id, { fields: ['id', 'is_locked'], useNoteLock: true })).rejects.toThrow();
|
||||
await expect(Note.load(note.id, { fields: ['id', 'body'], useNoteLock: true })).rejects.toThrow('Gated note lock load is missing lock state');
|
||||
|
||||
await Note.save(await Note.load(note.id, { useNoteLock: true }), { useNoteLock: true });
|
||||
expect((await Note.load(note.id)).body).not.toBe(plainTextBody);
|
||||
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
|
||||
|
||||
await expect(Note.save({
|
||||
id: note.id,
|
||||
body: 'must not be stored',
|
||||
}, { useNoteLock: true })).rejects.toThrow('Gated note lock save is missing lock state');
|
||||
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
|
||||
|
||||
await Note.save({
|
||||
...await Note.load(note.id, { useNoteLock: true }),
|
||||
body: `${plainTextBody} edited`,
|
||||
}, { useNoteLock: true });
|
||||
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(`${plainTextBody} edited`);
|
||||
|
||||
await Note.save({
|
||||
...await Note.load(note.id, { useNoteLock: true }),
|
||||
body: 'unlocked',
|
||||
is_locked: 0,
|
||||
}, { useNoteLock: true });
|
||||
const unlockedNote = await Note.load(note.id);
|
||||
expect(unlockedNote.body).toBe('unlocked');
|
||||
expect(unlockedNote.extracted_resource_ids).toBe('');
|
||||
});
|
||||
|
||||
it('should not decrypt locked notes while the feature is disabled', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const note = await Note.save({
|
||||
body: 'secret',
|
||||
is_locked: 1,
|
||||
}, { useNoteLock: true });
|
||||
|
||||
Setting.setConstant('env', Env.Prod);
|
||||
try {
|
||||
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(note.body);
|
||||
} finally {
|
||||
Setting.setConstant('env', Env.Dev);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail closed when note lock encryption cannot decrypt or encrypt', async () => {
|
||||
const noteLockKey = NoteLockKey.instance();
|
||||
await noteLockKey.create('123456');
|
||||
const note = await Note.save({
|
||||
body: 'secret',
|
||||
is_locked: 1,
|
||||
}, { useNoteLock: true });
|
||||
|
||||
noteLockKey.lock();
|
||||
await expect(Note.save({
|
||||
body: 'must not be stored',
|
||||
is_locked: 1,
|
||||
}, { useNoteLock: true })).rejects.toThrow('Note lock key is not unlocked');
|
||||
expect(await Note.all()).toHaveLength(1);
|
||||
|
||||
await noteLockKey.unlock('123456');
|
||||
const corruptedBody = `${note.body}invalid`;
|
||||
await db().exec('UPDATE notes SET body = ? WHERE id = ?', [corruptedBody, note.id]);
|
||||
await expect(Note.load(note.id, { useNoteLock: true })).rejects.toThrow();
|
||||
expect((await Note.load(note.id)).body).toBe(corruptedBody);
|
||||
|
||||
const incompleteBody = note.body.slice(0, -1);
|
||||
await db().exec('UPDATE notes SET body = ? WHERE id = ?', [incompleteBody, note.id]);
|
||||
await expect(Note.load(note.id, { useNoteLock: true })).rejects.toThrow();
|
||||
expect((await Note.load(note.id)).body).toBe(incompleteBody);
|
||||
});
|
||||
|
||||
it('should clear plaintext revision data when locking a note', async () => {
|
||||
const note = await Note.save({ body: 'plain text' });
|
||||
await Note.save({ id: note.id, body: 'updated plain text' });
|
||||
await revisionService().collectRevisions();
|
||||
expect(await Revision.countRevisions(Note.modelType(), note.id)).toBe(1);
|
||||
const encryptedRevision = await Revision.save({
|
||||
item_type: Note.modelType(),
|
||||
item_id: note.id,
|
||||
item_updated_time: Date.now(),
|
||||
is_locked: 1,
|
||||
});
|
||||
|
||||
await NoteLockKey.instance().create('123456');
|
||||
await Note.save({
|
||||
...await Note.load(note.id),
|
||||
is_locked: 1,
|
||||
}, { useNoteLock: true });
|
||||
|
||||
expect(await Revision.countRevisions(Note.modelType(), note.id)).toBe(1);
|
||||
expect(await Revision.load(encryptedRevision.id)).toBeTruthy();
|
||||
await ItemChange.waitForAllSaved();
|
||||
expect(await ItemChange.oldNoteContent(note.id)).toBe(null);
|
||||
});
|
||||
|
||||
it('should reset fields for a duplicate', (async () => {
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
const note1 = await Note.save({ title: 'note', parent_id: folder1.id });
|
||||
|
||||
@@ -8,7 +8,7 @@ import Setting from './Setting';
|
||||
import shim from '../shim';
|
||||
import time from '../time';
|
||||
import markdownUtils from '../markdownUtils';
|
||||
import { FolderEntity, NoteEntity } from '../services/database/types';
|
||||
import { FolderEntity, NoteEntity, SyncItemEntity } from '../services/database/types';
|
||||
import Tag from './Tag';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||
@@ -26,6 +26,8 @@ import { resolveFileRef, RefKind } from '../services/whiteboard/resolveRef';
|
||||
const { isImageMimeType } = require('../resourceUtils');
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { ALL_NOTES_FILTER_ID } from '../reserved-ids';
|
||||
import NoteLockNote from '../services/noteLock/NoteLockNote';
|
||||
import isNoteLockEnabled from '../services/noteLock/isNoteLockEnabled';
|
||||
|
||||
export interface PreviewsOrder {
|
||||
by: string;
|
||||
@@ -789,8 +791,10 @@ export default class Note extends BaseItem {
|
||||
return n.updated_time < date;
|
||||
}
|
||||
|
||||
public static load(id: string, options: LoadOptions = null): Promise<NoteEntity> {
|
||||
return super.load(id, options);
|
||||
public static async load(id: string, options: LoadOptions = null): Promise<NoteEntity> {
|
||||
const note = await super.load(id, options);
|
||||
if (isNoteLockEnabled() && !!options?.useNoteLock) return NoteLockNote.decryptBody(note);
|
||||
return note;
|
||||
}
|
||||
|
||||
public static async save(o: NoteEntity, options: SaveOptions = null): Promise<NoteEntity> {
|
||||
@@ -834,6 +838,9 @@ export default class Note extends BaseItem {
|
||||
// we should set beforeNoteJson to the current contents in the database, or the last value which was stored
|
||||
// in the item_changes table
|
||||
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
|
||||
if (isNoteLockEnabled() && !!options?.useNoteLock) {
|
||||
await NoteLockNote.prepareForSave(o, this.linkedItemIds, isNew);
|
||||
}
|
||||
|
||||
syncDebugLog.info('Save Note: P:', oldNote);
|
||||
|
||||
@@ -842,7 +849,9 @@ export default class Note extends BaseItem {
|
||||
// has just been downloaded from the sync target and save is invoked when the note has not yet been decrypted
|
||||
if (oldNote && !oldNote.encryption_applied) {
|
||||
const changedSinceCollection = this.revisionService().changedSinceCollection(o.id);
|
||||
if (changedSinceCollection) {
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(o)) {
|
||||
beforeNoteJson = null;
|
||||
} else if (changedSinceCollection) {
|
||||
beforeNoteJson = await ItemChange.oldNoteContent(o.id);
|
||||
} else {
|
||||
beforeNoteJson = JSON.stringify(oldNote);
|
||||
@@ -864,6 +873,11 @@ export default class Note extends BaseItem {
|
||||
|
||||
let savedNote = await super.save(o, options);
|
||||
|
||||
if (isNoteLockEnabled() && !!options?.useNoteLock && NoteLockNote.isLocking(o, oldNote)) {
|
||||
await ItemChange.waitForAllSaved();
|
||||
await this.revisionService().deleteUnencryptedHistoryForNote(savedNote.id, { sourceDescription: 'Note.save: note lock' });
|
||||
}
|
||||
|
||||
void ItemChange.add(BaseModel.TYPE_NOTE, savedNote.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, {
|
||||
changeSource, changeId: options?.changeId, beforeChangeItemJson: beforeNoteJson,
|
||||
});
|
||||
@@ -1218,6 +1232,24 @@ export default class Note extends BaseItem {
|
||||
return await Note.save(conflictNote, { autoTimestamp: false, changeSource: changeSource });
|
||||
}
|
||||
|
||||
// Records the note content that was just pushed to the server. This becomes the
|
||||
// "base" version - the common ancestor used to detect what changed on each side
|
||||
// when a conflict later occurs. A clean upload also means there's no active
|
||||
// conflict, so we clear any previously recorded conflict note id.
|
||||
public static async saveSyncBaseContent(syncTarget: number, noteId: string, body: string, title: string) {
|
||||
const sql = 'UPDATE sync_items SET base_body = ?, base_title = ?, base_conflict_note_id = ? WHERE item_id = ? AND item_type = ? AND sync_target = ?';
|
||||
await this.db().exec(sql, [body, title, '', noteId, this.TYPE_NOTE, syncTarget]);
|
||||
}
|
||||
|
||||
public static async setBaseConflictNoteId(syncTarget: number, noteId: string, conflictNoteId: string) {
|
||||
const sql = 'UPDATE sync_items SET base_conflict_note_id = ? WHERE item_id = ? AND item_type = ? AND sync_target = ?';
|
||||
await this.db().exec(sql, [conflictNoteId, noteId, this.TYPE_NOTE, syncTarget]);
|
||||
}
|
||||
|
||||
public static async syncBaseContent(syncTarget: number, noteId: string): Promise<SyncItemEntity> {
|
||||
return BaseItem.syncItem(syncTarget, noteId, { fields: ['base_body', 'base_title'] });
|
||||
}
|
||||
|
||||
public static async getNextOrderValue(folderId: string) {
|
||||
const reverse = Setting.value('notes.sortOrder.reverse');
|
||||
if (reverse) {
|
||||
|
||||
@@ -407,6 +407,18 @@ export default class Revision extends BaseItem {
|
||||
await this.batchDelete(revisions.map(item => item.id), options);
|
||||
}
|
||||
|
||||
// Same as deleteHistoryForNote, but keeps locked revisions when clearing plaintext history during note locking.
|
||||
public static async deleteUnencryptedHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
|
||||
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
|
||||
|
||||
const revisions: RevisionEntity[] = await this.modelSelectAll(
|
||||
`SELECT id FROM revisions WHERE item_type = ? AND item_id in (${this.escapeIdsForSql(ids)}) AND is_locked = 0 ORDER BY item_updated_time DESC`,
|
||||
[ModelType.Note],
|
||||
);
|
||||
|
||||
await this.batchDelete(revisions.map(item => item.id), options);
|
||||
}
|
||||
|
||||
public static async revisionExists(itemType: ModelType, itemId: string, updatedTime: number) {
|
||||
const existingRev = await Revision.latestRevision(itemType, itemId);
|
||||
return existingRev && existingRev.item_updated_time === updatedTime;
|
||||
|
||||
@@ -580,4 +580,20 @@ describe('models/Setting', () => {
|
||||
expect(Setting.value('editor.imageRendering')).toBe(true);
|
||||
expect(Setting.value(testSettingId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should limit listed sync targets on app.joplincloud.com', async () => {
|
||||
try {
|
||||
Setting.setConstant('isJoplinCloudWebApp', true);
|
||||
expect(Setting.enumOptionValues('sync.target')).toEqual(['0', '10']);
|
||||
|
||||
// Should list other sync targets on other apps
|
||||
Setting.setConstant('isJoplinCloudWebApp', false);
|
||||
const fullSyncOptions = Setting.enumOptionValues('sync.target');
|
||||
expect(fullSyncOptions).toContain('0');
|
||||
expect(fullSyncOptions).toContain('5');
|
||||
expect(fullSyncOptions).toContain('10');
|
||||
} finally {
|
||||
Setting.setConstant('isJoplinCloudWebApp', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ export interface Constants {
|
||||
syncVersion: number;
|
||||
startupDevPlugins: string[];
|
||||
isSubProfile: boolean;
|
||||
isJoplinCloudWebApp: boolean;
|
||||
|
||||
'sync.9.apiKey': string;
|
||||
'sync.10.apiKey': string;
|
||||
@@ -305,6 +306,7 @@ class Setting extends BaseModel {
|
||||
syncVersion: 3,
|
||||
startupDevPlugins: [],
|
||||
isSubProfile: false,
|
||||
isJoplinCloudWebApp: false,
|
||||
|
||||
'sync.9.apiKey': '',
|
||||
'sync.10.apiKey': '',
|
||||
@@ -1291,6 +1293,7 @@ class Setting extends BaseModel {
|
||||
'encryption',
|
||||
'joplinCloud',
|
||||
'ai',
|
||||
'mcp',
|
||||
'editor',
|
||||
'plugins',
|
||||
'markdownPlugins',
|
||||
@@ -1366,6 +1369,7 @@ class Setting extends BaseModel {
|
||||
if (name === 'importOrExport') return _('Import and Export');
|
||||
if (name === 'moreInfo') return _('More information');
|
||||
if (name === 'ai') return _('AI');
|
||||
if (name === 'mcp') return _('MCP Server');
|
||||
|
||||
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
|
||||
|
||||
@@ -1443,6 +1447,7 @@ class Setting extends BaseModel {
|
||||
'importOrExport': 'fa fa-file-export',
|
||||
'moreInfo': 'fa fa-info-circle',
|
||||
'ai': 'fa fa-robot',
|
||||
'mcp': 'fa fa-plug',
|
||||
};
|
||||
|
||||
// Icomoon icons are currently not present in the mobile app -- we override these
|
||||
|
||||
@@ -263,4 +263,15 @@ describe('models/Tag', () => {
|
||||
expect(loadedTag.id).toBe(legacyTagId);
|
||||
expect(loadedTag.id).not.toBe(normalizedTag.id);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// cSpell:disable
|
||||
'Посмотреть 1',
|
||||
'Öp',
|
||||
// cSpell:enable
|
||||
])('should find match for tags which have been saved, when they contain special unicode characters', async (title) => {
|
||||
await Tag.save({ title });
|
||||
const loadedTag = await Tag.loadByTitle(title);
|
||||
expect(loadedTag.id).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -171,17 +171,18 @@ export default class Tag extends BaseItem {
|
||||
}
|
||||
|
||||
public static async loadByTitle(title: string): Promise<TagEntity | null> {
|
||||
// Case insensitive matching via NOCASE does not work for certain characters, such as Ö or Cyrillic characters, but this is an accepted limitation
|
||||
// now that mixed case tags are supported
|
||||
const trimmedTitle = title.trim();
|
||||
const lowercaseTitle = trimmedTitle.toLowerCase();
|
||||
const normalizedLowercaseTitle = trimmedTitle.normalize('NFC').toLowerCase();
|
||||
const normalizedTitle = trimmedTitle.normalize('NFC');
|
||||
// We use a manual query here instead of loadByField to ensure deterministic ordering (ORDER BY created_time ASC)
|
||||
// when visually similar tags exist in the database.
|
||||
// We use created_time instead of id because IDs are UUIDs and cannot be meaningfully ordered.
|
||||
const tag = await this.modelSelectOne(`SELECT * FROM ${this.tableName()} WHERE title = ? COLLATE NOCASE ORDER BY created_time ASC`, [lowercaseTitle]);
|
||||
const tag = await this.modelSelectOne(`SELECT * FROM ${this.tableName()} WHERE title = ? COLLATE NOCASE ORDER BY created_time ASC`, [trimmedTitle]);
|
||||
if (tag) return tag;
|
||||
|
||||
if (normalizedLowercaseTitle !== lowercaseTitle) {
|
||||
return await this.modelSelectOne(`SELECT * FROM ${this.tableName()} WHERE title = ? COLLATE NOCASE ORDER BY created_time ASC`, [normalizedLowercaseTitle]);
|
||||
if (normalizedTitle !== trimmedTitle) {
|
||||
return await this.modelSelectOne(`SELECT * FROM ${this.tableName()} WHERE title = ? COLLATE NOCASE ORDER BY created_time ASC`, [normalizedTitle]);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -22,6 +22,10 @@ const showVoiceTypingSettings = (settings: VoiceTypingSettingSlice) => (
|
||||
shim.mobilePlatform() === 'android' && !!settings['buildFlag.voiceTypingEnabled']
|
||||
);
|
||||
|
||||
const show3rdPartySyncSettings = (Setting: typeof SettingType) => {
|
||||
return !Setting.value('isJoplinCloudWebApp');
|
||||
};
|
||||
|
||||
export enum CameraDirection {
|
||||
Back,
|
||||
Front,
|
||||
@@ -130,7 +134,12 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
return appType !== 'cli' ? null : _('The target to synchronise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).');
|
||||
},
|
||||
options: () => {
|
||||
return SyncTargetRegistry.idAndLabelPlainObject(platform);
|
||||
if (show3rdPartySyncSettings(Setting)) {
|
||||
return SyncTargetRegistry.idAndLabelPlainObject(platform);
|
||||
} else {
|
||||
// Only the Joplin-related sync targets are shown on app.joplincloud.com:
|
||||
return SyncTargetRegistry.idAndLabelPlainObject(platform, ['0', '10']);
|
||||
}
|
||||
},
|
||||
optionsOrder: () => {
|
||||
return SyncTargetRegistry.optionsOrder();
|
||||
@@ -818,6 +827,127 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
storage: SettingStorage.Database,
|
||||
},
|
||||
|
||||
'mcp.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Enable MCP server'),
|
||||
description: () => _('Exposes Joplin notes to external AI applications (Claude Desktop, Cursor, etc.) via the Model Context Protocol. Requires the Web Clipper service to be running. Connected AI tools can read your note content; any text they retrieve may be sent to the external LLM provider those tools use.'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.search_notes.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow searching notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.read_note.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow reading notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.list_notebooks.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow listing notebooks'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.list_tags.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow listing tags'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.create_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow creating notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.update_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow updating notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.delete_note.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow trashing notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.manage_tags.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow editing tags on notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.create_notebook.enabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow creating notebooks'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'mcp.tool.semantic_search_notes.enabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
section: 'mcp',
|
||||
appTypes: [AppType.Desktop],
|
||||
show: (settings) => !!settings['mcp.enabled'],
|
||||
label: () => _('MCP: Allow semantic search of notes'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
theme: {
|
||||
value: Setting.THEME_LIGHT,
|
||||
type: SettingItemType.Int,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ResourceEntity } from '../../services/database/types';
|
||||
import { resourceFilename } from './resourceUtils';
|
||||
|
||||
describe('resourceUtils', () => {
|
||||
|
||||
it('should build a filename from id and extension', () => {
|
||||
const resource: ResourceEntity = { id: '00000000000000000000000000000001', file_extension: 'txt' };
|
||||
expect(resourceFilename(resource)).toBe('00000000000000000000000000000001.txt');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ id: '../../escape', file_extension: 'txt' },
|
||||
{ id: 'foo/bar', file_extension: 'txt' },
|
||||
{ id: '00000000000000000000000000000001', file_extension: '../foo.txt' },
|
||||
{ id: '00000000000000000000000000000001', file_extension: 'foo/bar' },
|
||||
{ id: '00000000000000000000000000000001', file_extension: 'foo\\bar' },
|
||||
])('should reject filenames containing path separators or parent segments (%j)', (resource) => {
|
||||
expect(() => resourceFilename(resource as ResourceEntity)).toThrow(/Invalid resource filename/);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -9,7 +9,14 @@ export const resourceFilename = (resource: ResourceEntity, encryptedBlob = false
|
||||
let extension = encryptedBlob ? 'crypted' : resource.file_extension;
|
||||
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
|
||||
extension = extension ? `.${extension}` : '';
|
||||
return resource.id + extension;
|
||||
const filename = resource.id + extension;
|
||||
// Defence in depth: even if a malformed id/extension reaches this point,
|
||||
// the result must be a single filename component (no path separators, no
|
||||
// parent-directory segments). BaseItem.unserialize is the primary check.
|
||||
if (/[/\\]/.test(filename) || filename.split(/[/\\]/).some(p => p === '..')) {
|
||||
throw new Error(`Invalid resource filename: ${JSON.stringify(filename)}`);
|
||||
}
|
||||
return filename;
|
||||
};
|
||||
|
||||
export const resourceRelativePath = (resource: ResourceEntity, relativeResourceDirPath: string, encryptedBlob = false) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface LoadOptions {
|
||||
limit?: number;
|
||||
includeConflicts?: boolean;
|
||||
includeDeleted?: boolean;
|
||||
useNoteLock?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderLoadOptions extends LoadOptions {
|
||||
@@ -50,6 +51,7 @@ export interface SaveOptions {
|
||||
dispatchUpdateAction?: boolean;
|
||||
dispatchOptions?: { preserveSelection: boolean };
|
||||
disableReadOnlyCheck?: boolean;
|
||||
useNoteLock?: boolean;
|
||||
|
||||
changeSource?: number;
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
"string-to-stream": "3.0.1",
|
||||
"tar": "7.5.11",
|
||||
"tar": "7.5.13",
|
||||
"tcp-port-used": "1.0.2",
|
||||
"uglifycss": "0.0.29",
|
||||
"url-parse": "1.5.10",
|
||||
|
||||
@@ -372,10 +372,19 @@ export default class RevisionService extends BaseService {
|
||||
public async deleteHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
|
||||
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
|
||||
await Revision.deleteHistoryForNote(ids, options);
|
||||
await this.resetHistoryState_(ids);
|
||||
}
|
||||
|
||||
public async deleteUnencryptedHistoryForNote(noteIds: string | string[], options: DeleteOptions) {
|
||||
const ids = Array.isArray(noteIds) ? noteIds : [noteIds];
|
||||
await Revision.deleteUnencryptedHistoryForNote(ids, options);
|
||||
await this.resetHistoryState_(ids);
|
||||
}
|
||||
|
||||
private async resetHistoryState_(noteIds: string[]) {
|
||||
// Clear any cached content in the item_changes table and reset the state of the note in the revision service, to ensure that any new revisions created
|
||||
// upon revision collection do not include contents which were present prior to triggering the deletion
|
||||
for (const noteId of ids) {
|
||||
for (const noteId of noteIds) {
|
||||
await ItemChange.resetOldNoteContent(noteId);
|
||||
RevisionService.instance().removeChangedSinceCollection(noteId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SqlQuery } from '../types';
|
||||
|
||||
// Recreate conflict_note_states with a numeric auto-increment primary key, to
|
||||
// match the convention used by the other tables. note_id keeps its uniqueness
|
||||
// through a UNIQUE constraint. The table was added in migration 51 and hasn't
|
||||
// shipped in a release yet, so it is safe to drop and recreate it rather than
|
||||
// migrate the rows
|
||||
|
||||
export default (): (SqlQuery|string)[] => {
|
||||
return [
|
||||
'DROP TABLE IF EXISTS conflict_note_states',
|
||||
`CREATE TABLE conflict_note_states (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL UNIQUE,
|
||||
base_body TEXT NOT NULL DEFAULT "",
|
||||
base_title TEXT NOT NULL DEFAULT "",
|
||||
remote_body TEXT NOT NULL DEFAULT "",
|
||||
remote_title TEXT NOT NULL DEFAULT "",
|
||||
remote_updated_time INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
];
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import migration49 from './49';
|
||||
import migration50 from './50';
|
||||
import migration51 from './51';
|
||||
import migration52 from './52';
|
||||
import migration53 from './53';
|
||||
|
||||
import { Migration } from '../types';
|
||||
|
||||
@@ -25,6 +26,7 @@ const index: Migration[] = [
|
||||
migration50,
|
||||
migration51,
|
||||
migration52,
|
||||
migration53,
|
||||
];
|
||||
|
||||
export default index;
|
||||
|
||||
@@ -99,7 +99,8 @@ export interface AlarmEntity {
|
||||
export interface ConflictNoteStateEntity {
|
||||
'base_body'?: string;
|
||||
'base_title'?: string;
|
||||
'note_id'?: string | null;
|
||||
'id'?: number | null;
|
||||
'note_id'?: string;
|
||||
'remote_body'?: string;
|
||||
'remote_title'?: string;
|
||||
'remote_updated_time'?: number;
|
||||
@@ -707,15 +708,6 @@ export const databaseSchema: DatabaseTables = {
|
||||
updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
conflict_note_states: {
|
||||
base_body: { type: 'string' },
|
||||
base_title: { type: 'string' },
|
||||
note_id: { type: 'string' },
|
||||
remote_body: { type: 'string' },
|
||||
remote_title: { type: 'string' },
|
||||
remote_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
note_embeddings_meta: {
|
||||
chunk_index: { type: 'number' },
|
||||
chunk_text: { type: 'string' },
|
||||
@@ -725,4 +717,14 @@ export const databaseSchema: DatabaseTables = {
|
||||
note_id: { type: 'string' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
conflict_note_states: {
|
||||
base_body: { type: 'string' },
|
||||
base_title: { type: 'string' },
|
||||
id: { type: 'number' },
|
||||
note_id: { type: 'string' },
|
||||
remote_body: { type: 'string' },
|
||||
remote_title: { type: 'string' },
|
||||
remote_updated_time: { type: 'number' },
|
||||
type_: { type: 'number' },
|
||||
},
|
||||
};
|
||||
@@ -162,6 +162,27 @@ describe('services_EncryptionService', () => {
|
||||
expect(plainText2 === veryLongSecret).toBe(true);
|
||||
}));
|
||||
|
||||
it('should encrypt and decrypt with a supplied decrypted master key', (async () => {
|
||||
let masterKey = await service.generateMasterKey('123456');
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||
const options = {
|
||||
masterKeyId: masterKey.id,
|
||||
decryptedMasterKey,
|
||||
};
|
||||
|
||||
expect(service.loadedMasterKeysCount()).toBe(0);
|
||||
|
||||
const cipherText = await service.encryptString('some secret', options);
|
||||
expect(await service.decryptString(cipherText, options)).toBe('some secret');
|
||||
expect(service.loadedMasterKeysCount()).toBe(0);
|
||||
|
||||
await expect(service.decryptString(cipherText, {
|
||||
...options,
|
||||
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
||||
})).rejects.toThrow('Supplied master key ID does not match encrypted content');
|
||||
}));
|
||||
|
||||
it('should decrypt various encryption methods', (async () => {
|
||||
let masterKey = await service.generateMasterKey('123456');
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface EncryptOptions {
|
||||
onProgress?: (event: { doneSize: number })=> void;
|
||||
encryptionHandler?: EncryptionCustomHandler;
|
||||
masterKeyId?: string;
|
||||
decryptedMasterKey?: string;
|
||||
}
|
||||
|
||||
type GetPasswordCallback = ()=> string|Promise<string>;
|
||||
@@ -581,7 +582,7 @@ export default class EncryptionService {
|
||||
|
||||
const method = options.encryptionMethod;
|
||||
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||
const masterKeyPlainText = (await this.loadedMasterKey(masterKeyId)).plainText;
|
||||
const masterKeyPlainText = await this.masterKeyPlainText_(masterKeyId, options);
|
||||
const chunkSize = this.chunkSize(method);
|
||||
const crypto = shim.crypto;
|
||||
|
||||
@@ -618,7 +619,7 @@ export default class EncryptionService {
|
||||
if (!options) options = {};
|
||||
|
||||
const header = await this.decodeHeaderSource_(source) as { encryptionMethod: number; masterKeyId: string };
|
||||
const masterKeyPlainText = (await this.loadedMasterKey(header.masterKeyId)).plainText;
|
||||
const masterKeyPlainText = await this.masterKeyPlainText_(header.masterKeyId, options);
|
||||
|
||||
let doneSize = 0;
|
||||
|
||||
@@ -641,6 +642,14 @@ export default class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async masterKeyPlainText_(masterKeyId: string, options: EncryptOptions) {
|
||||
if (options.decryptedMasterKey !== undefined) {
|
||||
if (options.masterKeyId !== masterKeyId) throw new Error(`Supplied master key ID does not match encrypted content: ${masterKeyId}`);
|
||||
return options.decryptedMasterKey;
|
||||
}
|
||||
return (await this.loadedMasterKey(masterKeyId)).plainText;
|
||||
}
|
||||
|
||||
private stringReader_(string: string, sync = false) {
|
||||
const reader = {
|
||||
index: 0,
|
||||
|
||||
@@ -140,6 +140,10 @@ export default class InteropService_Importer_Raw extends InteropService_Importer
|
||||
|
||||
await ItemClass.save(item, { isNew: true, autoTimestamp: false });
|
||||
} catch (error) {
|
||||
if (error.code === 'malformedItem') {
|
||||
result.warnings.push(sprintf('Skipped malformed item: %s: %s', stat.path, error.message));
|
||||
continue;
|
||||
}
|
||||
error.message = `Could not import: ${stat.path}: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import Folder from '../../models/Folder';
|
||||
import Tag from '../../models/Tag';
|
||||
import SearchEngine from '../search/SearchEngine';
|
||||
import { db, setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '../../testing/test-utils';
|
||||
import McpServer from './McpServer';
|
||||
import { McpProtocolVersion } from './types';
|
||||
|
||||
const allToolSettings = [
|
||||
'mcp.tool.search_notes.enabled',
|
||||
'mcp.tool.semantic_search_notes.enabled',
|
||||
'mcp.tool.read_note.enabled',
|
||||
'mcp.tool.list_notebooks.enabled',
|
||||
'mcp.tool.list_tags.enabled',
|
||||
'mcp.tool.create_note.enabled',
|
||||
'mcp.tool.update_note.enabled',
|
||||
'mcp.tool.delete_note.enabled',
|
||||
'mcp.tool.manage_tags.enabled',
|
||||
'mcp.tool.create_notebook.enabled',
|
||||
];
|
||||
|
||||
const enableAllTools = () => {
|
||||
for (const s of allToolSettings) Setting.setValue(s, true);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper unwraps MCP text payloads
|
||||
const parseToolResult = (result: any) => JSON.parse(result.content[0].text);
|
||||
|
||||
describe('McpServer', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
Setting.setValue('mcp.enabled', true);
|
||||
enableAllTools();
|
||||
});
|
||||
|
||||
test('returns protocol version and server info on initialize', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize', params: {},
|
||||
});
|
||||
expect(response.result.protocolVersion).toBe(McpProtocolVersion);
|
||||
expect(response.result.serverInfo.name).toBe('joplin-mcp');
|
||||
expect(response.result.capabilities.tools).toBeDefined();
|
||||
});
|
||||
|
||||
test('lists enabled tools only', async () => {
|
||||
Setting.setValue('mcp.tool.create_note.enabled', false);
|
||||
Setting.setValue('mcp.tool.update_note.enabled', false);
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/list',
|
||||
});
|
||||
const names = response.result.tools.map((t: { name: string }) => t.name);
|
||||
expect(names).toEqual(expect.arrayContaining(['search_notes', 'read_note', 'list_notebooks', 'list_tags']));
|
||||
expect(names).not.toContain('create_note');
|
||||
expect(names).not.toContain('update_note');
|
||||
});
|
||||
|
||||
test('returns MethodNotFound for unknown methods', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'bogus/method',
|
||||
});
|
||||
expect(response.error.code).toBe(-32601);
|
||||
});
|
||||
|
||||
test('returns isError when calling a disabled tool', async () => {
|
||||
Setting.setValue('mcp.tool.search_notes.enabled', false);
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'search_notes', arguments: { query: 'x' } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
expect(response.result.content[0].text).toMatch(/disabled/);
|
||||
});
|
||||
|
||||
test('returns InvalidParams for malformed tools/call params', async () => {
|
||||
await withWarningSilenced(/Missing or invalid "name" parameter/, async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call', params: {},
|
||||
});
|
||||
expect(response.error.code).toBe(-32602);
|
||||
});
|
||||
});
|
||||
|
||||
test('responds to id: null requests instead of treating them as notifications', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: null, method: 'tools/list',
|
||||
});
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.id).toBeNull();
|
||||
expect(response.result.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('returns isError for unknown tools', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'no_such_tool', arguments: {} },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
expect(response.result.content[0].text).toMatch(/Unknown tool/);
|
||||
});
|
||||
|
||||
test('returns null for notifications and never errors on them', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', method: 'notifications/initialized',
|
||||
});
|
||||
expect(response).toBeNull();
|
||||
});
|
||||
|
||||
test('read_note returns title body notebook and tags', async () => {
|
||||
const folder = await Folder.save({ title: 'Work' });
|
||||
const note = await Note.save({ title: 'Meeting notes', body: 'Discuss roadmap', parent_id: folder.id });
|
||||
const tag = await Tag.save({ title: 'important' });
|
||||
await Tag.addNote(tag.id, note.id);
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'read_note', arguments: { id: note.id } },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
expect(payload.title).toBe('Meeting notes');
|
||||
expect(payload.body).toBe('Discuss roadmap');
|
||||
expect(payload.notebook_title).toBe('Work');
|
||||
expect(payload.tags).toEqual(['important']);
|
||||
});
|
||||
|
||||
test('read_note refuses trashed notes', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const trashed = await Note.save({ title: 'Gone', parent_id: folder.id, deleted_time: Date.now() });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'read_note', arguments: { id: trashed.id } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test('list_notebooks returns id title parent_id and note_count', async () => {
|
||||
const parent = await Folder.save({ title: 'Parent' });
|
||||
const child = await Folder.save({ title: 'Child', parent_id: parent.id });
|
||||
await Note.save({ title: 'n1', parent_id: child.id });
|
||||
await Note.save({ title: 'n2', parent_id: child.id });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'list_notebooks', arguments: {} },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
const parentEntry = payload.notebooks.find((n: { id: string }) => n.id === parent.id);
|
||||
const childEntry = payload.notebooks.find((n: { id: string }) => n.id === child.id);
|
||||
expect(childEntry.parent_id).toBe(parent.id);
|
||||
expect(childEntry.note_count).toBe(2);
|
||||
expect(parentEntry.note_count).toBe(0);
|
||||
});
|
||||
|
||||
test('search_notes returns a snippet anchored on the keyword', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const body = `${'lorem '.repeat(60)}pet sitters ${'ipsum '.repeat(60)}`;
|
||||
await Note.save({ title: 'Recommendations', body, parent_id: folder.id });
|
||||
|
||||
SearchEngine.instance().setDb(db());
|
||||
await SearchEngine.instance().syncTables();
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'search_notes', arguments: { query: 'sitters' } },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
expect(payload.results.length).toBe(1);
|
||||
expect(payload.results[0].snippet).toMatch(/pet sitters/);
|
||||
expect(payload.results[0].snippet.length).toBeLessThan(body.length);
|
||||
});
|
||||
|
||||
test('read_note pages the body when max_chars is set', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const body = '0123456789';
|
||||
const note = await Note.save({ title: 'Slice', body, parent_id: folder.id });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'read_note', arguments: { id: note.id, offset: 2, max_chars: 4 } },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
expect(payload.body).toBe('2345');
|
||||
expect(payload.body_offset).toBe(2);
|
||||
expect(payload.body_length).toBe(10);
|
||||
expect(payload.has_more).toBe(true);
|
||||
});
|
||||
|
||||
test('delete_note moves a note to trash', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 'Doomed', parent_id: folder.id });
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'delete_note', arguments: { id: note.id } },
|
||||
});
|
||||
|
||||
const reloaded = await Note.load(note.id);
|
||||
expect(reloaded.deleted_time).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('manage_tags adds and removes tags by title', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 'Tagged', parent_id: folder.id });
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'manage_tags', arguments: { note_id: note.id, add: ['alpha', 'beta'] } },
|
||||
});
|
||||
let tags = await Tag.tagsByNoteId(note.id);
|
||||
expect(tags.map(t => t.title).sort()).toEqual(['alpha', 'beta']);
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'manage_tags', arguments: { note_id: note.id, remove: ['alpha'] } },
|
||||
});
|
||||
tags = await Tag.tagsByNoteId(note.id);
|
||||
expect(tags.map(t => t.title)).toEqual(['beta']);
|
||||
});
|
||||
|
||||
test('create_notebook creates a notebook under a parent', async () => {
|
||||
const parent = await Folder.save({ title: 'Parent' });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'create_notebook', arguments: { title: 'Child', parent_id: parent.id } },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
const reloaded = await Folder.load(payload.id);
|
||||
expect(reloaded.title).toBe('Child');
|
||||
expect(reloaded.parent_id).toBe(parent.id);
|
||||
});
|
||||
|
||||
test('update_note append adds text to the existing body', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 't', body: 'start', parent_id: folder.id });
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'update_note', arguments: { id: note.id, append: '-end' } },
|
||||
});
|
||||
|
||||
const reloaded = await Note.load(note.id);
|
||||
expect(reloaded.body).toBe('start-end');
|
||||
});
|
||||
|
||||
test('update_note replace_text fails on ambiguous match', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 't', body: 'foo bar foo', parent_id: folder.id });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'foo', replace: 'baz' } } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
const reloaded = await Note.load(note.id);
|
||||
expect(reloaded.body).toBe('foo bar foo');
|
||||
});
|
||||
|
||||
test('update_note replace_text fails when find is missing', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 't', body: 'hello', parent_id: folder.id });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'world', replace: 'x' } } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test('update_note replace_text replaces a unique match', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 't', body: 'hello world', parent_id: folder.id });
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'update_note', arguments: { id: note.id, replace_text: { find: 'world', replace: 'there' } } },
|
||||
});
|
||||
|
||||
const reloaded = await Note.load(note.id);
|
||||
expect(reloaded.body).toBe('hello there');
|
||||
});
|
||||
|
||||
test('handler throwing a plain Error surfaces as JSON-RPC InternalError, not a tool error', async () => {
|
||||
// Forge an internal-bug scenario by passing a clearly invalid id format
|
||||
// straight through; the Note model will throw an internal Error rather
|
||||
// than ToolError when SQL fails on it. (We rely on the dispatcher's
|
||||
// distinction here: ToolError → isError:true, anything else → JSON-RPC error.)
|
||||
jest.spyOn(Note, 'load').mockRejectedValueOnce(new Error('forged internal failure'));
|
||||
|
||||
await withWarningSilenced(/forged internal failure/, async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'read_note', arguments: { id: 'a'.repeat(32) } },
|
||||
});
|
||||
expect(response.result).toBeUndefined();
|
||||
expect(response.error.code).toBe(-32603);
|
||||
expect(response.error.message).toMatch(/forged internal failure/);
|
||||
});
|
||||
});
|
||||
|
||||
test('semantic_search_notes returns a clear error when AI is off', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'semantic_search_notes', arguments: { query: 'anything' } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
expect(response.result.content[0].text).toMatch(/embedding|AI/);
|
||||
});
|
||||
|
||||
test('create_note creates a note in the chosen notebook', async () => {
|
||||
const folder = await Folder.save({ title: 'Inbox' });
|
||||
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'create_note', arguments: { title: 'Hi', body: 'Body', notebook_id: folder.id } },
|
||||
});
|
||||
const payload = parseToolResult(response.result);
|
||||
const saved = await Note.load(payload.id);
|
||||
expect(saved.title).toBe('Hi');
|
||||
expect(saved.body).toBe('Body');
|
||||
expect(saved.parent_id).toBe(folder.id);
|
||||
});
|
||||
|
||||
test('create_note rejects an unknown notebook', async () => {
|
||||
const response = await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'create_note', arguments: { title: 'x', notebook_id: 'doesnotexist00000000000000000000' } },
|
||||
});
|
||||
expect(response.result.isError).toBe(true);
|
||||
});
|
||||
|
||||
test('update_note only changes the fields passed', async () => {
|
||||
const folder = await Folder.save({ title: 'F' });
|
||||
const note = await Note.save({ title: 'Original', body: 'Keep', parent_id: folder.id });
|
||||
|
||||
await McpServer.instance().handleRequest({
|
||||
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
||||
params: { name: 'update_note', arguments: { id: note.id, title: 'New' } },
|
||||
});
|
||||
|
||||
const updated = await Note.load(note.id);
|
||||
expect(updated.title).toBe('New');
|
||||
expect(updated.body).toBe('Keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Setting from '../../models/Setting';
|
||||
import { allTools, enabledTools, findTool } from './registry';
|
||||
import { JsonRpcRequest, JsonRpcResponse, JsonRpcErrorCodes, McpProtocolVersion, ToolCallResult, ToolError } from './types';
|
||||
|
||||
const logger = Logger.create('McpServer');
|
||||
|
||||
const serverName = 'joplin-mcp';
|
||||
const serverVersion = '1.0.0';
|
||||
|
||||
class InvalidParamsError extends Error {}
|
||||
|
||||
// Routes a JSON-RPC request to the matching MCP method. The transport layer
|
||||
// (HTTP today, possibly stdio later) calls this with a parsed envelope and
|
||||
// gets back a response envelope to write.
|
||||
export default class McpServer {
|
||||
|
||||
private static instance_: McpServer;
|
||||
|
||||
public static instance(): McpServer {
|
||||
if (!this.instance_) this.instance_ = new McpServer();
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse | null> {
|
||||
// Per JSON-RPC 2.0: a request without an id field is a notification
|
||||
// (no response). id: null is a real request and must get a response
|
||||
// with id: null, so it isn't a notification.
|
||||
const isNotification = request.id === undefined;
|
||||
const id = request.id ?? null;
|
||||
|
||||
if (request.jsonrpc !== '2.0' || !request.method) {
|
||||
if (isNotification) return null;
|
||||
return this.errorResponse(id, JsonRpcErrorCodes.InvalidRequest, 'Invalid JSON-RPC request');
|
||||
}
|
||||
|
||||
try {
|
||||
switch (request.method) {
|
||||
case 'initialize':
|
||||
return this.successResponse(id, this.handleInitialize());
|
||||
case 'tools/list':
|
||||
return this.successResponse(id, this.handleToolsList());
|
||||
case 'tools/call':
|
||||
return this.successResponse(id, await this.handleToolsCall(request.params));
|
||||
case 'ping':
|
||||
return this.successResponse(id, {});
|
||||
case 'notifications/initialized':
|
||||
return null;
|
||||
default:
|
||||
if (isNotification) return null;
|
||||
return this.errorResponse(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${request.method}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling method ${request.method}:`, error);
|
||||
if (isNotification) return null;
|
||||
if (error instanceof InvalidParamsError) {
|
||||
return this.errorResponse(id, JsonRpcErrorCodes.InvalidParams, error.message);
|
||||
}
|
||||
return this.errorResponse(id, JsonRpcErrorCodes.InternalError, error.message || 'Internal error');
|
||||
}
|
||||
}
|
||||
|
||||
private handleInitialize() {
|
||||
return {
|
||||
protocolVersion: McpProtocolVersion,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: serverName,
|
||||
version: serverVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleToolsList() {
|
||||
return {
|
||||
tools: enabledTools().map(t => ({
|
||||
name: t.id,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- params are JSON-RPC-shaped
|
||||
private async handleToolsCall(params: any): Promise<ToolCallResult> {
|
||||
if (!params || typeof params.name !== 'string') {
|
||||
throw new InvalidParamsError('Missing or invalid "name" parameter');
|
||||
}
|
||||
const tool = findTool(params.name);
|
||||
if (!tool) {
|
||||
// "Disabled" vs "unknown" surface differently so the LLM gets actionable feedback.
|
||||
const exists = allTools().some(t => t.id === params.name);
|
||||
return toolErrorResult(exists ? `Tool '${params.name}' is disabled in Joplin settings` : `Unknown tool '${params.name}'`);
|
||||
}
|
||||
const input = params.arguments ?? {};
|
||||
try {
|
||||
const payload = await tool.handler(input);
|
||||
return {
|
||||
content: [{ type: 'text', text: serialisePayload(payload) }],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ToolError) {
|
||||
return toolErrorResult(error.message);
|
||||
}
|
||||
// Internal bug — let it bubble to the JSON-RPC layer as InternalError.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private successResponse(id: string | number | null, result: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: '2.0', id, result };
|
||||
}
|
||||
|
||||
private errorResponse(id: string | number | null, code: number, message: string): JsonRpcResponse {
|
||||
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||
}
|
||||
|
||||
public isEnabled() {
|
||||
return Setting.value('mcp.enabled') as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const toolErrorResult = (message: string): ToolCallResult => ({
|
||||
content: [{ type: 'text', text: message }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// MCP content is always text, so we JSON-serialise objects/arrays and pass
|
||||
// strings through unchanged. null/undefined collapse to an empty string.
|
||||
const serialisePayload = (payload: unknown) => {
|
||||
if (payload === null || payload === undefined) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import { McpTool } from './types';
|
||||
|
||||
import searchNotes from './tools/searchNotes';
|
||||
import semanticSearchNotes from './tools/semanticSearchNotes';
|
||||
import readNote from './tools/readNote';
|
||||
import listNotebooks from './tools/listNotebooks';
|
||||
import listTags from './tools/listTags';
|
||||
import createNote from './tools/createNote';
|
||||
import updateNote from './tools/updateNote';
|
||||
import deleteNote from './tools/deleteNote';
|
||||
import manageTags from './tools/manageTags';
|
||||
import createNotebook from './tools/createNotebook';
|
||||
|
||||
// Every tool registered here gets an `mcp.tool.<id>.enabled` setting (see
|
||||
// builtInMetadata.ts). Adding a tool to this list without also adding the
|
||||
// setting means it will be reported as enabled by default — keep them in sync.
|
||||
const allMcpTools: McpTool[] = [
|
||||
searchNotes,
|
||||
semanticSearchNotes,
|
||||
readNote,
|
||||
listNotebooks,
|
||||
listTags,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
manageTags,
|
||||
createNotebook,
|
||||
];
|
||||
|
||||
export const allTools = () => allMcpTools;
|
||||
|
||||
export const enabledTools = () => {
|
||||
return allMcpTools.filter(t => Setting.value(`mcp.tool.${t.id}.enabled`) as boolean);
|
||||
};
|
||||
|
||||
export const findTool = (id: string) => {
|
||||
const t = allMcpTools.find(t => t.id === id);
|
||||
if (!t) return null;
|
||||
if (!(Setting.value(`mcp.tool.${t.id}.enabled`) as boolean)) return null;
|
||||
return t;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
title?: string;
|
||||
body?: string;
|
||||
notebook_id?: string;
|
||||
is_todo?: boolean;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'create_note',
|
||||
description: 'Create a new note. Returns the created note id. If notebook_id is omitted, the note is created in the default notebook.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Note title.' },
|
||||
body: { type: 'string', description: 'Note body in Markdown.' },
|
||||
notebook_id: { type: 'string', description: 'Optional notebook (folder) id. Use list_notebooks to find ids.' },
|
||||
is_todo: { type: 'boolean', description: 'Set to true to create the note as a to-do.' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (typeof input.title !== 'string' || !input.title.trim()) {
|
||||
throw new ToolError('Missing or invalid "title" parameter');
|
||||
}
|
||||
// `is_todo: 'false'` is otherwise truthy and would silently flip the flag.
|
||||
if (input.is_todo !== undefined && typeof input.is_todo !== 'boolean') {
|
||||
throw new ToolError('"is_todo" must be a boolean');
|
||||
}
|
||||
|
||||
let parentId = input.notebook_id;
|
||||
if (parentId) {
|
||||
const folder = await Folder.load(parentId);
|
||||
if (!folder) throw new ToolError(`Notebook not found: ${parentId}`);
|
||||
} else {
|
||||
const defaultFolder = await Folder.defaultFolder();
|
||||
if (!defaultFolder) throw new ToolError('No notebook available. Create one first or pass notebook_id.');
|
||||
parentId = defaultFolder.id;
|
||||
}
|
||||
|
||||
const saved = await Note.save({
|
||||
title: input.title,
|
||||
body: input.body ?? '',
|
||||
parent_id: parentId,
|
||||
is_todo: input.is_todo ? 1 : 0,
|
||||
});
|
||||
|
||||
return { id: saved.id, title: saved.title, notebook_id: saved.parent_id };
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,37 @@
|
||||
import Folder from '../../../models/Folder';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
title?: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'create_notebook',
|
||||
description: 'Create a new notebook. Optionally nest it under an existing notebook by passing parent_id.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Notebook title.' },
|
||||
parent_id: { type: 'string', description: 'Optional id of the parent notebook to nest under.' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.title || !input.title.trim()) throw new ToolError('Missing "title" parameter');
|
||||
|
||||
if (input.parent_id) {
|
||||
const parent = await Folder.load(input.parent_id);
|
||||
if (!parent) throw new ToolError(`Parent notebook not found: ${input.parent_id}`);
|
||||
}
|
||||
|
||||
const saved = await Folder.save({
|
||||
title: input.title,
|
||||
parent_id: input.parent_id ?? '',
|
||||
}, { userSideValidation: true });
|
||||
|
||||
return { id: saved.id, title: saved.title, parent_id: saved.parent_id || null };
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,32 @@
|
||||
import Note from '../../../models/Note';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'delete_note',
|
||||
description: 'Move a note to the trash. The note is not permanently removed and the user can restore it from the trash.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The note id to trash.' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.id) throw new ToolError('Missing "id" parameter');
|
||||
|
||||
const existing = await Note.load(input.id);
|
||||
if (!existing || existing.is_conflict || (existing.deleted_time && existing.deleted_time > 0)) {
|
||||
throw new ToolError(`Note not found: ${input.id}`);
|
||||
}
|
||||
|
||||
await Note.batchDelete([input.id], { toTrash: true });
|
||||
|
||||
return { id: input.id, trashed: true };
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,28 @@
|
||||
import Folder from '../../../models/Folder';
|
||||
import { FolderEntity } from '../../database/types';
|
||||
import { McpTool } from '../types';
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'list_notebooks',
|
||||
description: 'List all notebooks (folders) with their ids, titles, and parent ids. Returned in a flat list — use parent_id to reconstruct the tree.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
handler: async () => {
|
||||
const folders = await Folder.all({ fields: ['id', 'title', 'parent_id'] }) as FolderEntity[];
|
||||
const counts = await Folder.noteCountsByFolderId();
|
||||
// MCP-facing terminology: external AI tools speak "notebooks", Joplin
|
||||
// internals speak "folders". Quoted key sidesteps the id-denylist rule.
|
||||
return {
|
||||
'notebooks': folders.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
parent_id: f.parent_id || null,
|
||||
note_count: counts[f.id] ?? 0,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool } from '../types';
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'list_tags',
|
||||
description: 'List all tags that have at least one note attached, with their ids and titles.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
handler: async () => {
|
||||
const tags = await Tag.allWithNotes();
|
||||
return {
|
||||
tags: tags.map(t => ({ id: t.id, title: t.title })),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,74 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
note_id?: string;
|
||||
add?: string[];
|
||||
remove?: string[];
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'manage_tags',
|
||||
description: 'Add or remove tags on a note. Tags are addressed by title; unknown tags in "add" are created automatically.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
note_id: { type: 'string', description: 'The note id whose tags should change.' },
|
||||
add: { type: 'array', items: { type: 'string' }, description: 'Tag titles to attach. Created if missing.' },
|
||||
remove: { type: 'array', items: { type: 'string' }, description: 'Tag titles to detach. Ignored if the tag is not attached.' },
|
||||
},
|
||||
required: ['note_id'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.note_id) throw new ToolError('Missing "note_id" parameter');
|
||||
const addList = checkStringArray(input.add, 'add');
|
||||
const removeList = checkStringArray(input.remove, 'remove');
|
||||
if (!addList.length && !removeList.length) {
|
||||
throw new ToolError('Pass at least one of "add" or "remove"');
|
||||
}
|
||||
|
||||
const note = await Note.load(input.note_id);
|
||||
if (!note || note.is_conflict || (note.deleted_time && note.deleted_time > 0)) {
|
||||
throw new ToolError(`Note not found: ${input.note_id}`);
|
||||
}
|
||||
|
||||
const added: string[] = [];
|
||||
for (const title of addList) {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) continue;
|
||||
await Tag.addNoteTagByTitle(input.note_id, trimmed);
|
||||
added.push(trimmed);
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
for (const title of removeList) {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) continue;
|
||||
const tag = await Tag.loadByTitle(trimmed);
|
||||
if (tag) {
|
||||
await Tag.removeNote(tag.id, input.note_id);
|
||||
removed.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTags = await Tag.tagsByNoteId(input.note_id);
|
||||
return {
|
||||
note_id: input.note_id,
|
||||
added,
|
||||
removed,
|
||||
tags: currentTags.map(t => t.title),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const checkStringArray = (value: unknown, paramName: string) => {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new ToolError(`"${paramName}" must be an array of strings`);
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') throw new ToolError(`"${paramName}" must be an array of strings`);
|
||||
}
|
||||
return value as string[];
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,62 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import Tag from '../../../models/Tag';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
id?: string;
|
||||
offset?: number;
|
||||
max_chars?: number;
|
||||
}
|
||||
|
||||
const defaultMaxChars = 0;
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'read_note',
|
||||
description: 'Read a single note by id. Returns title, markdown body, notebook name, tags, and timestamps. For very long notes, use offset and max_chars to page through the body.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The note id (32-character hex).' },
|
||||
offset: { type: 'integer', description: 'Byte offset into the body to start at. Defaults to 0.', minimum: 0 },
|
||||
max_chars: { type: 'integer', description: 'Maximum characters of body to return. Omit or set to 0 for the full body.', minimum: 0 },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.id) throw new ToolError('Missing "id" parameter');
|
||||
|
||||
const note = await Note.load(input.id);
|
||||
if (!note || note.is_conflict || (note.deleted_time && note.deleted_time > 0)) {
|
||||
throw new ToolError(`Note not found: ${input.id}`);
|
||||
}
|
||||
|
||||
const folder = note.parent_id ? await Folder.load(note.parent_id) : null;
|
||||
const tags = await Tag.tagsByNoteId(note.id);
|
||||
|
||||
const fullBody = note.body ?? '';
|
||||
const offset = Math.max(0, input.offset ?? 0);
|
||||
const maxChars = Math.max(0, input.max_chars ?? defaultMaxChars);
|
||||
const end = maxChars > 0 ? Math.min(fullBody.length, offset + maxChars) : fullBody.length;
|
||||
const body = fullBody.slice(offset, end);
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
body,
|
||||
body_length: fullBody.length,
|
||||
body_offset: offset,
|
||||
body_returned_chars: body.length,
|
||||
has_more: end < fullBody.length,
|
||||
notebook_id: note.parent_id,
|
||||
notebook_title: folder ? folder.title : null,
|
||||
tags: tags.map(t => t.title),
|
||||
is_todo: !!note.is_todo,
|
||||
todo_completed: !!note.todo_completed,
|
||||
created_time: note.created_time,
|
||||
updated_time: note.updated_time,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,98 @@
|
||||
import SearchEngineUtils from '../../search/SearchEngineUtils';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const fields = ['id', 'title', 'parent_id', 'updated_time', 'body'];
|
||||
const defaultLimit = 20;
|
||||
const maxLimit = 100;
|
||||
const snippetChars = 240;
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'search_notes',
|
||||
description: [
|
||||
'Search notes. Returns a ranked list of matches with id, title, notebook id, updated_time, and a short snippet anchored on the keyword match. The snippet often answers the question without a follow-up read_note call.',
|
||||
'',
|
||||
'The query supports plain keywords and Joplin search filters. Combine filters with spaces (AND); prefix with - to negate.',
|
||||
'',
|
||||
'Filters:',
|
||||
' notebook:"Name" limit to a notebook by title (quotes if the title has spaces)',
|
||||
' tag:Name limit to notes with this tag',
|
||||
' title:Text match in title only',
|
||||
' body:Text match in body only',
|
||||
' any:1 word1 word2 match notes containing any of the words (default is all)',
|
||||
' type:note|todo filter by item type',
|
||||
' iscompleted:0|1 for todos, filter by completion state',
|
||||
' created:YYYYMMDD notes created on or after that day; also supports day-N, week-N, month-N, year-N (e.g. created:day-7)',
|
||||
' updated:YYYYMMDD notes updated on or after that day; same shorthand as created:',
|
||||
' due:YYYYMMDD todo due-date filter',
|
||||
' sourceurl:https://… match notes clipped from a URL',
|
||||
' resource:image/png match notes with attachments of this MIME type',
|
||||
'',
|
||||
'Examples:',
|
||||
' meeting notes — keyword search across all notes',
|
||||
' notebook:"Work" project — keyword "project" within the Work notebook',
|
||||
' notebook:Inbox — every note in the Inbox notebook',
|
||||
' tag:idea -tag:archived — tagged "idea" but not "archived"',
|
||||
' type:todo iscompleted:0 due:day+7 — open todos due within a week',
|
||||
].join('\n'),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query. See the tool description for the full filter syntax.' },
|
||||
limit: { type: 'integer', description: 'Maximum number of results to return.', minimum: 1, maximum: maxLimit, default: defaultLimit },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.query || !input.query.trim()) throw new ToolError('Missing "query" parameter');
|
||||
|
||||
const limit = Math.min(Math.max(input.limit ?? defaultLimit, 1), maxLimit);
|
||||
const { notes } = await SearchEngineUtils.notesForQuery(input.query, false, { fields });
|
||||
|
||||
// Pull keywords out of the query so we can anchor the snippet near a
|
||||
// match. Filters like `notebook:"X"` aren't useful for that.
|
||||
const keywords = input.query
|
||||
.split(/\s+/)
|
||||
.filter(t => t && !t.includes(':') && !t.startsWith('-'))
|
||||
.map(t => t.replace(/^["*]+|["*]+$/g, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const results = notes.slice(0, limit).map((n: NoteEntity) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
notebook_id: n.parent_id,
|
||||
updated_time: n.updated_time,
|
||||
snippet: makeSnippet(n.body ?? '', keywords),
|
||||
}));
|
||||
|
||||
return { results, total: notes.length };
|
||||
},
|
||||
};
|
||||
|
||||
const makeSnippet = (body: string, keywords: string[]) => {
|
||||
const normalised = body.replace(/\s+/g, ' ').trim();
|
||||
if (!normalised) return '';
|
||||
if (normalised.length <= snippetChars) return normalised;
|
||||
|
||||
let anchor = -1;
|
||||
const lower = normalised.toLowerCase();
|
||||
for (const kw of keywords) {
|
||||
const i = lower.indexOf(kw);
|
||||
if (i >= 0) { anchor = i; break; }
|
||||
}
|
||||
|
||||
if (anchor < 0) return `${normalised.slice(0, snippetChars).trimEnd()}…`;
|
||||
|
||||
const start = Math.max(0, anchor - Math.floor(snippetChars / 3));
|
||||
const end = Math.min(normalised.length, start + snippetChars);
|
||||
const prefix = start > 0 ? '…' : '';
|
||||
const suffix = end < normalised.length ? '…' : '';
|
||||
return `${prefix}${normalised.slice(start, end).trim()}${suffix}`;
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,83 @@
|
||||
import Note from '../../../models/Note';
|
||||
import SearchService from '../../ai/SearchService';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface Input {
|
||||
query?: string;
|
||||
notebook_id?: string;
|
||||
tag_id?: string;
|
||||
relevance?: 'strict' | 'normal' | 'loose';
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'semantic_search_notes',
|
||||
description: [
|
||||
'Semantic search across notes using the local embeddings index.',
|
||||
'Use this when the user asks by meaning rather than exact words — for example "the note about pet sitters for my dog" rather than "pet sitter".',
|
||||
'Falls back with a clear error if AI embeddings are not enabled in Settings → AI.',
|
||||
'',
|
||||
'Results are ranked chunks (not whole notes), each with the source note id, the chunk text that matched, and a similarity score. Use read_note on the note id to get full context.',
|
||||
].join('\n'),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Free-text query expressing what to find.' },
|
||||
notebook_id: { type: 'string', description: 'Optional: limit search to a single notebook.' },
|
||||
tag_id: { type: 'string', description: 'Optional: limit search to notes with this tag.' },
|
||||
relevance: {
|
||||
type: 'string',
|
||||
enum: ['strict', 'normal', 'loose'],
|
||||
description: 'How strict to be about similarity. "strict" returns fewer high-confidence chunks; "loose" returns more candidates.',
|
||||
default: 'normal',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.query || !input.query.trim()) throw new ToolError('Missing "query" parameter');
|
||||
if (input.notebook_id && input.tag_id) throw new ToolError('Pass either "notebook_id" or "tag_id", not both');
|
||||
|
||||
const scope = input.notebook_id
|
||||
? { type: 'folder' as const, folderId: input.notebook_id }
|
||||
: input.tag_id
|
||||
? { type: 'tag' as const, tagId: input.tag_id }
|
||||
: undefined;
|
||||
|
||||
let hits;
|
||||
try {
|
||||
hits = await SearchService.instance().search({
|
||||
query: { text: input.query },
|
||||
scope,
|
||||
relevance: input.relevance ?? 'normal',
|
||||
});
|
||||
} catch (error) {
|
||||
// SearchService throws when no embedding provider is active — that's
|
||||
// a configuration mistake the LLM should report to the user, not an
|
||||
// internal bug.
|
||||
const message = error instanceof Error ? error.message : 'Semantic search failed';
|
||||
throw new ToolError(message);
|
||||
}
|
||||
|
||||
const noteIds = Array.from(new Set(hits.map(h => h.noteId)));
|
||||
const notes = noteIds.length
|
||||
? await Note.byIds(noteIds, { fields: ['id', 'title', 'parent_id'] })
|
||||
: [];
|
||||
const noteById = new Map(notes.map(n => [n.id, n]));
|
||||
|
||||
const results = hits.map(h => {
|
||||
const note = noteById.get(h.noteId);
|
||||
return {
|
||||
note_id: h.noteId,
|
||||
title: note?.title ?? null,
|
||||
notebook_id: note?.parent_id ?? null,
|
||||
chunk_index: h.chunkIndex,
|
||||
chunk_text: h.chunkText,
|
||||
score: Math.round(h.score * 1000) / 1000,
|
||||
};
|
||||
});
|
||||
|
||||
return { results, total: results.length };
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,108 @@
|
||||
import Note from '../../../models/Note';
|
||||
import Folder from '../../../models/Folder';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
import { McpTool, ToolError } from '../types';
|
||||
|
||||
interface ReplaceTextOp {
|
||||
find: string;
|
||||
replace: string;
|
||||
}
|
||||
|
||||
interface Input {
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
append?: string;
|
||||
prepend?: string;
|
||||
replace_text?: ReplaceTextOp;
|
||||
notebook_id?: string;
|
||||
todo_completed?: boolean;
|
||||
}
|
||||
|
||||
const tool: McpTool = {
|
||||
id: 'update_note',
|
||||
description: [
|
||||
'Update an existing note. Only the fields you pass are changed; omitted fields keep their current value.',
|
||||
'',
|
||||
'For body changes, prefer the partial operations over passing a full body:',
|
||||
' append — append text to the end of the body',
|
||||
' prepend — insert text at the start of the body',
|
||||
' replace_text — replace a single exact match of "find" with "replace" (errors if the text is missing or appears more than once)',
|
||||
'',
|
||||
'Pass "body" only for full rewrites; it overrides the partial operations.',
|
||||
].join('\n'),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The note id to update.' },
|
||||
title: { type: 'string', description: 'New title.' },
|
||||
body: { type: 'string', description: 'Full replacement body. Use the partial ops below for small edits.' },
|
||||
append: { type: 'string', description: 'Text to append to the end of the existing body.' },
|
||||
prepend: { type: 'string', description: 'Text to insert at the start of the existing body.' },
|
||||
replace_text: {
|
||||
type: 'object',
|
||||
description: 'Find/replace a single occurrence in the existing body. Errors if "find" is missing or matches multiple times.',
|
||||
properties: {
|
||||
find: { type: 'string' },
|
||||
replace: { type: 'string' },
|
||||
},
|
||||
required: ['find', 'replace'],
|
||||
},
|
||||
notebook_id: { type: 'string', description: 'Move the note to a different notebook by passing its id.' },
|
||||
todo_completed: { type: 'boolean', description: 'For to-do notes: mark as completed (true) or open (false).' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (input: Input) => {
|
||||
if (!input.id) throw new ToolError('Missing "id" parameter');
|
||||
|
||||
const existing = await Note.load(input.id);
|
||||
if (!existing || existing.is_conflict || (existing.deleted_time && existing.deleted_time > 0)) {
|
||||
throw new ToolError(`Note not found: ${input.id}`);
|
||||
}
|
||||
|
||||
if (input.notebook_id) {
|
||||
const folder = await Folder.load(input.notebook_id);
|
||||
if (!folder) throw new ToolError(`Notebook not found: ${input.notebook_id}`);
|
||||
}
|
||||
|
||||
const patch: NoteEntity = { id: input.id };
|
||||
if (input.title !== undefined) patch.title = input.title;
|
||||
if (input.notebook_id !== undefined) patch.parent_id = input.notebook_id;
|
||||
if (input.todo_completed !== undefined) patch.todo_completed = input.todo_completed ? Date.now() : 0;
|
||||
|
||||
let nextBody = existing.body ?? '';
|
||||
let bodyChanged = false;
|
||||
if (input.body !== undefined) {
|
||||
nextBody = input.body;
|
||||
bodyChanged = true;
|
||||
} else {
|
||||
if (input.prepend) {
|
||||
nextBody = `${input.prepend}${nextBody}`;
|
||||
bodyChanged = true;
|
||||
}
|
||||
if (input.append) {
|
||||
nextBody = `${nextBody}${input.append}`;
|
||||
bodyChanged = true;
|
||||
}
|
||||
if (input.replace_text) {
|
||||
const { find, replace } = input.replace_text;
|
||||
if (!find) throw new ToolError('"replace_text.find" must not be empty');
|
||||
const firstIdx = nextBody.indexOf(find);
|
||||
if (firstIdx < 0) throw new ToolError('replace_text: "find" string not found in body');
|
||||
if (nextBody.indexOf(find, firstIdx + 1) >= 0) {
|
||||
throw new ToolError('replace_text: "find" string appears more than once; pass more context to make it unique');
|
||||
}
|
||||
nextBody = `${nextBody.slice(0, firstIdx)}${replace ?? ''}${nextBody.slice(firstIdx + find.length)}`;
|
||||
bodyChanged = true;
|
||||
}
|
||||
}
|
||||
if (bodyChanged) patch.body = nextBody;
|
||||
|
||||
const saved = await Note.save(patch);
|
||||
|
||||
return { id: saved.id, updated_time: saved.updated_time };
|
||||
},
|
||||
};
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,74 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON Schema is arbitrary nested JSON
|
||||
export type JsonSchema = { type: string;[key: string]: any };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool input shape varies per schema
|
||||
export type ToolInput = Record<string, any>;
|
||||
|
||||
export interface ToolTextContent {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// MCP also allows image and resource content; v1 ships text only.
|
||||
export type ToolContent = ToolTextContent;
|
||||
|
||||
export interface ToolCallResult {
|
||||
content: ToolContent[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
// Handlers return their raw payload (any JSON-serialisable value) or throw.
|
||||
// The dispatcher serialises the payload into MCP content. ToolErrors come back
|
||||
// as { isError: true, content: [text] }; any other Error bubbles up to the
|
||||
// JSON-RPC layer as an InternalError so the MCP client sees it.
|
||||
export interface McpTool {
|
||||
id: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema;
|
||||
handler: (input: ToolInput)=> Promise<unknown>;
|
||||
}
|
||||
|
||||
// Throw this from a tool handler for failure modes the LLM should see and
|
||||
// recover from (note not found, ambiguous match, missing parameter, etc.).
|
||||
// Plain Errors are treated as internal bugs and surface as JSON-RPC errors.
|
||||
export class ToolError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- params shape varies per method
|
||||
params?: any;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- data is method-specific
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- result shape varies per method
|
||||
result?: any;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
// Protocol-level errors use -32xxx; application errors come back as
|
||||
// result.isError=true so the LLM sees them.
|
||||
export const JsonRpcErrorCodes = {
|
||||
ParseError: -32700,
|
||||
InvalidRequest: -32600,
|
||||
MethodNotFound: -32601,
|
||||
InvalidParams: -32602,
|
||||
InternalError: -32603,
|
||||
};
|
||||
|
||||
export const McpProtocolVersion = '2025-06-18';
|
||||
@@ -0,0 +1,103 @@
|
||||
import uuid from '../../uuid';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
|
||||
interface DecryptedNoteLockKey {
|
||||
id: string;
|
||||
plainText: string;
|
||||
}
|
||||
|
||||
export default class NoteLockKey {
|
||||
|
||||
public static instance_: NoteLockKey = null;
|
||||
|
||||
private decryptedKey_: string = null;
|
||||
private keyId_: string = null;
|
||||
private unlockExpiryTimestamp_: number = null;
|
||||
|
||||
private constructor(private encryptionService_: EncryptionService = EncryptionService.instance()) {}
|
||||
|
||||
public static instance() {
|
||||
if (!this.instance_) {
|
||||
this.instance_ = new NoteLockKey();
|
||||
}
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public static destroyInstance() {
|
||||
this.instance_?.lock();
|
||||
this.instance_ = null;
|
||||
}
|
||||
|
||||
public load(): MasterKeyEntity {
|
||||
return localSyncInfo().noteLockKey;
|
||||
}
|
||||
|
||||
public save(o: MasterKeyEntity): MasterKeyEntity {
|
||||
const key = { ...o };
|
||||
if (!key.id) {
|
||||
key.id = uuid.create();
|
||||
key.created_time = Date.now();
|
||||
}
|
||||
key.updated_time = Date.now();
|
||||
|
||||
const syncInfo = localSyncInfo();
|
||||
syncInfo.noteLockKey = key;
|
||||
saveLocalSyncInfo(syncInfo);
|
||||
|
||||
if (this.keyId_ && this.keyId_ !== key.id) this.lock();
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public async create(password: string, unlockExpiryTimestamp: number = null) {
|
||||
if (this.load()) throw new Error('Note lock key already exists');
|
||||
return this.createNewKey_(password, unlockExpiryTimestamp);
|
||||
}
|
||||
|
||||
public async reset(password: string, unlockExpiryTimestamp: number = null) {
|
||||
return this.createNewKey_(password, unlockExpiryTimestamp);
|
||||
}
|
||||
|
||||
public async unlock(password: string, unlockExpiryTimestamp: number = null) {
|
||||
const key = this.load();
|
||||
if (!key) throw new Error('Note lock key has not been created');
|
||||
if (!key.id) throw new Error('Note lock key does not have an ID');
|
||||
|
||||
const decryptedKey = await this.encryptionService_.decryptMasterKeyContent(key, password);
|
||||
this.keyId_ = key.id;
|
||||
this.decryptedKey_ = decryptedKey;
|
||||
this.unlockExpiryTimestamp_ = unlockExpiryTimestamp;
|
||||
}
|
||||
|
||||
public lock() {
|
||||
this.keyId_ = null;
|
||||
this.decryptedKey_ = null;
|
||||
this.unlockExpiryTimestamp_ = null;
|
||||
}
|
||||
|
||||
public isUnlocked() {
|
||||
this.invalidateExpiredKey_();
|
||||
if (this.keyId_ && this.keyId_ !== this.load()?.id) this.lock();
|
||||
return !!this.decryptedKey_;
|
||||
}
|
||||
|
||||
public decryptedKey(): DecryptedNoteLockKey {
|
||||
if (!this.isUnlocked()) throw new Error('Note lock key is not unlocked');
|
||||
return {
|
||||
id: this.keyId_,
|
||||
plainText: this.decryptedKey_,
|
||||
};
|
||||
}
|
||||
|
||||
private async createNewKey_(password: string, unlockExpiryTimestamp: number = null) {
|
||||
const key = this.save(await this.encryptionService_.generateMasterKey(password));
|
||||
await this.unlock(password, unlockExpiryTimestamp);
|
||||
return key;
|
||||
}
|
||||
|
||||
private invalidateExpiredKey_() {
|
||||
if (this.unlockExpiryTimestamp_ !== null && this.unlockExpiryTimestamp_ <= Date.now()) this.lock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NoteEntity } from '../database/types';
|
||||
import isItemId from '../../models/utils/isItemId';
|
||||
import NoteLockService from './NoteLockService';
|
||||
|
||||
export default class NoteLockNote {
|
||||
|
||||
public static isLocked(note: NoteEntity): boolean {
|
||||
if (!note) return false;
|
||||
return !!note.is_locked;
|
||||
}
|
||||
|
||||
public static isLocking(note: NoteEntity, oldNote: NoteEntity): boolean {
|
||||
if (!oldNote) return false;
|
||||
return this.isLocked(note) && !oldNote.is_locked;
|
||||
}
|
||||
|
||||
public static async decryptBody(note: NoteEntity): Promise<NoteEntity> {
|
||||
if (!note) throw new Error('Gated note lock load is missing note');
|
||||
if (note.is_locked === undefined) throw new Error('Gated note lock load is missing lock state');
|
||||
if (this.isLocked(note)) {
|
||||
// A missing body here means the gated load did not request enough fields, so pass an empty string and let decryption fail explicitly.
|
||||
return {
|
||||
...note,
|
||||
body: await NoteLockService.instance().decryptString(note.body ?? ''),
|
||||
};
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
public static async prepareForSave(note: NoteEntity, linkedItemIds: (body: string)=> string[], isNew: boolean) {
|
||||
if (!note) throw new Error('Gated note lock save is missing note');
|
||||
// Gated saves for existing notes should be based on a loaded note, so missing lock state is a logic error.
|
||||
if (note.is_locked === undefined && !isNew) throw new Error('Gated note lock save is missing lock state');
|
||||
const isLocked = this.isLocked(note);
|
||||
if (!isLocked) note.extracted_resource_ids = '';
|
||||
|
||||
const plainTextBody = note.body ?? '';
|
||||
if (isLocked) {
|
||||
note.extracted_resource_ids = linkedItemIds(plainTextBody).filter(id => !!isItemId(id)).join(',');
|
||||
note.body = await NoteLockService.instance().encryptString(plainTextBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import { afterAllCleanUp, encryptionService, fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockKey from './NoteLockKey';
|
||||
import NoteLockService from './NoteLockService';
|
||||
|
||||
describe('NoteLockService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(1);
|
||||
NoteLockKey.destroyInstance();
|
||||
NoteLockService.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should encrypt and decrypt strings and files without loading an E2EE master key', async () => {
|
||||
const encryptionServiceInstance = EncryptionService.instance();
|
||||
const noteLockKey = NoteLockKey.instance();
|
||||
const service = NoteLockService.instance();
|
||||
await noteLockKey.create('123456');
|
||||
|
||||
const cipherText = await service.encryptString('some secret');
|
||||
expect(await service.decryptString(cipherText)).toBe('some secret');
|
||||
|
||||
const sourcePath = `${supportDir}/photo.jpg`;
|
||||
const encryptedPath = `${Setting.value('tempDir')}/note-lock-photo.crypted`;
|
||||
const decryptedPath = `${Setting.value('tempDir')}/note-lock-photo.jpg`;
|
||||
await service.encryptFile(sourcePath, encryptedPath);
|
||||
await service.decryptFile(encryptedPath, decryptedPath);
|
||||
|
||||
expect(fileContentEqual(sourcePath, encryptedPath)).toBe(false);
|
||||
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
|
||||
expect(encryptionServiceInstance.loadedMasterKeysCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear cached key data and only rotate on reset', async () => {
|
||||
const noteLockKey = NoteLockKey.instance();
|
||||
const firstKey = await noteLockKey.create('123456');
|
||||
await expect(noteLockKey.create('123456')).rejects.toThrow('Note lock key already exists');
|
||||
|
||||
noteLockKey.lock();
|
||||
expect(noteLockKey.isUnlocked()).toBe(false);
|
||||
await expect(noteLockKey.unlock('wrong password')).rejects.toThrow();
|
||||
|
||||
await noteLockKey.unlock('123456', Date.now() - 1);
|
||||
expect(noteLockKey.isUnlocked()).toBe(false);
|
||||
|
||||
const secondKey = await noteLockKey.reset('654321');
|
||||
expect(secondKey.id).not.toBe(firstKey.id);
|
||||
expect(noteLockKey.isUnlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should sync the encrypted note lock key without loading it into the E2EE registry', async () => {
|
||||
const createdKey = await NoteLockKey.instance().create('123456');
|
||||
NoteLockKey.destroyInstance();
|
||||
NoteLockService.destroyInstance();
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
await synchronizerStart();
|
||||
|
||||
const syncedKey = NoteLockKey.instance();
|
||||
expect(syncedKey.load()).toEqual(createdKey);
|
||||
expect(syncedKey.isUnlocked()).toBe(false);
|
||||
expect(EncryptionService.instance().loadedMasterKeysCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import EncryptionService, { EncryptOptions } from '../e2ee/EncryptionService';
|
||||
import NoteLockKey from './NoteLockKey';
|
||||
|
||||
export default class NoteLockService {
|
||||
|
||||
public static instance_: NoteLockService = null;
|
||||
|
||||
private constructor(
|
||||
private encryptionService_: EncryptionService,
|
||||
private noteLockKey_: NoteLockKey,
|
||||
) {}
|
||||
|
||||
public static instance() {
|
||||
if (!this.instance_) {
|
||||
const encryptionService = EncryptionService.instance();
|
||||
const noteLockKey = NoteLockKey.instance();
|
||||
this.instance_ = new NoteLockService(encryptionService, noteLockKey);
|
||||
}
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public static destroyInstance() {
|
||||
this.instance_ = null;
|
||||
}
|
||||
|
||||
public async encryptString(plainText: string) {
|
||||
return this.encryptionService_.encryptString(plainText, this.encryptionOptions_());
|
||||
}
|
||||
|
||||
public async decryptString(cipherText: string) {
|
||||
return this.encryptionService_.decryptString(cipherText, this.encryptionOptions_());
|
||||
}
|
||||
|
||||
public async encryptFile(srcPath: string, destPath: string) {
|
||||
return this.encryptionService_.encryptFile(srcPath, destPath, this.encryptionOptions_());
|
||||
}
|
||||
|
||||
public async decryptFile(srcPath: string, destPath: string) {
|
||||
return this.encryptionService_.decryptFile(srcPath, destPath, this.encryptionOptions_());
|
||||
}
|
||||
|
||||
private encryptionOptions_(): EncryptOptions {
|
||||
const key = this.noteLockKey_.decryptedKey();
|
||||
return {
|
||||
masterKeyId: key.id,
|
||||
decryptedMasterKey: key.plainText,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Setting, { Env } from '../../models/Setting';
|
||||
|
||||
const isNoteLockEnabled = () => {
|
||||
return Setting.value('env') === Env.Dev;
|
||||
};
|
||||
|
||||
export default isNoteLockEnabled;
|
||||
@@ -54,7 +54,8 @@ export default class JoplinData {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private serializeApiBody(body: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: body can be any serialisable value
|
||||
private serializeApiBody(body: any) {
|
||||
if (typeof body !== 'string') { return JSON.stringify(body); }
|
||||
return body;
|
||||
}
|
||||
@@ -75,19 +76,23 @@ export default class JoplinData {
|
||||
return path.join('/');
|
||||
}
|
||||
|
||||
public async get(path: Path, query: Record<string, unknown> = null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: query/body are arbitrary user-provided values
|
||||
public async get(path: Path, query: any = null) {
|
||||
return this.api_.route(RequestMethod.GET, this.pathToString(path), query);
|
||||
}
|
||||
|
||||
public async post(path: Path, query: Record<string, unknown> = null, body: unknown = null, files: RequestFile[] = null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: query/body are arbitrary user-provided values
|
||||
public async post(path: Path, query: any = null, body: any = null, files: RequestFile[] = null) {
|
||||
return this.api_.route(RequestMethod.POST, this.pathToString(path), query, this.serializeApiBody(body), files);
|
||||
}
|
||||
|
||||
public async put(path: Path, query: Record<string, unknown> = null, body: unknown = null, files: RequestFile[] = null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: query/body are arbitrary user-provided values
|
||||
public async put(path: Path, query: any = null, body: any = null, files: RequestFile[] = null) {
|
||||
return this.api_.route(RequestMethod.PUT, this.pathToString(path), query, this.serializeApiBody(body), files);
|
||||
}
|
||||
|
||||
public async delete(path: Path, query: Record<string, unknown> = null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: query/body are arbitrary user-provided values
|
||||
public async delete(path: Path, query: any = null) {
|
||||
return this.api_.route(RequestMethod.DELETE, this.pathToString(path), query);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ export default class JoplinPlugins {
|
||||
/**
|
||||
* @deprecated Use joplin.require()
|
||||
*/
|
||||
public require(_path: string): unknown {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: returns whatever native module the plugin requires; concretely replaced inside the sandbox
|
||||
public require(_path: string): any {
|
||||
// Just a stub. Implementation has to be done within plugin process, in plugin_index.js
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -149,9 +149,11 @@ export default class JoplinSettings {
|
||||
/**
|
||||
* Gets setting values (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
public async values(keys: string[] | string): Promise<Record<string, unknown>> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: setting values are heterogeneous per setting; plugin authors narrow at use site
|
||||
public async values(keys: string[] | string): Promise<Record<string, any>> {
|
||||
if (typeof keys === 'string') keys = [keys];
|
||||
const output: Record<string, unknown> = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See above
|
||||
const output: Record<string, any> = {};
|
||||
for (const key of keys) {
|
||||
output[key] = Setting.value(getPluginNamespacedSettingKey(this.plugin_.id, key));
|
||||
}
|
||||
@@ -165,14 +167,16 @@ export default class JoplinSettings {
|
||||
* it is recommended to use the `values()` function instead - it will be much faster than
|
||||
* calling `value()` multiple times.
|
||||
*/
|
||||
public async value(key: string): Promise<unknown> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: setting values are heterogeneous per setting; plugin authors narrow at use site
|
||||
public async value(key: string): Promise<any> {
|
||||
return Setting.value(getPluginNamespacedSettingKey(this.plugin_.id, key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a setting value (only applies to setting you registered from your plugin)
|
||||
*/
|
||||
public async setValue(key: string, value: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: see above
|
||||
public async setValue(key: string, value: any) {
|
||||
return Setting.setValue(getPluginNamespacedSettingKey(this.plugin_.id, key), value);
|
||||
}
|
||||
|
||||
@@ -183,7 +187,8 @@ export default class JoplinSettings {
|
||||
*
|
||||
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts
|
||||
*/
|
||||
public async globalValues(keys: string[]): Promise<unknown[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: setting values are heterogeneous; plugin authors narrow at use site
|
||||
public async globalValues(keys: string[]): Promise<any[]> {
|
||||
const output: (string|number|boolean|undefined)[] = [];
|
||||
for (const key of keys) {
|
||||
output.push(Setting.isSecureKey(key) ? undefined : Setting.value(key));
|
||||
@@ -194,7 +199,8 @@ export default class JoplinSettings {
|
||||
/**
|
||||
* @deprecated Use joplin.settings.globalValues()
|
||||
*/
|
||||
public async globalValue(key: string): Promise<unknown> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: setting values are heterogeneous; plugin authors narrow at use site
|
||||
public async globalValue(key: string): Promise<any> {
|
||||
if (Setting.isSecureKey(key)) return undefined;
|
||||
return Setting.value(key);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,8 @@ export default class JoplinViewsEditors {
|
||||
/**
|
||||
* See [[JoplinViewPanels]]
|
||||
*/
|
||||
public postMessage(handle: ViewHandle, message: unknown): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: messages exchanged with webview can be of any serialisable type
|
||||
public postMessage(handle: ViewHandle, message: any): void {
|
||||
return this.controller(handle).postMessage(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ export default class JoplinViewsPanels {
|
||||
*
|
||||
* It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api.
|
||||
*/
|
||||
public postMessage(handle: ViewHandle, message: unknown): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: messages exchanged with webview can be of any serialisable type
|
||||
public postMessage(handle: ViewHandle, message: any): void {
|
||||
return this.controller(handle).postMessage(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ export type RenderNoteView = Record<string, any>;
|
||||
|
||||
export interface OnChangeEvent {
|
||||
elementId: string;
|
||||
value: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: value depends on the input element type
|
||||
value: any;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,12 +186,14 @@ export interface ExportContext {
|
||||
/**
|
||||
* You can attach your own custom data using this property - it will then be passed to each event handler, allowing you to keep state from one event to the next.
|
||||
*/
|
||||
userData?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state
|
||||
userData?: any;
|
||||
}
|
||||
|
||||
export interface ImportContext {
|
||||
sourcePath: string;
|
||||
options: Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: import options are arbitrary per-importer
|
||||
options: any;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -200,7 +202,8 @@ export interface ImportContext {
|
||||
// =================================================================
|
||||
|
||||
export interface Script {
|
||||
onStart?(event: Record<string, unknown>): Promise<void>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: event payload shape depends on the host context
|
||||
onStart?(event: any): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Disposable {
|
||||
@@ -305,7 +308,8 @@ export interface MenuItem {
|
||||
* Arguments that should be passed to the command. They will be as rest
|
||||
* parameters.
|
||||
*/
|
||||
commandArgs?: unknown[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command args depend on the command
|
||||
commandArgs?: any[];
|
||||
|
||||
/**
|
||||
* Set to "separator" to create a divider line
|
||||
@@ -358,12 +362,14 @@ export type ViewHandle = string;
|
||||
|
||||
export interface EditorCommand {
|
||||
name: string;
|
||||
value?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: command value depends on the command
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface DialogResult {
|
||||
id: ButtonId;
|
||||
formData?: Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: form data shape depends on the dialog
|
||||
formData?: any;
|
||||
}
|
||||
|
||||
export enum ToastType {
|
||||
@@ -655,7 +661,8 @@ export interface ContentScriptContext {
|
||||
}
|
||||
|
||||
export interface ContentScriptModuleLoadedEvent {
|
||||
userData?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Plugin API: userData is arbitrary per-plugin state
|
||||
userData?: any;
|
||||
}
|
||||
|
||||
export interface ContentScriptModule {
|
||||
|
||||
@@ -11,6 +11,7 @@ import route_ping from './routes/ping';
|
||||
import route_auth from './routes/auth';
|
||||
import route_events from './routes/events';
|
||||
import route_revisions from './routes/revisions';
|
||||
import route_mcp from './routes/mcp';
|
||||
|
||||
import { ltrimSlashes } from '../../path-utils';
|
||||
const md5 = require('md5');
|
||||
@@ -122,6 +123,7 @@ export default class Api {
|
||||
auth: route_auth,
|
||||
events: route_events,
|
||||
revisions: route_revisions,
|
||||
mcp: route_mcp,
|
||||
};
|
||||
|
||||
this.dispatch = this.dispatch.bind(this);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Request, RequestMethod } from '../Api';
|
||||
import { ErrorBadRequest, ErrorForbidden, ErrorMethodNotAllowed } from '../utils/errors';
|
||||
import Setting from '../../../models/Setting';
|
||||
import McpServer from '../../mcp/McpServer';
|
||||
import { JsonRpcRequest, JsonRpcResponse } from '../../mcp/types';
|
||||
|
||||
// Single-endpoint JSON-RPC transport. v1 only handles client-initiated
|
||||
// requests so plain POST/response is enough; streamable HTTP can come later
|
||||
// if we ever need server-initiated messages.
|
||||
export default async function(request: Request) {
|
||||
if (request.method !== RequestMethod.POST) throw new ErrorMethodNotAllowed();
|
||||
|
||||
if (!(Setting.value('mcp.enabled') as boolean)) {
|
||||
throw new ErrorForbidden('MCP server is disabled');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON-RPC envelope is dynamically shaped
|
||||
let payload: any;
|
||||
try {
|
||||
payload = typeof request.body === 'string' ? JSON.parse(request.body) : request.body;
|
||||
} catch (error) {
|
||||
throw new ErrorBadRequest('Invalid JSON body');
|
||||
}
|
||||
|
||||
const server = McpServer.instance();
|
||||
|
||||
// JSON-RPC batch: spec allows an array of requests. If every item is a
|
||||
// notification, the server must return nothing (empty body), same as the
|
||||
// single-notification case.
|
||||
if (Array.isArray(payload)) {
|
||||
const responses: JsonRpcResponse[] = [];
|
||||
for (const item of payload) {
|
||||
const r = await server.handleRequest(item as JsonRpcRequest);
|
||||
if (r) responses.push(r);
|
||||
}
|
||||
if (!responses.length) return '';
|
||||
return responses;
|
||||
}
|
||||
|
||||
const response = await server.handleRequest(payload as JsonRpcRequest);
|
||||
// Notifications get no body per JSON-RPC spec.
|
||||
if (!response) return '';
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { synchronizerStart, setupDatabaseAndSynchronizer, switchClient, syncTargetId } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import ConflictNoteState from '../../models/ConflictNoteState';
|
||||
|
||||
// Creates a conflict on client 1 for the given note: client 2 edits and syncs first
|
||||
// (becoming the remote/winning version), then client 1 edits and syncs, which is when
|
||||
// the conflict is detected and resolved locally on client 1.
|
||||
const makeConflictOnClient1 = async (noteId: string, client2Body: string, client1Body: string) => {
|
||||
await switchClient(2);
|
||||
await Note.save({ id: noteId, body: client2Body });
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(1);
|
||||
await Note.save({ id: noteId, body: client1Body });
|
||||
await synchronizerStart();
|
||||
};
|
||||
|
||||
describe('Synchronizer.baseFields', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should record base_body and base_title after a clean upload', (async () => {
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'My title', body: 'My body', parent_id: folder.id });
|
||||
await synchronizerStart();
|
||||
|
||||
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_title', 'base_conflict_note_id'] });
|
||||
expect(syncItem.base_title).toBe('My title');
|
||||
expect(syncItem.base_body).toBe('My body');
|
||||
expect(syncItem.base_conflict_note_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should clear base_conflict_note_id when a note is cleanly uploaded again', (async () => {
|
||||
const note = await Note.save({ title: 'title', body: 'body' });
|
||||
await synchronizerStart();
|
||||
|
||||
// Simulate a previously recorded conflict link
|
||||
await Note.setBaseConflictNoteId(syncTargetId(), note.id, 'some-old-conflict-id');
|
||||
|
||||
await Note.save({ id: note.id, body: 'body changed' });
|
||||
await synchronizerStart();
|
||||
|
||||
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_body', 'base_conflict_note_id'] });
|
||||
expect(syncItem.base_body).toBe('body changed');
|
||||
expect(syncItem.base_conflict_note_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should link the original note to its conflict note when a conflict is resolved', (async () => {
|
||||
const note = await Note.save({ title: 'title', body: 'base body' });
|
||||
await synchronizerStart();
|
||||
await switchClient(2);
|
||||
await synchronizerStart();
|
||||
await switchClient(1);
|
||||
|
||||
await makeConflictOnClient1(note.id, 'remote body', 'local body');
|
||||
|
||||
const conflictedNotes = await Note.conflictedNotes();
|
||||
expect(conflictedNotes.length).toBe(1);
|
||||
const conflictNote = conflictedNotes[0];
|
||||
|
||||
// The link must survive the remote-overwrite step, which rebuilds the sync_items row.
|
||||
const syncItem = await BaseItem.syncItem(syncTargetId(), note.id, { fields: ['base_conflict_note_id'] });
|
||||
expect(syncItem.base_conflict_note_id).toBe(conflictNote.id);
|
||||
}));
|
||||
|
||||
it('should record base and remote content in conflict_note_states', (async () => {
|
||||
const note = await Note.save({ title: 'base title', body: 'base body' });
|
||||
await synchronizerStart();
|
||||
await switchClient(2);
|
||||
await synchronizerStart();
|
||||
await switchClient(1);
|
||||
|
||||
await makeConflictOnClient1(note.id, 'remote body', 'local body');
|
||||
|
||||
const conflictNote = (await Note.conflictedNotes())[0];
|
||||
const state = await ConflictNoteState.byNoteId(conflictNote.id);
|
||||
|
||||
expect(state.base_body).toBe('base body');
|
||||
expect(state.base_title).toBe('base title');
|
||||
expect(state.remote_body).toBe('remote body');
|
||||
expect(state.remote_updated_time).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
it('should not overwrite the base snapshot during a conflict', (async () => {
|
||||
const note = await Note.save({ title: 'title', body: 'original base' });
|
||||
await synchronizerStart();
|
||||
await switchClient(2);
|
||||
await synchronizerStart();
|
||||
await switchClient(1);
|
||||
|
||||
await makeConflictOnClient1(note.id, 'remote body', 'local body');
|
||||
|
||||
// conflict_note_states keeps the common ancestor, not either side's edit
|
||||
const conflictNote = (await Note.conflictedNotes())[0];
|
||||
const state = await ConflictNoteState.byNoteId(conflictNote.id);
|
||||
expect(state.base_body).toBe('original base');
|
||||
expect(state.base_body).not.toBe('remote body');
|
||||
expect(state.base_body).not.toBe('local body');
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import shim from '../../shim';
|
||||
import Setting from '../../models/Setting';
|
||||
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, msleep } from '../../testing/test-utils';
|
||||
import { synchronizerStart, tempFilePath, resourceFetcher, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, checkThrowAsync, msleep, withWarningSilenced } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import Resource from '../../models/Resource';
|
||||
@@ -416,4 +416,39 @@ describe('Synchronizer.resources', () => {
|
||||
expect(await synchronizer().api().get(`${resource.id}.md`)).toContain('my new title 2');
|
||||
});
|
||||
|
||||
// A crafted resource metadata file with `..` in its id must be rejected at sync ingestion. Sync
|
||||
// must continue past it (not abort the whole batch) so one malformed item can't permanently
|
||||
// break sync.
|
||||
it('should skip resources with malformed IDs during sync without aborting', async () => {
|
||||
const folder = await Folder.save({ title: 'folder1' });
|
||||
const note = await Note.save({ title: 'good note', parent_id: folder.id });
|
||||
await synchronizerStart();
|
||||
|
||||
const malicious = [
|
||||
'poc-resource',
|
||||
'',
|
||||
'id: ../../joplin-poc-escaped',
|
||||
'mime: application/octet-stream',
|
||||
'filename: poc',
|
||||
'file_extension: txt',
|
||||
'size: 0',
|
||||
'created_time: 2026-01-01T00:00:00.000Z',
|
||||
'updated_time: 2026-01-01T00:00:00.000Z',
|
||||
'user_created_time: 2026-01-01T00:00:00.000Z',
|
||||
'user_updated_time: 2026-01-01T00:00:00.000Z',
|
||||
'type_: 4',
|
||||
].join('\n');
|
||||
await fileApi().put('11111111111111111111111111111111.md', malicious);
|
||||
|
||||
await switchClient(2);
|
||||
await withWarningSilenced(/Skipping item from sync target|There was some errors|Invalid item ID format/, async () => {
|
||||
await synchronizerStart();
|
||||
});
|
||||
|
||||
const localNote = await Note.load(note.id);
|
||||
expect(localNote).toBeTruthy();
|
||||
expect(localNote.title).toBe('good note');
|
||||
expect(await Resource.load('11111111111111111111111111111111')).toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -329,4 +329,25 @@ describe('Synchronizer.revisions', () => {
|
||||
await synchronizerStart();
|
||||
expect(Setting.value('revisionService.enabled')).toBe(true);
|
||||
}));
|
||||
|
||||
it('should not overwrite a customised local ttlDays with another client default', (async () => {
|
||||
// Regression: a client that has never customised revisionService.ttlDays
|
||||
// must not push its default over another client's customised value.
|
||||
const changeSetting = (key: string, value: unknown) => {
|
||||
Setting.setValue(key, value);
|
||||
onRevisionServiceSettingsChanged(key, value);
|
||||
};
|
||||
|
||||
changeSetting('revisionService.ttlDays', 30);
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(90);
|
||||
await synchronizerStart();
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(30);
|
||||
|
||||
await switchClient(1);
|
||||
await synchronizerStart();
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(30);
|
||||
}));
|
||||
});
|
||||
@@ -121,6 +121,25 @@ describe('syncInfoUtils', () => {
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('0.0.0');
|
||||
});
|
||||
|
||||
it('should merge sync target info and keep the latest note lock key', () => {
|
||||
const syncInfo1 = new SyncInfo();
|
||||
syncInfo1.noteLockKey = {
|
||||
id: '1',
|
||||
content: 'content1',
|
||||
updated_time: 100,
|
||||
};
|
||||
|
||||
const syncInfo2 = new SyncInfo();
|
||||
syncInfo2.noteLockKey = {
|
||||
id: '2',
|
||||
content: 'content2',
|
||||
updated_time: 200,
|
||||
};
|
||||
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).noteLockKey).toEqual(syncInfo2.noteLockKey);
|
||||
expect(mergeSyncInfos(new SyncInfo(), syncInfo1).noteLockKey).toEqual(syncInfo1.noteLockKey);
|
||||
});
|
||||
|
||||
it('should merge sync target info and takes into account usage of master key - 1', async () => {
|
||||
const syncInfo1 = new SyncInfo();
|
||||
syncInfo1.masterKeys = [{
|
||||
@@ -229,6 +248,15 @@ describe('syncInfoUtils', () => {
|
||||
'hasBeenUsed': true,
|
||||
},
|
||||
],
|
||||
'noteLockKey': {
|
||||
'id': '400227d8a77c4d3bb7346514861c643c',
|
||||
'created_time': 1515008161362,
|
||||
'updated_time': 1708103706234,
|
||||
'source_application': 'net.cozic.joplin-desktop',
|
||||
'encryption_method': 4,
|
||||
'checksum': '',
|
||||
'content': '{"iv":"M1uezlW1Pu1g3dwrCTqcHg=="}',
|
||||
},
|
||||
'ppk': {
|
||||
'value': {
|
||||
'id': 'SNQ5ZCs61KDVUW2qqqqHd3',
|
||||
@@ -270,6 +298,13 @@ describe('syncInfoUtils', () => {
|
||||
'updated_time': 1708103706234,
|
||||
},
|
||||
],
|
||||
'noteLockKey': {
|
||||
'created_time': 1515008161362,
|
||||
'encryption_method': 4,
|
||||
'id': '400227d8a77c4d3bb7346514861c643c',
|
||||
'source_application': 'net.cozic.joplin-desktop',
|
||||
'updated_time': 1708103706234,
|
||||
},
|
||||
'ppk': {
|
||||
'updatedTime': 1633274368892,
|
||||
'value': {
|
||||
@@ -334,6 +369,7 @@ describe('syncInfoUtils', () => {
|
||||
expect(result.e2ee).toEqual(false);
|
||||
expect(result.appMinVersion).toEqual('3.7.0');
|
||||
expect(result.masterKeys).toEqual([]);
|
||||
expect(result.noteLockKey).toEqual(null);
|
||||
|
||||
Logger.globalLogger.enabled = true;
|
||||
});
|
||||
|
||||
@@ -96,10 +96,16 @@ export async function migrateLocalSyncInfo(db: JoplinDatabase) {
|
||||
// most likely not what the user wants.
|
||||
syncInfo.setKeyTimestamp('e2ee', 0);
|
||||
syncInfo.setKeyTimestamp('activeMasterKeyId', 0);
|
||||
syncInfo.revisionServiceEnabled = Setting.value('revisionService.enabled');
|
||||
syncInfo.revisionServiceTtlDays = Setting.value('revisionService.ttlDays');
|
||||
syncInfo.setKeyTimestamp('revisionServiceEnabled', 0);
|
||||
syncInfo.setKeyTimestamp('revisionServiceTtlDays', 0);
|
||||
|
||||
// For revisionService.*: stamp the timestamp only when the local value
|
||||
// has been customised. A default value uploaded with a real timestamp
|
||||
// would overwrite another client's customised value on first sync.
|
||||
const localRevisionEnabled = Setting.value('revisionService.enabled');
|
||||
const localRevisionTtlDays = Setting.value('revisionService.ttlDays');
|
||||
syncInfo.revisionServiceEnabled = localRevisionEnabled;
|
||||
syncInfo.revisionServiceTtlDays = localRevisionTtlDays;
|
||||
syncInfo.setKeyTimestamp('revisionServiceEnabled', localRevisionEnabled === Setting.settingMetadata('revisionService.enabled').value ? 0 : Date.now());
|
||||
syncInfo.setKeyTimestamp('revisionServiceTtlDays', localRevisionTtlDays === Setting.settingMetadata('revisionService.ttlDays').value ? 0 : Date.now());
|
||||
|
||||
await saveLocalSyncInfo(syncInfo);
|
||||
}
|
||||
@@ -261,6 +267,16 @@ export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
|
||||
}
|
||||
}
|
||||
|
||||
const noteLockKey1 = s1.noteLockKey;
|
||||
const noteLockKey2 = s2.noteLockKey;
|
||||
if (!noteLockKey1) {
|
||||
output.noteLockKey = noteLockKey2;
|
||||
} else if (!noteLockKey2) {
|
||||
output.noteLockKey = noteLockKey1;
|
||||
} else {
|
||||
output.noteLockKey = (noteLockKey1.updated_time || 0) >= (noteLockKey2.updated_time || 0) ? noteLockKey1 : noteLockKey2;
|
||||
}
|
||||
|
||||
// We use >= so that the version from s1 (local) is preferred to the version in s2 (remote).
|
||||
// For example, if s2 has appMinVersion 0.00 and s1 has appMinVersion 0.0.0, we choose the
|
||||
// local version, 0.0.0.
|
||||
@@ -279,6 +295,7 @@ export class SyncInfo {
|
||||
private e2ee_: SyncInfoValueBoolean;
|
||||
private activeMasterKeyId_: SyncInfoValueString;
|
||||
private masterKeys_: MasterKeyEntity[] = [];
|
||||
private noteLockKey_: MasterKeyEntity = null;
|
||||
private ppk_: SyncInfoValuePublicPrivateKeyPair;
|
||||
private appMinVersion_: string = appMinVersion_;
|
||||
private revisionServiceEnabled_: SyncInfoValueBoolean;
|
||||
@@ -300,6 +317,7 @@ export class SyncInfo {
|
||||
e2ee: this.e2ee_,
|
||||
activeMasterKeyId: this.activeMasterKeyId_,
|
||||
masterKeys: this.masterKeys,
|
||||
noteLockKey: this.noteLockKey,
|
||||
ppk: this.ppk_,
|
||||
appMinVersion: this.appMinVersion,
|
||||
revisionServiceEnabled: this.revisionServiceEnabled_,
|
||||
@@ -319,6 +337,11 @@ export class SyncInfo {
|
||||
});
|
||||
}
|
||||
|
||||
if (filtered.noteLockKey) {
|
||||
delete filtered.noteLockKey.content;
|
||||
delete filtered.noteLockKey.checksum;
|
||||
}
|
||||
|
||||
// Truncate the private key and public key
|
||||
if (filtered.ppk.value) {
|
||||
filtered.ppk.value.privateKey.ciphertext = `${filtered.ppk.value.privateKey.ciphertext.substr(0, 20)}...${filtered.ppk.value.privateKey.ciphertext.substr(-20)}`;
|
||||
@@ -344,6 +367,7 @@ export class SyncInfo {
|
||||
this.e2ee_ = 'e2ee' in s ? s.e2ee : { value: false, updatedTime: 0 };
|
||||
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
||||
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
||||
this.noteLockKey_ = 'noteLockKey' in s ? s.noteLockKey : null;
|
||||
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
|
||||
this.appMinVersion_ = s.appMinVersion ? s.appMinVersion : '0.0.0';
|
||||
this.revisionServiceEnabled_ = 'revisionServiceEnabled' in s ? s.revisionServiceEnabled : { value: true, updatedTime: 0 };
|
||||
@@ -443,6 +467,16 @@ export class SyncInfo {
|
||||
this.masterKeys_ = v;
|
||||
}
|
||||
|
||||
public get noteLockKey(): MasterKeyEntity {
|
||||
return this.noteLockKey_;
|
||||
}
|
||||
|
||||
public set noteLockKey(v: MasterKeyEntity) {
|
||||
if (JSON.stringify(v) === JSON.stringify(this.noteLockKey_)) return;
|
||||
|
||||
this.noteLockKey_ = v;
|
||||
}
|
||||
|
||||
public keyTimestamp(name: string): number {
|
||||
const self = this as unknown as Record<string, { updatedTime: number }>;
|
||||
if (!(`${name}_` in self)) throw new Error(`Invalid name: ${name}`);
|
||||
|
||||
@@ -4,14 +4,19 @@ import BaseItem from '../../../models/BaseItem';
|
||||
import ItemChange from '../../../models/ItemChange';
|
||||
import Note from '../../../models/Note';
|
||||
import Resource from '../../../models/Resource';
|
||||
import { BaseItemEntity } from '../../database/types';
|
||||
import { BaseItemEntity, NoteEntity } from '../../database/types';
|
||||
import { SyncAction, conflictActions } from './types';
|
||||
import ConflictNoteState from '../../../models/ConflictNoteState';
|
||||
|
||||
const logger = Logger.create('handleConflictAction');
|
||||
|
||||
export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExists: boolean, remoteContent: BaseItemEntity, local: BaseItemEntity, syncTargetId: number, itemIsReadOnly: boolean, dispatch: Dispatch) => {
|
||||
if (!conflictActions.includes(action)) return;
|
||||
|
||||
// Linked to the original note only after the remote-overwrite step below, which
|
||||
// rebuilds the sync_items row and would otherwise wipe the link.
|
||||
let createdConflictNoteId = '';
|
||||
|
||||
logger.debug(`Handling conflict: ${action}`);
|
||||
logger.debug('local:', local, 'remoteContent', remoteContent);
|
||||
logger.debug('remoteExists:', remoteExists);
|
||||
@@ -57,7 +62,21 @@ export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExis
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
if (mustHandleConflict) {
|
||||
await Note.createConflictNote(local, ItemChange.SOURCE_SYNC);
|
||||
const conflictNote = await Note.createConflictNote(local, ItemChange.SOURCE_SYNC);
|
||||
createdConflictNoteId = conflictNote.id;
|
||||
|
||||
// Record base (read now, before the rebuild below) and remote. The local
|
||||
// version is already preserved as the conflict note itself.
|
||||
const base = await Note.syncBaseContent(syncTargetId, local.id);
|
||||
const remoteNote = remoteContent as NoteEntity;
|
||||
await ConflictNoteState.save({
|
||||
note_id: conflictNote.id,
|
||||
base_body: base ? base.base_body : '',
|
||||
base_title: base ? base.base_title : '',
|
||||
remote_body: remoteNote ? remoteNote.body : '',
|
||||
remote_title: remoteNote ? remoteNote.title : '',
|
||||
remote_updated_time: remoteNote ? remoteNote.updated_time : 0,
|
||||
});
|
||||
}
|
||||
} else if (action === SyncAction.ResourceConflict) {
|
||||
if (!remoteContent || Resource.mustHandleConflict(local, remoteContent)) {
|
||||
@@ -87,6 +106,11 @@ export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExis
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, BaseItem.remoteItemSyncTime(remoteContent.updated_time), remoteContent.updated_time);
|
||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||
|
||||
// Link after the save above, which rebuilds the sync_items row.
|
||||
if (createdConflictNoteId) {
|
||||
await Note.setBaseConflictNoteId(syncTargetId, local.id, createdConflictNoteId);
|
||||
}
|
||||
|
||||
if (local.encryption_applied) dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
|
||||
} else {
|
||||
// Remote no longer exists (note deleted) so delete local one too
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user