Compare commits

..
12 Commits
Author SHA1 Message Date
renovate[bot] d428762a21 fix(deps): update dependency @fortawesome/react-fontawesome to v3.4.0 (#17031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 08:42:56 +01:00
Rehman Ahmad ffbf55ec21 All: Translation: Add ur.po (Urdu) (#16978) 2026-11-05 22:32:25 -05:00
chiajlingvoj b7dd23ca8f All: Translation: Add cy_GB.po (Welsh) (#17033) 2026-11-05 20:07:50 -05:00
chiajlingvoj 2604fa339d All: Translation: Update eo.po (#17032) 2026-11-05 20:07:26 -05:00
renovate[bot] 85dc37a521 chore(deps): update dependency node-mocks-http to v1.18.1 (#17028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 23:01:16 +00:00
renovate[bot] 479921f6e7 chore(deps): update dependency node-mocks-http to v1.18.0 (#17025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 13:32:57 +01:00
Laurent Cozic 504453d0d3 Merge branch 'release-3.7' into dev 2026-11-05 11:35:34 +01:00
Laurent Cozic 9bccaee2a2 Update test expectations for always-quoted attribute values
(cherry picked from commit 5a6949eabd)
2026-11-05 11:19:15 +01:00
Laurent Cozic 97ed78d486 Renderer: Security: Always serialise attribute values and drop invalid attribute names when sanitising HTML
(cherry picked from commit ee9ee10803)
2026-11-05 11:19:15 +01:00
Joplin Bot 93a561d9f2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-11-05 10:10:06 +00:00
Laurent Cozic e5ed69ad01 Desktop, Cli: Fix ENEX import failing when an unescaped ampersand is directly followed by a tag
(cherry picked from commit be60c3a2fd)
2026-11-05 09:58:10 +01:00
Aissa Benfodda d730509c28 Desktop: Resolves #16450: Add a first-class OpenAI provider using the Responses API (#16864) 2026-11-03 12:37:40 +01:00
20 changed files with 16626 additions and 109 deletions

No files matched your search

+3
View File
@@ -1664,8 +1664,10 @@ packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/providers/openAiResponses.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/testing/mockOpenAiServer.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
@@ -1694,6 +1696,7 @@ packages/lib/services/ai/types.js
packages/lib/services/ai/utils/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/ai/utils/parseToolArguments.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
+3
View File
@@ -1690,8 +1690,10 @@ packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/providers/openAiResponses.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/testing/mockOpenAiServer.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
@@ -1720,6 +1722,7 @@ packages/lib/services/ai/types.js
packages/lib/services/ai/utils/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/ai/utils/parseToolArguments.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
+19 -23
View File
@@ -1,4 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><item><title><![CDATA[A full security review of Joplin Server and Joplin Cloud]]></title><description><![CDATA[<p>October 2026 has been nicknamed &quot;Black October&quot;: within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.</p>
<h2>The review<a name="the-review" href="#the-review" class="heading-anchor">🔗</a></h2>
<p>Anthropic gave Joplin access to the Defense Access tier of its Cyber Verification Program, which lets open source maintainers use Claude's full security capabilities on software they maintain. We used it for a complete review of Joplin Server, which also powers Joplin Cloud: every route of the server, and every flow end to end, from signing in to sharing, publishing and email-to-note. Each finding was verified independently, and each fix was reviewed again before release.</p>
<p>No critical issues were found, but there were a number of high and medium severity ones, and many smaller ones. We have fixed more than 50 of them. Joplin Cloud is already updated, and the fixes are now in a new Joplin Server release. If you use Joplin Cloud, there's nothing you need to do.</p>
<h2>What was addressed<a name="what-was-addressed" href="#what-was-addressed" class="heading-anchor">🔗</a></h2>
<p>Most fixes concern <strong>account security</strong>: signing in, password resets, email changes and two-factor authentication were all tightened. Changing your password now shuts out anyone who had access to your account, and the server no longer reveals which email addresses have an account. Password guessing is now limited per account, without locking out the real owner.</p>
<p>Others concern <strong>sharing and publishing</strong>. The server trusted shared content a little too much, so someone you shared a notebook with could in some cases affect notes outside the share. It now checks that shared content really belongs where it claims, and published pages show only what was meant to be published.</p>
<p>The rest make the server more <strong>robust</strong>, so that oversized requests or specially crafted content can't slow it down for everyone, and improve <strong>LDAP and SAML</strong> sign-in for self-hosted servers.</p>
<h2>If you self-host<a name="if-you-self-host" href="#if-you-self-host" class="heading-anchor">🔗</a></h2>
<p>Please update to <strong>version 3.7.4</strong> or greater. Note these changes:</p>
<ul>
<li>With <strong>LDAP</strong>, an account matching a directory entry is managed by the directory after its first LDAP sign-in, and its local password stops working.</li>
<li>Sign-up is disabled when <strong>SAML or LDAP</strong> is enabled, and the SAML Audience must match your server's entity ID.</li>
<li>Email confirmation is only required for sharing when your server can send emails.</li>
<li>Your reverse proxy must set the <code>X-Real-IP</code> header for rate limits to work.</li>
</ul>
<p>Details are in the changelog and the security advisory. Thanks to Anthropic for making this review possible, and to everyone who reports security issues responsibly.</p>
]]></description><link>https://joplinapp.org/news/20261102-joplin-server-security-review</link><guid isPermaLink="false">20261102-joplin-server-security-review</guid><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
<p><strong>A European AI provider, processing in Europe.</strong> Joplin Cloud AI uses models from Mistral AI, a French company. We send requests to Mistral's EU regional endpoint, which processes data only in data centres in the European Union and EFTA countries. Your notes are not sent to the United States for processing.</p>
<p><strong>Zero Data Retention.</strong> Mistral has enabled Zero Data Retention on our account. Once a response has been generated, Mistral doesn't store or log the content of the request or the response. Only basic billing information, such as how many tokens were used, is kept.</p>
<p><strong>No training on your notes.</strong> Your content isn't used to train or improve AI models. Mistral's commercial terms rule this out for our account, and training is switched off in our account settings.</p>
@@ -543,25 +560,4 @@ sys 0m38.013s</p>
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android/">https://joplinapp.org/help/about/changelog/android/</a></li>
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios/">https://joplinapp.org/help/about/changelog/ios/</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.13</twitter-text></item><item><title><![CDATA[Working in the shadows with white-hat hackers]]></title><description><![CDATA[<p>The majority of Joplin's development is carried out in the public domain. This includes the discussion of issues on GitHub, as well as the submission of pull requests and related discussions. The transparency of these processes allows for collaborative problem-solving and shared insights.</p>
<p>However, there is one aspect that operates behind closed doors, and for good reason: addressing cybersecurity vulnerabilities. It is imperative that these issues remain undisclosed until they have been resolved. Once a solution is implemented, it is usually accompanied by discreet commits and a message in the changelog to signify the progress made.</p>
<p>Typically, the process begins with an email from a security researcher. They provide valuable insights, such as a specially crafted note that triggers a bug, or an API call, along with an explanation of how the application's security can be circumvented. We examine the vulnerability, create a fix, and create automated test units to prevent any accidental reintroduction of the vulnerability in future code updates. An example of such a commit is: <a href="https://github.com/laurent22/joplin/commit/9e90d9016daf79b5414646a93fd369aedb035071">9e90d9016daf79b5414646a93fd369aedb035071</a></p>
<p>We then share our fix with the researcher for validation. Additionally, we often apply the fix to previous versions of Joplin, depending on the severity of the vulnerability.</p>
<p>The contribution of security researchers in this regard is immeasurable. They employ their ingenuity to identify inventive methods of bypassing existing security measures and often discover subtle flaws in the code that might otherwise go unnoticed.</p>
<p>We would like to express our sincere gratitude to the security researchers who have assisted us throughout the years in identifying and rectifying security vulnerabilities!</p>
<ul>
<li><a href="https://github.com/a1ise">@Alise</a></li>
<li>@hexodotsh</li>
<li><a href="https://github.com/ly1g3">@ly1g3</a></li>
<li><a href="https://twitter.com/maple3142">@maple3142</a></li>
<li>Ademar Nowasky Junior</li>
<li><a href="mailto:ben@mayhem.sg">Benjamin Harris</a></li>
<li><a href="https://github.com/JavierOlmedo">Javier Olmedo</a></li>
<li><a href="https://twitter.com/newfolderj">Jubair Rehman Yousafzai</a></li>
<li>lin@UCCU Hacker</li>
<li><a href="https://github.com/personalizedrefrigerator">personalizedrefrigerator</a></li>
<li><a href="https://twitter.com/fhlipZero">Phil Holbrook</a></li>
<li><a href="https://ryotak.net/">RyotaK</a></li>
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.13</twitter-text></item></channel></rss>
@@ -6,6 +6,7 @@ import Note from '../../models/Note';
import Tag from '../../models/Tag';
import { NoteEntity } from '../database/types';
import { NoteContext, ToolDefinition } from './tools/types';
import mockOpenAiServer, { jsonResponse } from './testing/mockOpenAiServer';
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
const getContext = async () => {
@@ -117,4 +118,30 @@ describe('noteChat.tools', () => {
expect(infoMessage).toBeTruthy();
expect(infoMessage.content).toContain('Tool `manage_tags` is disabled in Joplin\'s settings');
});
test('should keep the model reasoning between the requests of a tool loop', async () => {
const baseUrl = 'http://localhost:1234/v1';
Setting.setValue('ai.chat.providerType', 'openai-compatible');
Setting.setValue('ai.chat.baseUrl', baseUrl);
Setting.setValue('ai.chat.model', 'test-model');
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'editor_readNoteBody', arguments: '{}' };
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => {
const output = requests.length === 1 ? [reasoningItem, functionCallItem] : [{ type: 'message', content: [{ type: 'output_text', text: 'Done' }] }];
return jsonResponse({ status: 'completed', output });
},
});
try {
const note = await Note.save({ title: 'test' });
await runChatForNote(note, [{ role: ChatRole.User, content: 'testing' }], 'Read the note');
} finally {
jest.restoreAllMocks();
}
expect(requests).toHaveLength(2);
expect(requests[1].body.input).toEqual(expect.arrayContaining([reasoningItem, functionCallItem]));
});
});
+1
View File
@@ -203,6 +203,7 @@ const stepNoteChat = async ({
role: ChatRole.Assistant,
content: chatResult.text ?? '',
toolCalls: allowTools ? chatResult.toolCalls : [],
responsesState: allowTools ? chatResult.responsesState : undefined,
});
onHistoryChanged([...messages]);
@@ -85,6 +85,22 @@ describe('ai/providers/JoplinCloud', () => {
expect(error.message).toMatch(/reached your AI usage budget/i);
});
it('should only use the Chat Completions API', async () => {
const urls: string[] = [];
const mock = mockFetch((request) => {
urls.push(request.url);
return new Response(request.url.endsWith('ai/chat/completions') ? '{ "choices": [] }' : '{ }', { status: 200 });
});
try {
await new JoplinCloudProvider().chat([]);
} finally {
mock.reset();
}
expect(urls.filter(url => url.endsWith('ai/chat/completions'))).toHaveLength(1);
expect(urls.filter(url => url.includes('responses'))).toHaveLength(0);
});
it('should retry rate-limited requests', async () => {
const { mock, requestCount } = mockRateLimitedChatApi();
try {
@@ -87,6 +87,10 @@ export default class JoplinCloudProvider extends OpenAiCompatibleProvider {
});
}
protected override canTryResponsesApi() {
return false;
}
private async api() {
// Fetch the API from the registry, rather than caching the API locally.
// This avoids authentication drift if the user changes the sync target credentials.
@@ -1,21 +1,51 @@
import { mockFetch, setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
import OpenAiCompatibleProvider from './OpenAiCompatible';
import { ChatRole } from '../types';
import { ChatMessage, ChatResult, ChatRole } from '../types';
import { ToolImageResponse } from '../tools/types';
import mockOpenAiServer, { jsonResponse } from '../testing/mockOpenAiServer';
const baseUrl = 'http://localhost:1234/v1';
const mockChatResponse = (message: Record<string, unknown>, finishReason: string|undefined) => {
return mockFetch(() => {
return new Response(JSON.stringify({
return mockOpenAiServer(baseUrl, {
'/chat/completions': () => jsonResponse({
choices: [{ message, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 20 },
}), { status: 200 });
}),
});
};
const newProvider = () => {
const responsesBody = (output: unknown[], extra: Record<string, unknown> = {}) => {
return {
status: 'completed',
output,
usage: { input_tokens: 10, output_tokens: 20 },
...extra,
};
};
const mockResponsesOutput = (output: unknown[], extra: Record<string, unknown> = {}) => {
return mockOpenAiServer(baseUrl, {
'/responses': () => jsonResponse(responsesBody(output, extra)),
});
};
const messageItem = (text: string) => ({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] });
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
const reasoningContentItem = { type: 'reasoning', id: 'rs_1', summary: [] as unknown[], content: [{ type: 'reasoning_text', text: 'Thinking...' }] };
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' };
const weatherTool = {
id: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
};
const newProvider = (model = 'test-model', apiKey = 'test-key') => {
return new OpenAiCompatibleProvider({
baseUrl: 'http://localhost:1234/v1',
apiKey: 'test-key',
model: 'test-model',
baseUrl,
apiKey,
model,
classification: 'local',
});
};
@@ -24,6 +54,22 @@ const chat = async () => {
return newProvider().chat([{ role: ChatRole.User, content: 'Hello' }]);
};
const toolLoopMessages = (firstResult: ChatResult, toolOutput: ChatMessage['content']): ChatMessage[] => {
return [
{ role: ChatRole.User, content: 'Weather in Paris?' },
{ role: ChatRole.Assistant, content: firstResult.text, toolCalls: firstResult.toolCalls, responsesState: firstResult.responsesState },
{
role: ChatRole.Tool,
content: toolOutput,
toolName: 'get_weather',
toolCallId: 'call_1',
isError: false,
userDescription: '',
isEdit: false,
},
];
};
describe('ai/providers/OpenAiCompatible', () => {
beforeEach(async () => {
@@ -31,6 +77,10 @@ describe('ai/providers/OpenAiCompatible', () => {
await switchClient(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
test.each([
['length', 'length'],
['max_tokens', 'length'],
@@ -61,4 +111,188 @@ describe('ai/providers/OpenAiCompatible', () => {
expect(result.reasoningText).toBeUndefined();
});
it('should use the Responses API when the server supports it', async () => {
const requests = mockResponsesOutput([reasoningItem, messageItem('Hi!')]);
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
expect(result).toMatchObject({
text: 'Hi!',
toolCalls: [],
usage: { inputTokens: 10, outputTokens: 20 },
finishReason: 'stop',
reasoningText: 'Thinking...',
responsesState: undefined,
});
});
test.each([
['content', [reasoningContentItem, messageItem('Hi!')], 'Hi!'],
['content without a message', [reasoningContentItem], ''],
])('should read the reasoning text from %s', async (_label, output, expectedText) => {
mockResponsesOutput(output);
const result = await chat();
expect(result.text).toBe(expectedText);
expect(result.reasoningText).toBe('Thinking...');
});
test.each([
['returns 404', () => new Response('404 page not found', { status: 404 })],
// Older LM Studio versions answer unknown endpoints like this
['returns 200 without output', () => jsonResponse({ error: 'Unexpected endpoint or method. (POST /v1/responses)' })],
])('should fall back to Chat Completions once when the Responses API %s', async (_label, responsesHandler) => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': responsesHandler,
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' }, finish_reason: 'stop' }] }),
});
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/chat/completions']);
expect(result.text).toBe('OK');
});
it('should try the Responses API again when the fallback also fails', async () => {
let modelInstalled = false;
const modelNotFound = () => jsonResponse({ error: { message: 'model \'test-model\' not found' } }, 404);
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => modelInstalled ? jsonResponse(responsesBody([messageItem('Hi!')])) : modelNotFound(),
'/chat/completions': modelNotFound,
});
const provider = newProvider();
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404: model \'test-model\' not found');
modelInstalled = true;
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/responses']);
expect(result.text).toBe('Hi!');
});
test.each([400, 401])('should not fall back to Chat Completions on error %s', async (status) => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => jsonResponse({ error: { message: 'Something failed' } }, status),
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
});
await expect(chat()).rejects.toThrow(`AI provider returned ${status}: Something failed`);
expect(requests.map(request => request.path)).toEqual(['/responses']);
});
it('should not fall back to Chat Completions after the Responses API has worked', async () => {
let responsesCallCount = 0;
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => {
responsesCallCount++;
return responsesCallCount === 1 ? jsonResponse(responsesBody([messageItem('Hi!')])) : new Response('', { status: 404 });
},
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
});
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404');
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
});
it('should map the request options to the Responses API', async () => {
const requests = mockResponsesOutput([messageItem('{"answer":"Paris"}')]);
await newProvider().chat([
{ role: ChatRole.System, content: 'Be brief' },
{ role: ChatRole.User, content: 'Capital of France?' },
], {
maxTokens: 100,
tools: [weatherTool],
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
});
expect(requests[0].body).toEqual({
model: 'test-model',
input: [
{ role: 'system', content: 'Be brief' },
{ role: 'user', content: 'Capital of France?' },
],
stream: false,
store: false,
include: ['reasoning.encrypted_content'],
max_output_tokens: 100,
text: { format: { type: 'json_schema', name: 'answer', strict: true, schema: { type: 'object' } } },
tools: [{ type: 'function', name: 'get_weather', description: 'Gets the weather', parameters: weatherTool.inputSchema }],
});
});
it('should retry without the schema when the model rejects structured output', async () => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': body => body.text
? jsonResponse({ error: { message: 'Invalid parameter: \'text.format\' of type \'json_schema\' is not supported with this model.' } }, 400)
: jsonResponse(responsesBody([messageItem('Hi!')])),
});
const result = await newProvider().chat([{ role: ChatRole.User, content: 'Hello' }], {
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
});
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
expect(requests[1].body.text).toBeUndefined();
expect(result.text).toBe('Hi!');
});
test.each([
[{ status: 'completed' }, 'stop'],
[{ status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } }, 'length'],
[{ status: 'incomplete', incomplete_details: { reason: 'content_filter' } }, 'other'],
])('should map Responses API status %j to finish reason %s', async (status, expected) => {
mockResponsesOutput([messageItem('Hi!')], status);
const result = await chat();
expect(result.finishReason).toBe(expected);
});
it('should send the output items back in their original order to the same model and API key only', async () => {
const textItem = messageItem('Let me check.');
const requests = mockResponsesOutput([reasoningItem, textItem, functionCallItem]);
const provider = newProvider();
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
expect(firstResult.toolCalls).toEqual([{ toolName: 'get_weather', callId: 'call_1', arguments: { city: 'Paris' }, parseError: null }]);
expect(firstResult.finishReason).toBe('tool_calls');
const messages = toolLoopMessages(firstResult, 'Sunny');
await provider.chat(messages, { tools: [weatherTool] });
const toolOutputItem = { type: 'function_call_output', call_id: 'call_1', output: 'Sunny' };
expect(requests[1].body.input).toEqual([
{ role: 'user', content: 'Weather in Paris?' },
reasoningItem,
textItem,
functionCallItem,
toolOutputItem,
]);
await newProvider('other-model').chat(messages, { tools: [weatherTool] });
await newProvider('test-model', 'other-key').chat(messages, { tools: [weatherTool] });
expect(requests).toHaveLength(4);
for (const request of requests.slice(2)) {
expect(request.body.input).toEqual([
{ role: 'user', content: 'Weather in Paris?' },
{ role: 'assistant', content: 'Let me check.' },
{ type: 'function_call', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' },
toolOutputItem,
]);
}
});
it('should send image tool results as images', async () => {
const requests = mockResponsesOutput([functionCallItem]);
const provider = newProvider();
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
const dataUrl = 'data:image/png;base64,AAAA';
const image = new ToolImageResponse({ dataUrl, mimeType: 'image/png', id: '0123456789abcdef0123456789abcdef' });
await provider.chat(toolLoopMessages(firstResult, image), { tools: [weatherTool] });
expect(requests[1].body.input).toContainEqual({
type: 'function_call_output',
call_id: 'call_1',
output: [{ type: 'input_image', image_url: dataUrl }],
});
});
});
@@ -6,6 +6,9 @@ import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatToolCall, P
import ChatProviderBase from './ChatProviderBase';
import { ToolSpec } from '../tools/types';
import extractReasoning from '../utils/extractReasoning';
import parseToolArguments from '../utils/parseToolArguments';
import { fromResponsesJson, ResponsesApiResponse, toResponsesBody } from './openAiResponses';
const md5 = require('md5');
const logger = Logger.create('OpenAiCompatibleProvider');
@@ -118,19 +121,6 @@ const convertMessage = (message: ChatMessage) => {
}
};
const describeJsonParseFailure = (rawContent: string) => {
const parseError = ['Failed to parse JSON.'];
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
// the model knows to retry with a shorter message:
const suggestedRetryLengthLimit = 1000;
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
}
return parseError.join(' ');
};
export interface ChatRequestOptions {
signal?: AbortSignal;
}
@@ -140,6 +130,8 @@ export interface OpenAiChatResponse {
json: OpenAiResponse;
}
type ChatApi = 'responses' | 'chat-completions';
export default class OpenAiCompatibleProvider extends ChatProviderBase {
public id = 'openai-compatible';
@@ -147,6 +139,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
private baseUrl_: string;
private apiKey_: string;
private model_: string;
private api_: ChatApi|null = null;
public constructor(options: Options) {
super();
@@ -156,9 +149,72 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
this.classification = options.classification;
}
protected canTryResponsesApi() {
return true;
}
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
if (!this.model_) throw new JoplinError('OpenAI-compatible provider has no model configured', 'aiProviderNotConfigured');
if (this.canTryResponsesApi() && this.api_ !== 'chat-completions') {
const result = await this.doResponsesChat(messages, options);
if (result) {
this.api_ = 'responses';
return result;
}
}
const chatCompletionsResult = await this.doChatCompletions(messages, options);
// Some servers like Ollama also return 404 when the model is not found so we only
// remember the fallback after /chat/completions works
this.api_ = 'chat-completions';
return chatCompletionsResult;
}
private async doResponsesChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult|null> {
// Encrypted reasoning only works with the account that created it. The key is
// hashed because the replay key is saved with the chat history
const replayKey = `${this.baseUrl_}|${this.model_}|${md5(this.apiKey_)}`;
const body = toResponsesBody(this.model_, messages, options, replayKey);
let { status, json } = await this.sendResponsesRequest(body, { signal: options?.signal });
if (status === 404 && this.api_ === null) {
logger.info(`${this.baseUrl_}/responses returned 404. Using /chat/completions instead.`);
return null;
}
// Same compatibility retry as `response_format` in doChatCompletions
if (status === 400 && 'text' in body && /json_schema|response_format/i.test(json?.error?.message ?? '')) {
logger.warn(`Model ${this.model_} rejected text.format; retrying without structured output schema.`);
delete body.text;
({ status, json } = await this.sendResponsesRequest(body, { signal: options?.signal }));
}
if (status >= 400) {
const detail = json?.error?.message ? `: ${json.error.message}` : '';
throw new JoplinError(`AI provider returned ${status}${detail}`, status);
}
if (!Array.isArray(json?.output)) {
// Older LM Studio versions return 200 with an error body for unknown endpoints
if (this.api_ === null) {
logger.info(`${this.baseUrl_}/responses returned ${status} with no output. Using /chat/completions instead.`);
return null;
}
throw this.badResponseShapeError();
}
return fromResponsesJson(json, replayKey);
}
private badResponseShapeError() {
return new JoplinError(
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
'aiProviderBadResponse',
);
}
private async doChatCompletions(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
const body: Record<string, unknown> = {
model: this.model_,
messages: messages.flatMap((message): unknown[] => convertMessage(message)),
@@ -210,12 +266,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
// is wrong (e.g. user forgot the `/v1` suffix on a local server) — many
// such servers reply 200 with an empty body rather than 404. Surface
// this rather than returning an empty string the caller can't diagnose.
if (!Array.isArray(json.choices)) {
throw new JoplinError(
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
'aiProviderBadResponse',
);
}
if (!Array.isArray(json.choices)) throw this.badResponseShapeError();
const choice = json.choices?.[0];
const responseMessage = choice?.message;
@@ -229,16 +280,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
const toolCalls: ChatToolCall[] = (responseMessage?.tool_calls ?? []).map(call => {
if (!call.function) return null;
let args;
let parseError: string|null = null;
const argumentString = call.function.arguments;
try {
args = JSON.parse(argumentString);
} catch (error) {
args = {};
parseError = describeJsonParseFailure(argumentString);
logger.error('JSON parse failed', error, parseError);
}
const { args, parseError } = parseToolArguments(call.function.arguments);
return {
toolName: call.function.name,
@@ -257,29 +299,44 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
};
}
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
private async post(path: string, body: Record<string, unknown>, options: ChatRequestOptions) {
if (!this.baseUrl_) throw new JoplinError('OpenAI-compatible provider has no base URL configured', 'aiProviderNotConfigured');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey_) headers['Authorization'] = `Bearer ${this.apiKey_}`;
const response = await shim.fetch(`${this.baseUrl_}/chat/completions`, {
const response = await shim.fetch(`${this.baseUrl_}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: options.signal,
});
const text = await response.text();
return { status: response.status, text: await response.text() };
}
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
const { status, text } = await this.post('/chat/completions', body, options);
let json: OpenAiResponse;
try {
json = JSON.parse(text) as OpenAiResponse;
} catch {
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, response.status);
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
}
return {
response: { status: response.status },
response: { status },
json,
};
}
private async sendResponsesRequest(body: Record<string, unknown>, options: ChatRequestOptions) {
const { status, text } = await this.post('/responses', body, options);
let json: ResponsesApiResponse|null = null;
try {
json = JSON.parse(text) as ResponsesApiResponse;
} catch {
if (status !== 404) throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
}
return { status, json };
}
}
@@ -0,0 +1,123 @@
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall } from '../types';
import { ToolSpec } from '../tools/types';
import extractReasoning from '../utils/extractReasoning';
import parseToolArguments from '../utils/parseToolArguments';
interface ResponsesContentPart {
type: string;
text?: string;
}
interface ResponsesOutputItem {
type: string;
content?: ResponsesContentPart[];
summary?: ResponsesContentPart[];
call_id?: string;
name?: string;
arguments?: string;
}
export interface ResponsesApiResponse {
output?: ResponsesOutputItem[];
status?: string;
incomplete_details?: { reason?: string }|null;
usage?: { input_tokens?: number; output_tokens?: number };
error?: { message?: string }|null;
}
const convertTool = (tool: ToolSpec) => {
return {
type: 'function',
name: tool.id,
description: tool.description,
parameters: tool.inputSchema,
};
};
const convertMessage = (message: ChatMessage, replayKey: string): unknown[] => {
if (message.role === ChatRole.Tool) {
const content = message.content;
return [{
type: 'function_call_output',
call_id: message.toolCallId,
output: typeof content === 'string' ? content : [{ type: 'input_image', image_url: content.dataUrl }],
}];
}
if (message.responsesState?.key === replayKey) {
return message.responsesState.items;
}
const textItems = message.content || message.role !== ChatRole.Assistant ? [{ role: message.role, content: message.content }] : [];
return [
...textItems,
...(message.toolCalls ?? []).map(call => ({
type: 'function_call',
call_id: call.callId,
name: call.toolName,
arguments: JSON.stringify(call.arguments),
})),
];
};
export const toResponsesBody = (model: string, messages: ChatMessage[], options: ChatOptions|undefined, replayKey: string) => {
const body: Record<string, unknown> = {
model,
input: messages.flatMap(message => convertMessage(message, replayKey)),
stream: false,
store: false,
include: ['reasoning.encrypted_content'],
};
if (options?.temperature !== undefined) body.temperature = options.temperature;
if (options?.maxTokens !== undefined) body.max_output_tokens = options.maxTokens;
if (options?.responseFormat !== undefined) body.text = { format: { type: 'json_schema', ...options.responseFormat.json_schema } };
if (options?.tools?.length) body.tools = options.tools.map(convertTool);
return body;
};
const toChatFinishReason = (json: ResponsesApiResponse, hasToolCalls: boolean): ChatFinishReason|undefined => {
if (json.status === 'incomplete') return json.incomplete_details?.reason === 'max_output_tokens' ? 'length' : 'other';
if (hasToolCalls) return 'tool_calls';
if (json.status === 'completed') return 'stop';
return json.status ? 'other' : undefined;
};
const partsText = (parts: ResponsesContentPart[], type: string) => {
return parts.filter(part => part.type === type).map(part => part.text ?? '');
};
export const fromResponsesJson = (json: ResponsesApiResponse, replayKey: string): ChatResult => {
const output = json.output ?? [];
const rawText = partsText(output.filter(item => item.type === 'message').flatMap(item => item.content ?? []), 'output_text').join('');
const { text, reasoning: inlineReasoning } = extractReasoning(rawText);
const reasoningItems = output.filter(item => item.type === 'reasoning');
const summaryText = partsText(reasoningItems.flatMap(item => item.summary ?? []), 'summary_text').join('\n\n');
// LM Studio sends the reasoning in `content` instead of `summary`
const reasoningContentText = partsText(reasoningItems.flatMap(item => item.content ?? []), 'reasoning_text').join('\n\n');
const toolCalls: ChatToolCall[] = output.filter(item => item.type === 'function_call').map(call => {
const { args, parseError } = parseToolArguments(call.arguments ?? '');
return {
toolName: call.name,
callId: call.call_id,
arguments: args,
parseError,
};
});
const replayItems = output.filter(item => item.type === 'reasoning' || item.type === 'message' || item.type === 'function_call');
return {
text,
toolCalls,
usage: {
inputTokens: json.usage?.input_tokens ?? 0,
outputTokens: json.usage?.output_tokens ?? 0,
},
finishReason: toChatFinishReason(json, toolCalls.length > 0),
reasoningText: summaryText || reasoningContentText || inlineReasoning || undefined,
responsesState: toolCalls.length ? { key: replayKey, items: replayItems } : undefined,
};
};
@@ -0,0 +1,27 @@
import shim from '../../../shim';
type RequestBody = Record<string, unknown>;
type RequestHandler = (body: RequestBody)=> Response;
export interface MockServerRequest {
path: string;
body: RequestBody;
}
export const jsonResponse = (json: unknown, status = 200) => {
return new Response(JSON.stringify(json), { status });
};
const mockOpenAiServer = (baseUrl: string, handlers: Record<string, RequestHandler>) => {
const requests: MockServerRequest[] = [];
jest.spyOn(shim, 'fetch').mockImplementation(async (url, options) => {
const path = url.substring(baseUrl.length);
const body: RequestBody = JSON.parse(options.body);
requests.push({ path, body });
const handler = handlers[path];
return handler ? handler(body) : new Response('404 page not found', { status: 404 });
});
return requests;
};
export default mockOpenAiServer;
+7
View File
@@ -15,6 +15,7 @@ export interface ChatStandardMessage extends ChatBaseMessage {
content: string;
hide?: boolean;
toolCalls?: ChatToolCall[];
responsesState?: ResponsesReplayState;
}
export interface ChatToolMessage extends ChatBaseMessage {
@@ -67,6 +68,11 @@ export interface ChatToolCall {
parseError: string|null;
}
export interface ResponsesReplayState {
key: string;
items: unknown[];
}
export type ChatFinishReason = 'stop' | 'length' | 'tool_calls' | 'other';
export interface ChatResult {
@@ -79,6 +85,7 @@ export interface ChatResult {
// Model reasoning, from a dedicated provider field or extracted from inline
// markers. Kept separate so it doesn't pollute the assistant reply.
reasoningText?: string;
responsesState?: ResponsesReplayState;
// Joplin Cloud degradation / budget signals. Populated only by the
// joplin-cloud provider; other providers leave them undefined. Consumed
// internally to drive the aiStatus Redux slice — plugins receive only
@@ -0,0 +1,29 @@
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('ai/parseToolArguments');
const describeJsonParseFailure = (rawContent: string) => {
const parseError = ['Failed to parse JSON.'];
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
// the model knows to retry with a shorter message:
const suggestedRetryLengthLimit = 1000;
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
}
return parseError.join(' ');
};
const parseToolArguments = (argumentString: string) => {
try {
const args: Record<string, unknown> = JSON.parse(argumentString);
return { args, parseError: null as string|null };
} catch (error) {
const parseError = describeJsonParseFailure(argumentString);
logger.error('JSON parse failed', error, parseError);
return { args: {}, parseError };
}
};
export default parseToolArguments;
+1 -1
View File
@@ -44,7 +44,7 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.1.2",
"@fortawesome/free-solid-svg-icons": "6.1.2",
"@fortawesome/react-fontawesome": "3.3.1",
"@fortawesome/react-fontawesome": "3.4.0",
"@joplin/lib": "~3.7",
"async-mutex": "0.5.0",
"pdfjs-dist": "2.16.105",
+1 -1
View File
@@ -84,7 +84,7 @@
"jest": "29.7.0",
"jest-expect-message": "1.1.3",
"jsdom": "26.1.0",
"node-mocks-http": "1.17.2",
"node-mocks-http": "1.18.1",
"typescript": "5.9.3"
}
}
File diff suppressed because it is too large. Load diff
+29 -29
View File
@@ -1365,7 +1365,7 @@ msgid ""
"Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end "
"encryption. They may do so from the screen Configuration > Encryption."
msgstr ""
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ebligis ĉifradon de fino al "
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ŝaltis ĉifradon de fino al "
"fino. Tion eblas fari en la ekrano Agordo > Ĉifrado."
#: packages/app-mobile/components/NoteEditor/SearchPanel.tsx:340
@@ -1437,7 +1437,7 @@ msgstr "Kontroli ĝisdatigojn"
#: packages/app-desktop/gui/MenuBar.tsx:598 packages/app-desktop/gui/MenuBar.tsx:923
msgid "Check for updates..."
msgstr "Kontrolu ĝisdatigojn..."
msgstr "Kontroli ĝisdatigojn..."
#: packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx:357
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:488
@@ -1504,7 +1504,7 @@ msgid ""
"Click \"%s\" to restore the note. It will be copied in the notebook named \"%s\". The current "
"version of the note will not be replaced or modified."
msgstr ""
"Alklaku \"%s\" por restarigi la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
"Alklaku \"%s\" por restaŭri la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
"versio de la noto ne estos anstataŭigita aŭ modifita."
#: packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx:239
@@ -2441,7 +2441,7 @@ msgstr "Malŝalti la servon Web Clipper"
#: packages/app-mobile/components/screens/encryption-config.tsx:384
#: packages/lib/models/settings/builtInMetadata.ts:1845
msgid "Disabled"
msgstr "Malebligita"
msgstr "Malŝaltita"
#: packages/app-mobile/components/screens/encryption-config.tsx:353
msgid "Disabled keys"
@@ -2539,7 +2539,7 @@ msgid ""
"data! To enable encryption, please enter your password below."
msgstr ""
"Ne perdu la pasvorton, ĉar pro sekurecaj kialoj ĉi tio estos la *nura* maniero malĉifri la "
"datumojn! Por ebligi ĉifradon, bonvolu enigi vian pasvorton sube."
"datumojn! Por ŝalti ĉifradon, bonvolu enigi vian pasvorton sube."
#: packages/lib/models/settings/builtInMetadata.ts:2499
msgid "Document scanner: Title template"
@@ -2659,7 +2659,7 @@ msgstr "Dropbox"
#: packages/app-desktop/gui/Root.tsx:164
msgid "Dropbox Login"
msgstr "Dropbox-salutnomo"
msgstr "Ensaluto al Dropbox"
#: packages/app-desktop/gui/NoteListHeader/utils/getColumnTitle.ts:14
msgid "Due"
@@ -2839,7 +2839,7 @@ msgstr "Malplenigi rubujon"
#: packages/app-mobile/components/biometrics/BiometricPopup.tsx:87
#: packages/app-mobile/components/screens/encryption-config.tsx:228
msgid "Enable"
msgstr "Ebligi"
msgstr "Ŝalti"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:175
#, python-format
@@ -2903,7 +2903,7 @@ msgstr "Permesi file://-URL-ojn por bildoj kaj videoj"
#: packages/lib/models/settings/builtInMetadata.ts:1499
msgid "Enable footnotes"
msgstr "Ebligi piednotojn"
msgstr "Ŝalti piednotojn"
#: packages/lib/models/settings/builtInMetadata.ts:1491
msgid "Enable Fountain syntax support"
@@ -2943,7 +2943,7 @@ msgstr "Ŝalti la plurtabelan Markdown-etendaĵon"
#: packages/lib/models/settings/builtInMetadata.ts:2144
msgid "Enable note history"
msgstr "Ebligi nothistorion"
msgstr "Ŝalti nothistorion"
#: packages/lib/models/Setting.ts:1415
msgid "Enable or disable plugins"
@@ -2960,7 +2960,7 @@ msgstr "Ŝalti subtenon por kromprogramoj"
#: packages/lib/models/settings/builtInMetadata.ts:1486
msgid "Enable soft breaks"
msgstr "Ŝalti molajn linisalton"
msgstr "Ŝalti molajn linisaltojn"
#: packages/lib/models/settings/builtInMetadata.ts:1927
msgid "Enable spell checking in Markdown editor"
@@ -2995,7 +2995,7 @@ msgstr "Ŝalti la servon Web Clipper"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx:93
#: packages/app-mobile/components/screens/encryption-config.tsx:384
msgid "Enabled"
msgstr "Ebligita"
msgstr "Ŝaltita"
#: packages/lib/models/settings/builtInMetadata.ts:1127
msgid "Enables Markdown list continuation, auto-closing HTML tags, and other markup autocompletions."
@@ -3183,7 +3183,7 @@ msgstr "Evernote-eksportdosieroj (dosierujo, kiel Markdown)"
#: packages/app-cli/app/command-exit.ts:11
msgid "Exits the application."
msgstr "Eliri la aplikaĵon."
msgstr "Eliras el la aplikaĵo."
#: packages/app-mobile/components/side-menu-content.tsx:219
#, python-format
@@ -3455,7 +3455,7 @@ msgstr "Trovita: %d."
#: packages/app-mobile/utils/getVersionInfoText.ts:47
#, python-format
msgid "FTS enabled: %d"
msgstr "FTS ebligita: %d"
msgstr "FTS ŝaltita: %d"
#: packages/app-desktop/checkForUpdates.ts:116
msgid "Full changelog"
@@ -3620,7 +3620,7 @@ msgstr "Kaŝas averton"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
msgid "Highlight"
msgstr "Emfazi"
msgstr "Elstarigi"
#: packages/server/src/services/MustacheService.ts:149
#: packages/server/src/services/MustacheService.ts:282
@@ -4015,12 +4015,12 @@ msgstr "Nevalida %s: %s."
#: packages/app-cli/app/cli-utils.ts:199
#, python-format
msgid "Invalid answer: %s"
msgstr "Malvalida respondo: %s"
msgstr "Nevalida respondo: %s"
#: packages/app-cli/app/command-tag.ts:94
#, python-format
msgid "Invalid command: \"%s\""
msgstr "Malvalida komando: \"%s\""
msgstr "Nevalida komando: \"%s\""
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:364
msgid "Invalid format. E.g.: 48.8581372, 2.2926735"
@@ -4275,7 +4275,7 @@ msgstr "Ŝlosiloj kiuj bezonas ĝisdatigon"
#: packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.tsx:494
#: packages/editor/ProseMirror/plugins/imagePlugin.ts:265
msgid "Label"
msgstr "Etikedo"
msgstr "Surskribo"
#: packages/lib/models/settings/builtInMetadata.ts:1885
msgid "Landscape"
@@ -4576,7 +4576,7 @@ msgstr "Markdown-redaktilo"
#: packages/lib/models/settings/builtInMetadata.ts:1968
msgid "Markdown editor: Highlight active line"
msgstr "Markdown-redaktilo: emfazi aktivan linion"
msgstr "Markdown-redaktilo: elstarigi aktivan linion"
#: packages/lib/models/settings/builtInMetadata.ts:1947
msgid "Markdown editor: Interactive table editing"
@@ -4584,7 +4584,7 @@ msgstr "Markdown-redaktilo: interaga tabelredaktado"
#: packages/lib/models/settings/builtInMetadata.ts:1957
msgid "Markdown editor: Render images"
msgstr "Markdown-redaktilo: bildigi bildojn"
msgstr "Markdown-redaktilo: montri bildojn"
#: packages/lib/models/settings/builtInMetadata.ts:1937
msgid "Markdown editor: Render markup in editor"
@@ -5314,7 +5314,7 @@ msgstr "OCR: Vakigi kaŝmemoron kaj reelŝuti lingvajn datumdosierojn"
#: packages/lib/models/settings/builtInMetadata.ts:573
msgid "OCR: Enable optical character recognition"
msgstr "OCR: Ebligi optikan signorekonon"
msgstr "OCR: Ŝalti optikan signorekonon"
#: packages/lib/models/settings/builtInMetadata.ts:613
msgid "OCR: Language data URL or path"
@@ -5391,7 +5391,7 @@ msgstr "OneDrive"
#: packages/app-desktop/gui/Root.tsx:163
msgid "OneDrive Login"
msgstr "OneDrive-salutnomo"
msgstr "Ensaluto al OneDrive"
#: packages/lib/services/interop/InteropService.ts:159
msgid "OneNote Notebook"
@@ -6187,11 +6187,11 @@ msgstr "Forigi %s el kunhavigo"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:164
#, python-format
msgid "Remove tag \"%s\" from all notes?"
msgstr "Forigi etikedon \"%s\" el ĉiuj notoj?"
msgstr "Ĉu forigi etikedon \"%s\" el ĉiuj notoj?"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:169
msgid "Remove this search from the sidebar?"
msgstr "Forigi ĉi tiun serĉon el la flankbreto?"
msgstr "Ĉu forigi ĉi tiun serĉon el la flankbreto?"
#: packages/lib/services/ai/tools/buildEditorTools.ts:51
#, python-format
@@ -6379,11 +6379,11 @@ msgstr "Restarigi defaŭltojn"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.ts:10
msgid "Restore note"
msgstr "Restarigi noton"
msgstr "Restaŭri noton"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.ts:9
msgid "Restore notebook"
msgstr "Restarigi notlibron"
msgstr "Restaŭri notlibron"
#: packages/app-cli/app/command-restore.ts:12
msgid "Restore the items matching <pattern> from the trash."
@@ -6391,7 +6391,7 @@ msgstr "Restarigas la erojn kongruantajn kun <pattern> el la rubujo."
#: packages/lib/services/trash/index.ts:88
msgid "Restored items"
msgstr "Restarigitaj eroj"
msgstr "Restaŭritaj eroj"
#: packages/lib/services/RevisionService.ts:264
msgid "Restored Notes"
@@ -7229,7 +7229,7 @@ msgstr "Ŝanĝi al antaŭa fotilo"
#: packages/app-desktop/gui/utils/NoteListUtils.ts:89
msgid "Switch to note type"
msgstr "Ŝalti al nota tipo"
msgstr "Ŝanĝi al nota tipo"
#: packages/app-desktop/commands/switchProfile1.ts:7 packages/app-desktop/commands/switchProfile2.ts:7
#: packages/app-desktop/commands/switchProfile3.ts:7
@@ -7252,7 +7252,7 @@ msgstr "Ŝanĝi al la nova redaktilo"
#: packages/app-desktop/gui/utils/NoteListUtils.ts:98
msgid "Switch to to-do type"
msgstr "Ŝalti al taska tipo"
msgstr "Ŝanĝi al taska tipo"
#: packages/app-desktop/gui/ChatPanel/ChatPanel.tsx:152
#, python-format
@@ -8153,7 +8153,7 @@ msgstr "Por forigi etikedon, forigu ĝin de la asociitaj notoj."
#: packages/lib/services/ReportService.ts:367
#, python-format
msgid "To delete: %d"
msgstr "Forigi: %d"
msgstr "Forigotaj: %d"
#: packages/app-cli/app/command-help.ts:82
msgid "To enter command line mode, press \":\""
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,7 @@
---
forum_url: https://discourse.joplinapp.org/t/51403
---
# A full security review of Joplin Server and Joplin Cloud
October 2026 has been nicknamed "Black October": within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.
+11 -12
View File
@@ -11552,13 +11552,13 @@ __metadata:
languageName: node
linkType: hard
"@fortawesome/react-fontawesome@npm:3.3.1":
version: 3.3.1
resolution: "@fortawesome/react-fontawesome@npm:3.3.1"
"@fortawesome/react-fontawesome@npm:3.4.0":
version: 3.4.0
resolution: "@fortawesome/react-fontawesome@npm:3.4.0"
peerDependencies:
"@fortawesome/fontawesome-svg-core": ~6 || ~7
react: ^18.0.0 || ^19.0.0
checksum: 10/e099a5721099f1cf59d61fa21ce0ad6473be782470781574aa3c21136263f44d067e15dae4a4e273876ca3b3f16df6a099f93a4ed9c0a717223dffa66bb57da7
checksum: 10/64e57e04a7cd98352503edc8fa890420e75d8953262363579388b718656716c10ef2652c62af76edda4d1d1b35205c80ba68af7dc7b32ee4bf0d4c7093afc69e
languageName: node
linkType: hard
@@ -13058,7 +13058,7 @@ __metadata:
dependencies:
"@fortawesome/fontawesome-svg-core": "npm:6.1.2"
"@fortawesome/free-solid-svg-icons": "npm:6.1.2"
"@fortawesome/react-fontawesome": "npm:3.3.1"
"@fortawesome/react-fontawesome": "npm:3.4.0"
"@joplin/lib": "npm:~3.7"
"@types/jest": "npm:29.5.14"
"@types/pdfjs-dist": "npm:2.10.378"
@@ -13224,7 +13224,7 @@ __metadata:
markdown-it: "npm:13.0.2"
mustache: "npm:4.2.0"
node-cron: "npm:4.5.0"
node-mocks-http: "npm:1.17.2"
node-mocks-http: "npm:1.18.1"
nodemailer: "npm:6.10.1"
nodemon: "npm:3.1.14"
otplib: "npm:12.0.1"
@@ -44584,9 +44584,9 @@ __metadata:
languageName: node
linkType: hard
"node-mocks-http@npm:1.17.2":
version: 1.17.2
resolution: "node-mocks-http@npm:1.17.2"
"node-mocks-http@npm:1.18.1":
version: 1.18.1
resolution: "node-mocks-http@npm:1.18.1"
dependencies:
accepts: "npm:^1.3.7"
content-disposition: "npm:^0.5.3"
@@ -44595,7 +44595,6 @@ __metadata:
merge-descriptors: "npm:^1.0.1"
methods: "npm:^1.1.2"
mime: "npm:^1.3.4"
parseurl: "npm:^1.3.3"
range-parser: "npm:^1.2.0"
type-is: "npm:^1.6.18"
peerDependencies:
@@ -44606,7 +44605,7 @@ __metadata:
optional: true
"@types/node":
optional: true
checksum: 10/3ab97b5a7b2dba0032fc5090983e4ca4be0597f1c645b34dff99d465feb17ef1ee7ea4829a9b4303495399c1ac0f12f8964b878e0dda43cda798bb97254ad571
checksum: 10/7a7062de9b16c1ff8e90cd2a824fd74ab4e0c1552837ead87d54e4bc821324c1287a84aee6394d6c229c4fdc64bd2c249ce3d17b9e8e102d0f5142fe8e332958
languageName: node
linkType: hard
@@ -46464,7 +46463,7 @@ __metadata:
languageName: node
linkType: hard
"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
"parseurl@npm:^1.3.2, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
checksum: 10/407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2