Merge branch 'release-3.7' into dev

This commit is contained in:
Laurent Cozic committed 2026-09-25 17:09:18 +01:00
commit 6ae9353849
74 files changed
+2120 -304

No files matched your search

+11
View File
@@ -198,7 +198,9 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatHistory.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -540,6 +542,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -1458,6 +1461,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.test.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.test.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
@@ -1514,6 +1518,9 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ChatConversation.test.js
packages/lib/models/ChatConversation.js
packages/lib/models/ChatMessage.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
@@ -1647,6 +1654,7 @@ packages/lib/services/ai/providers/Anthropic.js
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
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/testing/TestEmbeddingProvider.test.js
@@ -1672,6 +1680,8 @@ packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
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/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
@@ -1710,6 +1720,7 @@ 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/54.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
+11
View File
@@ -224,7 +224,9 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatHistory.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -566,6 +568,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -1484,6 +1487,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.test.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.test.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
@@ -1540,6 +1544,9 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ChatConversation.test.js
packages/lib/models/ChatConversation.js
packages/lib/models/ChatMessage.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
@@ -1673,6 +1680,7 @@ packages/lib/services/ai/providers/Anthropic.js
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
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/testing/TestEmbeddingProvider.test.js
@@ -1698,6 +1706,8 @@ packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
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/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
@@ -1736,6 +1746,7 @@ 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/54.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260914T100000Z" application="Evernote" version="10.0">
<note>
<title>Unescaped ampersand</title>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><div>Mobilize &amp; Measure</div></en-note>
]]>
</content>
<created>20260914T100000Z</created>
<updated>20260914T100000Z</updated>
<note-attributes>
<application-data key="evernote.caption">Phase 1: Mobilize & Measure, which involves forming an advocacy group.</application-data>
</note-attributes>
</note>
<note>
<title>Note after the bad one</title>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><div>This note must still be imported</div></en-note>
]]>
</content>
<created>20260914T100000Z</created>
<updated>20260914T100000Z</updated>
</note>
</en-export>
+24 -12
View File
@@ -1,9 +1,25 @@
import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types';
import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer';
import appReducer, { createAppDefaultState } from './app.reducer';
import { defaultWindowId } from '@joplin/lib/reducer';
describe('app.reducer', () => {
it('should clear a deleted conversation from matching windows and keep other conversations', () => {
const messages: AiChatMessage[] = [{ id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: '', noteTitle: '' }];
const state = createAppDefaultState({});
state.aiChatConversationId = 'deleted';
state.aiChatMessages = messages;
state.backgroundWindows = {
second: { ...createAppDefaultWindowState(), windowId: 'second', aiChatConversationId: 'deleted', aiChatMessages: messages },
third: { ...createAppDefaultWindowState(), windowId: 'third', aiChatConversationId: 'other', aiChatMessages: messages },
};
const result = appReducer(state, { type: 'AI_CHAT_DELETE', conversationId: 'deleted' });
expect(result.aiChatConversationId).toBeNull();
expect(result.aiChatMessages).toEqual([]);
expect(result.backgroundWindows.second.aiChatConversationId).toBeNull();
expect(result.backgroundWindows.second.aiChatMessages).toEqual([]);
expect(result.backgroundWindows.third.aiChatConversationId).toBe('other');
expect(result.backgroundWindows.third.aiChatMessages).toEqual(messages);
});
it('should handle DIALOG_OPEN', async () => {
const state: AppState = createAppDefaultState({});
@@ -107,11 +123,15 @@ describe('app.reducer', () => {
it('should build AI chat history', () => {
let state: AppState = {
...createAppDefaultState({}),
aiChatConversationId: 'chat-1',
};
let idCounter = 0;
const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({
id: `id-${idCounter++}`,
createdTime: 1,
noteId: 'note-a',
noteTitle: 'A',
role,
text: content,
raw: [
@@ -125,12 +145,12 @@ describe('app.reducer', () => {
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
conversationId: 'chat-1',
message: buildMessage(ChatRole.User, 'Test', []),
});
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
conversationId: 'chat-1',
message: buildMessage(
ChatRole.Assistant,
'Testing',
@@ -139,7 +159,7 @@ describe('app.reducer', () => {
});
state = appReducer(state, {
type: 'AI_CHAT_ADD_TOOL_RESULT',
windowId: defaultWindowId,
conversationId: 'chat-1',
toolCall: ({
role: ChatRole.Tool,
toolName: 'testTool',
@@ -155,13 +175,5 @@ describe('app.reducer', () => {
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
{ id: 'id-1', role: 'assistant', text: 'Testing' },
]);
state = appReducer(state, {
type: 'AI_CHAT_RESET',
windowId: defaultWindowId,
});
expect(state.aiChatMessages).toEqual([]);
});
});
+41 -35
View File
@@ -5,12 +5,15 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems';
import { LayoutItem } from './gui/ResizableLayout/utils/types';
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
import Logger from '@joplin/utils/Logger';
import { ChatMessage } from '@joplin/lib/services/ai/types';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
const logger = Logger.create('app.reducer');
export interface AiChatMessage {
id: string;
createdTime: number;
noteId: string;
noteTitle: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
hide?: boolean;
@@ -78,6 +81,7 @@ export interface AppWindowState extends WindowState {
// In window state so the conversation survives panel hide/show (the
// layout container can swap component types and unmount the panel).
aiChatMessages: AiChatMessage[];
aiChatConversationId: string|null;
// Layout for secondary windows
secondaryWindowLayout: LayoutItem|null;
}
@@ -115,6 +119,7 @@ export const createAppDefaultWindowState = (): AppWindowState => {
whiteboardForceMarkdown: {},
activeNoteIsWhiteboard: false,
aiChatMessages: [],
aiChatConversationId: null,
secondaryWindowLayout: null,
};
};
@@ -304,43 +309,44 @@ export default function(state: AppState, action: any) {
);
break;
case 'AI_CHAT_DELETE':
newState = produce(state, draft => {
for (const windowState of stateUtils.allWindowStates(draft)) {
if (windowState.aiChatConversationId !== action.conversationId) continue;
windowState.aiChatConversationId = null;
windowState.aiChatMessages = [];
}
});
break;
case 'AI_CHAT_OPEN':
newState = produce(state, draft => {
const openConversation = action.conversationId && stateUtils.allWindowStates(state)
.find(windowState => windowState.aiChatConversationId === action.conversationId);
const windowState = stateUtils.windowStateById(draft, action.windowId);
windowState.aiChatConversationId = action.conversationId;
windowState.aiChatMessages = openConversation ? openConversation.aiChatMessages : action.messages;
});
break;
case 'AI_CHAT_APPEND':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => [...messages, action.message as AiChatMessage],
);
break;
case 'AI_CHAT_ADD_TOOL_RESULT':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => {
let lastMessage = messages[messages.length - 1];
if (lastMessage) {
lastMessage = {
...lastMessage,
raw: [
...lastMessage.raw,
action.toolCall,
],
};
return [...messages.slice(0, messages.length - 1), lastMessage];
}
return messages;
},
);
break;
case 'AI_CHAT_REMOVE':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id),
);
break;
case 'AI_CHAT_RESET':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', (): AiChatMessage[] => [],
);
newState = produce(state, draft => {
for (const windowState of stateUtils.allWindowStates(draft)) {
if (windowState.aiChatConversationId !== action.conversationId) continue;
if (action.type === 'AI_CHAT_APPEND') {
windowState.aiChatMessages.push(action.message);
} else if (action.type === 'AI_CHAT_REMOVE') {
windowState.aiChatMessages = windowState.aiChatMessages.filter(message => message.id !== action.id);
} else {
const message = windowState.aiChatMessages.find(message => message.raw.some(entry =>
entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === action.toolCall.toolCallId),
));
message?.raw.push(action.toolCall);
}
}
});
break;
case 'AI_STATUS_UPDATE':
@@ -0,0 +1,141 @@
@mixin chat-history-input {
box-sizing: border-box;
font: inherit;
color: var(--joplin-color);
background-color: var(--joplin-background-color);
border: 1px solid var(--joplin-divider-color);
border-radius: 4px;
}
.chat-history {
position: absolute;
top: 100%;
left: 0;
right: 30px;
z-index: 10;
display: flex;
flex-direction: column;
max-width: 400px;
margin-left: auto;
padding: 6px 10px 10px;
font-weight: normal;
background-color: var(--joplin-background-color);
border-bottom: 1px solid var(--joplin-divider-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
> .search {
flex-shrink: 0;
}
}
.conversation-search {
position: relative;
display: flex;
align-items: center;
> .icon {
position: absolute;
left: 8px;
color: var(--joplin-color-faded);
pointer-events: none;
}
> .field {
@include chat-history-input;
width: 100%;
padding: 6px 6px 6px 26px;
}
}
.conversation-list {
list-style: none;
margin: 6px 0 0;
padding: 0;
max-height: 200px;
overflow-y: auto;
}
.conversation-row {
display: flex;
align-items: center;
line-height: 16px;
cursor: pointer;
&.-current {
font-weight: bold;
background-color: var(--joplin-selected-color);
}
&:hover:not(.-current) {
background-color: var(--joplin-background-color-hover3);
}
> .open {
flex: 1;
min-width: 0;
}
> .title-input {
@include chat-history-input;
flex: 1;
min-width: 0;
margin: 6px 20px;
padding: 3px 4px;
}
> .actions {
flex-shrink: 0;
margin-right: 20px;
}
&:hover,
&:focus-within {
--chat-history-timestamp-display: none;
}
&:hover > .actions,
&:focus-within > .actions {
display: flex;
}
}
.conversation-link {
display: flex;
padding: 10px 20px;
gap: 18px;
text-align: left;
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
> .title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
> .timestamp {
display: var(--chat-history-timestamp-display, initial);
flex-shrink: 0;
text-align: right;
color: var(--joplin-color-faded);
}
}
.conversation-actions {
display: none;
gap: 10px;
align-items: center;
> button {
display: flex;
color: var(--joplin-color3);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
}
@@ -0,0 +1,112 @@
import * as React from 'react';
import { useState } from 'react';
import { _ } from '@joplin/lib/locale';
import { formatMsToRelativeTime } from '@joplin/utils/time';
export interface Conversation {
id: string;
title: string;
updated_time: number;
}
interface Props {
conversations: Conversation[];
currentConversationId?: string|null;
id: string;
popupRef: React.Ref<HTMLDivElement>;
onSearchChange: (search: string)=> void;
onOpen: (conversationId: string)=> void;
onRename: (conversation: Conversation, title: string)=> void;
onDelete: (conversationId: string)=> void;
onClose: ()=> void;
}
const ChatHistory: React.FC<Props> = props => {
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState<string|null>(null);
const [draftTitle, setDraftTitle] = useState('');
const startEditing = (conversation: Conversation) => {
setEditingId(conversation.id);
setDraftTitle(conversation.title);
};
const stopEditing = () => setEditingId(null);
const onTitleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, conversation: Conversation) => {
if (event.nativeEvent.isComposing) return;
if (event.key === 'Escape') {
event.stopPropagation();
stopEditing();
return;
}
if (event.key !== 'Enter') return;
event.preventDefault();
props.onRename(conversation, draftTitle);
stopEditing();
};
const onPopupKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.nativeEvent.isComposing || event.key !== 'Escape') return;
event.stopPropagation();
props.onClose();
};
return <div className='chat-history' id={props.id} ref={props.popupRef} onKeyDown={onPopupKeyDown}>
<div className='conversation-search search'>
<i className='icon icon-search' aria-hidden='true'/>
<input
className='field'
type='search'
autoFocus
aria-label={_('Search conversations')}
placeholder={_('Search conversations')}
value={search}
onChange={event => {
setSearch(event.target.value);
props.onSearchChange(event.target.value);
}}
/>
</div>
<ul className='conversation-list' aria-label={_('Conversations')}>
{props.conversations.map(conversation => {
const current = conversation.id === props.currentConversationId;
const title = conversation.title || _('(untitled)');
const editing = conversation.id === editingId;
return <li key={conversation.id} className={`conversation-row${current ? ' -current' : ''}`} aria-current={current ? 'true' : undefined}
onClick={event => {
if (event.target === event.currentTarget) props.onOpen(conversation.id);
}}
>
{editing ? (
<input
className='title-input'
type='text'
autoFocus
aria-label={_('Conversation title')}
value={draftTitle}
onChange={event => setDraftTitle(event.target.value)}
onKeyDown={event => onTitleKeyDown(event, conversation)}
onBlur={stopEditing}
/>
) : (
<button className='conversation-link open' type='button' onClick={() => props.onOpen(conversation.id)}>
<span className='title'>{title}</span>
<span className='timestamp'>{formatMsToRelativeTime(conversation.updated_time)}</span>
</button>
)}
<div className='conversation-actions actions'>
<button className='flat-button' type='button' title={_('Rename')} aria-label={_('Rename')} onClick={() => startEditing(conversation)}>
<i className='fa fa-pen' aria-hidden='true'/>
</button>
<button className='flat-button' type='button' title={_('Delete')} aria-label={_('Delete')} onClick={() => props.onDelete(conversation.id)}>
<i className='fas fa-trash' aria-hidden='true'/>
</button>
</div>
</li>;
})}
</ul>
</div>;
};
export default ChatHistory;
@@ -0,0 +1,200 @@
import * as React from 'react';
import { act, fireEvent, render, waitFor, within } from '@testing-library/react';
import BaseModel from '@joplin/lib/BaseModel';
import ChatConversation from '@joplin/lib/models/ChatConversation';
import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { ChatRole } from '@joplin/lib/services/ai/types';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
import ChatPanel from './ChatPanel';
import { WindowIdContext } from '../NewWindowOrIFrame';
import uuid from '@joplin/lib/uuid';
import '../../utils/window/eventHandlerOverrides';
jest.mock('@joplin/lib/services/ai/noteChat', () => ({ runNoteChat: jest.fn() }));
jest.mock('./ChatMessageItem', () => ({ message }: { message: { text: string } }) => <div>{message.text}</div>);
const Panel = ChatPanel.WrappedComponent;
const message: AiChatMessage = { id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: 'note-a', noteTitle: 'A' };
const renderPanel = (props: Partial<React.ComponentProps<typeof Panel>> = {}) => render(
<WindowIdContext.Provider value='second'><Panel
themeId={1} available={true} unavailableHint='' providerType='joplin-cloud'
noteId={null} noteTitle='' noteIsEncrypted={false} messages={[]} aiDegraded={false} dispatch={jest.fn()}
showToolbarButton={false}
{...props}
/></WindowIdContext.Provider>,
);
const openHistory = async (view: ReturnType<typeof render>) => {
await act(async () => { fireEvent.click(view.getByTitle('Chat history')); });
};
const saveConversation = async (id: string, title: string, updatedTime = 1) => {
await ChatConversation.save({ id, title, created_time: 1, updated_time: updatedTime }, { isNew: true, autoTimestamp: false });
};
const saveMessage = async (conversationId: string, text: string) => {
await ChatConversation.addMessage(conversationId, { id: uuid.create(), createdTime: 1, role: 'user', text, raw: [], noteId: '', noteTitle: '' });
};
describe('ChatPanel', () => {
let modelDispatch: jest.Mock;
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
Element.prototype.scrollIntoView = jest.fn();
jest.mocked(runNoteChat).mockReset();
modelDispatch = jest.fn();
BaseModel.dispatch = modelDispatch;
});
afterEach(() => {
jest.restoreAllMocks();
BaseModel.dispatch = () => {};
});
it('should search conversation titles and messages', async () => {
await saveConversation('shopping', 'Shopping list');
await saveConversation('travel', 'Travel plans');
await saveMessage('travel', 'Visit Paris');
const view = renderPanel();
await openHistory(view);
await view.findByText('Shopping list');
const search = view.getByRole('searchbox', { name: 'Search conversations' });
fireEvent.change(search, { target: { value: 'Shopping' } });
await waitFor(() => expect(view.getAllByRole('listitem')).toHaveLength(1));
expect(view.getByText('Shopping list')).toBeTruthy();
fireEvent.change(search, { target: { value: 'Paris' } });
expect(await view.findByText('Travel plans')).toBeTruthy();
expect(view.getAllByRole('listitem')).toHaveLength(1);
fireEvent.change(search, { target: { value: '' } });
expect(await view.findByText('Shopping list')).toBeTruthy();
expect(view.getAllByRole('listitem')).toHaveLength(2);
});
it('should rename a conversation inline when Enter is pressed', async () => {
await saveConversation('chat-1', 'Old title');
const view = renderPanel();
await openHistory(view);
const row = (await view.findByText('Old title')).closest('li');
fireEvent.click(within(row).getByRole('button', { name: 'Rename' }));
const input = within(row).getByRole('textbox', { name: 'Conversation title' });
fireEvent.change(input, { target: { value: 'New title' } });
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
expect(await view.findByText('New title')).toBeTruthy();
expect(view.queryByRole('textbox', { name: 'Conversation title' })).toBeNull();
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'New title' });
});
it('should close the history popup when clicking outside of it', async () => {
await saveConversation('chat-1', 'Saved chat');
const view = renderPanel();
await openHistory(view);
await view.findByText('Saved chat');
fireEvent.mouseDown(view.getByRole('searchbox', { name: 'Search conversations' }));
expect(view.getByText('Saved chat')).toBeTruthy();
fireEvent.mouseDown(view.getByRole('textbox', { name: 'Chat message' }));
await waitFor(() => expect(view.queryByText('Saved chat')).toBeNull());
});
it('should delete the selected conversation and its messages', async () => {
await saveConversation('delete-chat', 'Delete this chat');
await saveConversation('keep-chat', 'Keep this chat');
await saveMessage('delete-chat', 'Delete this message');
await saveMessage('keep-chat', 'Keep this message');
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'delete-chat', dispatch });
await openHistory(view);
const row = (await view.findByText('Delete this chat')).closest('li');
await act(async () => { fireEvent.click(within(row).getByRole('button', { name: 'Delete' })); });
await waitFor(() => expect(view.queryByText('Delete this chat')).toBeNull());
expect(view.getByText('Keep this chat')).toBeTruthy();
expect((await ChatConversation.history()).map(conversation => conversation.id)).toEqual(['keep-chat']);
expect(await ChatConversation.messages('keep-chat')).toMatchObject([{ text: 'Keep this message' }]);
await saveConversation('delete-chat', 'Recreated chat');
expect(await ChatConversation.messages('delete-chat')).toEqual([]);
expect(dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_DELETE', conversationId: 'delete-chat' });
});
it('should keep history when New chat opens an empty conversation in the current window', async () => {
await saveConversation('chat-1', 'Current chat');
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
await waitFor(() => expect(dispatch).toHaveBeenCalled());
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Current chat' });
const action = dispatch.mock.calls[0][0];
expect(action).toEqual({ type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [] });
expect(action.conversationId).not.toBe('chat-1');
const state = createAppDefaultState({});
state.aiChatConversationId = 'main-chat';
state.aiChatMessages = [message];
state.backgroundWindows = { second: { ...createAppDefaultWindowState(), windowId: 'second' } };
const opened = appReducer(state, action);
expect(opened.aiChatConversationId).toBe('main-chat');
expect(opened.aiChatMessages).toEqual([message]);
expect(opened.backgroundWindows.second.aiChatConversationId).toBe(action.conversationId);
expect(opened.backgroundWindows.second.aiChatMessages).toEqual([]);
});
it('should not add an empty conversation to history', async () => {
await saveConversation('chat-1', 'Current chat');
const before = await ChatConversation.history();
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
await waitFor(() => expect(dispatch).toHaveBeenCalled());
const after = await ChatConversation.history();
expect(after).toHaveLength(before.length);
});
it('should open the selected history row in the current window', async () => {
await saveConversation('chat-1', 'Current chat');
await saveConversation('chat-2', 'Saved chat');
const execute = jest.spyOn(CommandService.instance(), 'executeInWindow').mockResolvedValue(undefined);
const view = renderPanel({ conversationId: 'chat-1', messages: [message] });
await openHistory(view);
fireEvent.click(await view.findByRole('button', { name: /Saved chat/ }));
await waitFor(() => expect(execute).toHaveBeenCalledWith('openAiChatConversation', { windowId: 'second', args: ['chat-2'] }));
});
it('should save the question and reply with the active note ID and title as they are created', async () => {
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
jest.mocked(runNoteChat).mockImplementation(async (_context, _history, _text, _tools, onHistoryChanged) => {
onHistoryChanged([{ role: ChatRole.System, content: '' }, { role: ChatRole.Assistant, content: 'Reply' }]);
return [];
});
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A' });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
const expected = [
{ role: 'user', text: 'Question', noteId: 'note-a', noteTitle: 'A' },
{ role: 'assistant', text: 'Reply', noteId: 'note-a', noteTitle: 'A' },
];
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject(expected));
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Question' });
const appended = modelDispatch.mock.calls.map(([action]) => action).filter(action => action.type === 'AI_CHAT_APPEND');
expect(appended).toMatchObject(expected.map(message => ({ conversationId: 'chat-1', message })));
});
it('should remove the saved question when the request fails before any reply', async () => {
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
});
it('should assign an ID before messages are added to a new conversation', async () => {
const dispatch = jest.fn();
renderPanel({ dispatch });
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({
type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [],
}));
});
});
+189 -40
View File
@@ -10,7 +10,7 @@ import Logger from '@joplin/utils/Logger';
import { stateUtils } from '@joplin/lib/reducer';
import { AiChatMessage, AppState } from '../../app.reducer';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
import { AvailabilityReason, chatAvailability } from '@joplin/lib/services/ai/availability';
import { WindowIdContext } from '../NewWindowOrIFrame';
import AiDegradedNotice from '../AiDegradedNotice';
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
@@ -19,26 +19,34 @@ import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManag
import { Second } from '@joplin/utils/time';
import ChatMessageItem from './ChatMessageItem';
import NavService from '@joplin/lib/services/NavService';
import ChatConversation from '@joplin/lib/models/ChatConversation';
import uuid from '@joplin/lib/uuid';
import { focus } from '@joplin/lib/utils/focusHandler';
import dialogs from '../dialogs';
import ChatHistory, { Conversation } from './ChatHistory';
import Button, { ButtonLevel } from '../Button/Button';
const logger = Logger.create('ChatPanel');
interface Props {
themeId: number;
available: boolean;
availabilityReason?: AvailabilityReason;
unavailableHint: string;
providerType: string;
noteId: string | null;
noteTitle: string;
noteIsEncrypted: boolean;
messages: AiChatMessage[];
conversationId?: string|null;
aiDegraded: boolean;
showToolbarButton: boolean;
dispatch: Dispatch;
}
const disclosureSetting = 'ai.chat.disclosureAcknowledged';
let nextMessageId = 0;
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
const makeId = () => uuid.create();
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
return new Promise<void>((resolve) => {
@@ -105,7 +113,40 @@ const useHasFocus = () => {
const ChatPanel: React.FC<Props> = (props) => {
const { dispatch, messages } = props;
const [input, setInput] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const historyId = useId();
const [conversations, setConversations] = useState<Conversation[]>([]);
const loadConversationHistory = useCallback(async (search = '') => {
try {
setConversations(await ChatConversation.history(search));
} catch (error) {
logger.error('Could not load chat conversations', error);
}
}, []);
const handleHistoryToggle = useCallback(async () => {
setHistoryOpen(!historyOpen);
if (historyOpen) return;
await loadConversationHistory();
}, [historyOpen, loadConversationHistory]);
const historyPopupRef = useRef<HTMLDivElement>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null);
const closeHistory = useCallback(() => {
setHistoryOpen(false);
focus('ChatPanel::closeHistory', historyButtonRef.current);
}, []);
useEffect(() => {
if (!historyOpen) return () => {};
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (historyPopupRef.current?.contains(target) || historyButtonRef.current?.contains(target)) return;
setHistoryOpen(false);
};
const doc = historyButtonRef.current.ownerDocument;
doc.addEventListener('mousedown', onMouseDown);
return () => doc.removeEventListener('mousedown', onMouseDown);
}, [historyOpen]);
const [sending, setSending] = useState(false);
const archivingRef = useRef(false);
const [disclosureShown, setDisclosureShown] = useState<boolean>(() => {
try {
return !!Setting.value(disclosureSetting);
@@ -128,13 +169,35 @@ const ChatPanel: React.FC<Props> = (props) => {
const windowId = useContext(WindowIdContext);
const appendMessage = useCallback((message: AiChatMessage) => {
dispatch({ type: 'AI_CHAT_APPEND', windowId, message });
}, [dispatch, windowId]);
const appendMessage = useCallback(async (message: Omit<AiChatMessage, 'noteId' | 'noteTitle' | 'createdTime'>) => {
const fullMessage: AiChatMessage = { ...message, createdTime: Date.now(), noteId: props.noteId ?? '', noteTitle: props.noteId ? props.noteTitle : '' };
try {
await ChatConversation.addMessage(props.conversationId, fullMessage);
} catch (error) {
logger.error('Could not save chat message:', error);
}
}, [props.noteId, props.noteTitle, props.conversationId]);
const addToolResult = useCallback((result: ChatToolMessage) => {
dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', windowId, toolCall: result });
}, [dispatch, windowId]);
const removeMessage = useCallback(async (id: string) => {
try {
await ChatConversation.removeMessage(props.conversationId, id);
} catch (error) {
logger.error('Could not remove chat message:', error);
}
}, [props.conversationId]);
const addToolResult = useCallback(async (result: ChatToolMessage) => {
try {
await ChatConversation.addToolResult(props.conversationId, result);
} catch (error) {
logger.error('Could not save chat tool result:', error);
}
}, [props.conversationId]);
useEffect(() => {
if (props.conversationId) return;
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages });
}, [props.conversationId, dispatch, windowId, messages]);
// Drop a separator when the active note changes mid-conversation. Skip
// the first ever opened note (no prior context to separate from).
@@ -145,7 +208,7 @@ const ChatPanel: React.FC<Props> = (props) => {
if (messagesLengthRef.current === 0) return;
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
appendMessage({
void appendMessage({
id: makeId(),
role: 'separator',
text,
@@ -170,14 +233,14 @@ const ChatPanel: React.FC<Props> = (props) => {
const handleCancel = useCallback(() => {
cancelRequest();
appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
void appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
}, [cancelRequest, appendMessage]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
if (!props.noteId) {
appendMessage({
void appendMessage({
id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [],
});
return;
@@ -192,7 +255,7 @@ const ChatPanel: React.FC<Props> = (props) => {
// send the prior user turn as history alongside the new prompt.
const userTurnId = makeId();
let hadVisibleResponse = false;
appendMessage({
void appendMessage({
id: userTurnId,
role: 'user',
text,
@@ -201,6 +264,7 @@ const ChatPanel: React.FC<Props> = (props) => {
try {
const note = await Note.load(props.noteId);
if (abortController.signal.aborted) return;
if (!note) throw new Error(`Note not found: ${props.noteId}`);
const getContext = async () => {
@@ -242,11 +306,11 @@ const ChatPanel: React.FC<Props> = (props) => {
if (entry.role === ChatRole.System) continue;
if (entry.role === ChatRole.Tool) {
addToolResult(entry);
void addToolResult(entry);
} else {
hadVisibleResponse ||= !entry.hide;
appendMessage({
void appendMessage({
id: makeId(),
role: entry.role,
text: entry.content,
@@ -293,7 +357,7 @@ const ChatPanel: React.FC<Props> = (props) => {
await changeListener;
},
displayError: (message) => {
appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
void appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
},
}, onHistoryChanged, abortController.signal,
);
@@ -302,30 +366,77 @@ const ChatPanel: React.FC<Props> = (props) => {
if (abortController.signal.aborted) return;
if (!hadVisibleResponse) {
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
void removeMessage(userTurnId);
setInput(text);
}
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
void appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
} finally {
setSending(false);
}
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, dispatch, cancelRequest, abortControllerRef]);
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, removeMessage, cancelRequest, abortControllerRef]);
const handleAcknowledgeDisclosure = useCallback(() => {
Setting.setValue(disclosureSetting, true);
setDisclosureShown(true);
}, []);
const handleReset = useCallback(() => {
const handleNewChat = useCallback(() => {
if (archivingRef.current) return;
cancelRequest();
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
// Only saved once a message is sent so empty chats don't show up in history
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages: [] });
}, [dispatch, windowId, cancelRequest]);
const handleOpenConversation = useCallback(async (conversationId: string) => {
if (conversationId === props.conversationId || archivingRef.current) return;
archivingRef.current = true;
cancelRequest();
try {
await CommandService.instance().executeInWindow('openAiChatConversation', { windowId, args: [conversationId] });
} catch (error) {
logger.error('Could not open chat conversation:', error);
} finally {
archivingRef.current = false;
}
}, [windowId, cancelRequest, props.conversationId]);
const handleClose = useCallback(() => {
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
}, [windowId]);
const handleDeleteConversation = useCallback(async (conversationId: string) => {
if (archivingRef.current) return;
archivingRef.current = true;
if (conversationId === props.conversationId) cancelRequest();
try {
await ChatConversation.deleteConversation(conversationId);
setConversations(current => current.filter(item => item.id !== conversationId));
dispatch({ type: 'AI_CHAT_DELETE', conversationId });
} catch (error) {
logger.error('Could not delete chat conversation:', error);
await dialogs.alert(_('Could not delete conversation.'));
} finally {
archivingRef.current = false;
}
}, [dispatch, cancelRequest, props.conversationId]);
const handleRenameConversation = useCallback(async (conversation: Conversation, newTitle: string) => {
const title = newTitle.trim();
if (!title || title === conversation.title) return;
try {
await ChatConversation.renameConversation(conversation.id, title);
setConversations(current => current.map(item => item.id === conversation.id ? { ...item, title } : item));
} catch (error) {
logger.error('Could not rename chat conversation:', error);
await dialogs.alert(_('Could not rename conversation.'));
}
}, []);
const handleHideToolbarButton = useCallback(() => {
Setting.setValue('ai.chat.showToolbarButton', false);
handleClose();
}, [handleClose]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Don't send while an IME composition is in flight — Enter commits
// the composition for CJK / accented input.
@@ -339,7 +450,16 @@ const ChatPanel: React.FC<Props> = (props) => {
if (!props.available) {
const content = (
<div className='disabled-message'>
{props.unavailableHint}
<div className='hint'>{props.unavailableHint}</div>
{props.showToolbarButton && <>
<div className='hint'>{_('If you do not want to use the AI features, you may hide the toolbar button:')}</div>
<Button
level={ButtonLevel.Secondary}
title={_('Hide the AI Chat button')}
onClick={handleHideToolbarButton}
/>
<div className='hint'>{_('(You can show it again from Settings → AI)')}</div>
</>}
</div>
);
return { content, showingMessages: false };
@@ -408,24 +528,11 @@ const ChatPanel: React.FC<Props> = (props) => {
};
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
const closeLabel = _('Close');
const newChatLabel = _('New chat');
const headerId = useId();
const { content, showingMessages } = renderContent();
const renderHeaderActions = () => {
if (showingMessages) {
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
}
const closeLabel = _('Close');
return <button
type='button'
className='close'
onClick={handleClose}
title={closeLabel}
aria-label={closeLabel}
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
};
return (
<div
className='chat-panel'
@@ -436,7 +543,44 @@ const ChatPanel: React.FC<Props> = (props) => {
>
<div className='header chat-panel-header'>
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
{renderHeaderActions()}
{props.availabilityReason !== AvailabilityReason.Disabled && (
<button
type='button'
className='history toolbar-button'
ref={historyButtonRef}
onClick={handleHistoryToggle}
title={_('Chat history')}
aria-label={_('Chat history')}
aria-expanded={historyOpen}
aria-controls={historyId}
>
<i className='toolbar-icon far fa-clock' aria-hidden='true'/>
</button>
)}
{historyOpen && (
// Placed right after its button so that Tab moves from the button into the popup
<ChatHistory
conversations={conversations}
currentConversationId={props.conversationId}
id={historyId}
popupRef={historyPopupRef}
onSearchChange={loadConversationHistory}
onOpen={handleOpenConversation}
onRename={handleRenameConversation}
onDelete={handleDeleteConversation}
onClose={closeHistory}
/>
)}
<button type='button' className='reset toolbar-button' onClick={handleNewChat} disabled={!showingMessages} title={newChatLabel} aria-label={newChatLabel}>
<i className='toolbar-icon fas fa-comment-medical' aria-hidden='true'/>
</button>
<button
type='button'
className='close toolbar-button'
onClick={handleClose}
title={closeLabel}
aria-label={closeLabel}
><i className='toolbar-icon fas fa-times' role='img' aria-hidden={true}/></button>
</div>
{content}
</div>
@@ -455,14 +599,19 @@ const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
return {
themeId: state.settings.theme,
available: availability.available,
availabilityReason: availability.reason,
unavailableHint: availability.hint ?? '',
providerType: state.settings['ai.chat.providerType'] || 'openai-compatible',
noteId,
noteTitle: note?.title || '',
noteIsEncrypted: !!note?.encryption_applied,
messages: windowState.aiChatMessages || [],
conversationId: windowState.aiChatConversationId,
aiDegraded: !!state.aiStatus?.degraded,
showToolbarButton: !!state.settings['ai.chat.showToolbarButton'],
};
};
export default connect(mapStateToProps)(ChatPanel);
const ConversationPanel: React.FC<Props> = props => <ChatPanel key={props.conversationId} {...props} />;
export default connect(mapStateToProps)(ConversationPanel);
+10 -7
View File
@@ -1,8 +1,11 @@
@use './ChatHistory.scss';
.chat-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
background-color: var(--joplin-background-color);
color: var(--joplin-color);
@@ -11,6 +14,7 @@
}
.chat-panel > .header {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
@@ -26,15 +30,14 @@
margin: 0;
}
> .reset, > .close {
> .reset, > .close, > .history {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
&:hover {
&:hover:not(:disabled) {
color: var(--joplin-color);
}
}
@@ -215,10 +218,10 @@
.chat-panel > .disabled-message {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px;
color: var(--joplin-color-faded);
line-height: 1.5;
}
@@ -12,9 +12,8 @@ jest.mock('../../services/bridge', () => ({
}));
const setSelectedNoteId = (noteId: string | null) => {
// executeCallbackUrl reads the selected note id via commandUtils.store; a
// null id makes handleGetCurrentNote take its x-error branch, which routes
// the caller-supplied target through respond() without touching the DB.
// A null id makes handleGetCurrentNote take its x-error branch, which reaches
// respond() without touching the database.
commandUtils.store = {
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
};
@@ -27,22 +26,34 @@ describe('handleCallbackUrl', () => {
});
test.each([
'x-custom-scheme://arbitrary-scheme-reached',
'ms-msdt:/id PCWDiagnostic',
'search-ms:query=secret',
'shell:startup',
'file:///etc/passwd',
'ms-msdt://something',
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'mailto:someone@example.com',
'http://example.com/cb',
'https://example.com/cb',
'unknown-app://attacker.example.com/cb',
'unknown-app:///hostless',
'not a url',
])('should not dispatch a callback target with a disallowed scheme (%s)', async (target) => {
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with disallowed scheme/, async () => {
])('should not dispatch an unrecognised callback target (%s)', async (target) => {
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with host/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
}, { requireWarning: true });
expect(mockOpenExternal).not.toHaveBeenCalled();
});
test.each([
'http://example.com/cb',
'https://example.com/cb',
])('should dispatch a callback target with an allowed scheme (%s)', async (target) => {
'hook://x-callback-url/setCurrentNode',
'drafts://x-callback-url/create',
'bear://x-callback-url/open-note',
'ulysses://x-callback-url/new-sheet',
'new-app://x-callback-url/cb',
'editorial://workflow-callback',
'things:///add',
])('should dispatch a recognised callback target (%s)', async (target) => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(mockOpenExternal.mock.calls[0][0]).toContain(`${target}?`);
@@ -55,7 +66,7 @@ describe('handleCallbackUrl', () => {
};
await withWarningSilenced(/Error handling callback URL command "getCurrentNote":.*secret path \/home\/victim/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('https://example.com/cb')}`);
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('hook://x-callback-url/cb')}`);
}, { requireWarning: true });
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
@@ -9,25 +9,34 @@ import Logger from '@joplin/utils/Logger';
const logger = Logger.create('handleCallbackUrl');
// Opens the caller's x-success / x-error URL with the result appended as query params.
// Schemes of known apps, which are allowed whatever their callback format. Add
// any legitimate app here - it's preferred over relying on the host check below.
// From https://x-callback-url.com/apps/ and https://app-talk.com/
const knownCallbackProtocols = [
'agenda:', 'airmail:', 'bear:', 'beorg:', 'byword:', 'calca:', 'copied:',
'dayone:', 'devonthink:', 'drafts:', 'drafts5:', 'due:', 'editorial:',
'fantastical2:', 'gladys:', 'hook:', 'instapaper:', 'launcher:', 'omnifocus:',
'omnifocus3:', 'onewriter:', 'opener:', 'outlinely:', 'overcast:', 'prizmo:',
'pyto:', 'scriptable:', 'shortcutsiosopen:', 'terminology:', 'textastic:',
'things:', 'timepage:', 'todoist:', 'trello:', 'twodo:', 'ulysses:',
'working-copy:', 'x-devonthink:',
];
const respond = (target: string, params: Record<string, string> = {}) => {
if (!target) return;
// The callback target is untrusted input from the x-callback-url. Anything
// that is not file: is handed straight to shell.openExternal(), i.e. the OS
// URI dispatcher, so restrict it to schemes we're willing to invoke. This
// also covers the x-error path below, which fires on any thrown exception.
let protocol;
// A joplin:// URL can be triggered from a web page and the target is passed to
// the OS URI dispatcher, so it must not reach an arbitrary handler.
let url;
try {
protocol = new URL(target).protocol;
url = new URL(target);
} catch (error) {
// Don't log the caller-supplied target: it's untrusted and may contain
// secrets in its query or fragment.
// The target is untrusted and may contain secrets, so don't log it.
logger.warn('Rejected malformed callback target');
return;
}
if (protocol !== 'http:' && protocol !== 'https:') {
logger.warn(`Rejected callback target with disallowed scheme "${protocol}"`);
if (url.host !== 'x-callback-url' && !knownCallbackProtocols.includes(url.protocol)) {
logger.warn(`Rejected callback target with host "${url.host}" and scheme "${url.protocol}"`);
return;
}
+5 -1
View File
@@ -32,6 +32,7 @@ import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
import { FileSystemItem } from '@joplin/lib/services/interop/types';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
const logger = Logger.create('MenuBar');
@@ -185,6 +186,7 @@ interface Props {
secondaryWindowFocused: boolean;
showMenuBar: boolean;
syncPending: boolean;
showAiChatMenuItem: boolean;
}
const commandNames: string[] = menuCommandNames();
@@ -755,7 +757,7 @@ function useMenu(props: Props) {
menuItemDic.toggleSideBar,
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
menuItemDic.toggleNoteList,
menuItemDic.toggleAiChat,
{ ...menuItemDic.toggleAiChat, visible: props.showAiChatMenuItem },
menuItemDic.toggleVisiblePanes,
menuItemDic.toggleEditorPlugin,
menuItemDic.toggleEditors,
@@ -1071,6 +1073,7 @@ function useMenu(props: Props) {
switchProfileMenuItems,
menuItemDic,
props.syncPending,
props.showAiChatMenuItem,
]);
useMenuStates(menu, props);
@@ -1158,6 +1161,7 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
noteListRendererId: state.settings['notes.listRendererId'],
showMenuBar: state.settings.showMenuBar,
syncPending: state.syncPending,
showAiChatMenuItem: chatAvailability().available || !!state.settings['ai.chat.showToolbarButton'],
};
};
@@ -405,10 +405,15 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
);
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const effectAllowed = e.dataTransfer.effectAllowed;
const canLink = effectAllowed.toLowerCase().includes('link') || ['all', 'uninitialized'].includes(effectAllowed);
const types = Array.from(e.dataTransfer.types);
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
// Workaround: On Linux, the 'link' drag effect prevents dropping items into the editor.
// See https://github.com/laurent22/joplin/issues/16457.
e.dataTransfer.dropEffect = canLink ? 'link' : 'copy';
} else if (types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
@@ -10,6 +10,7 @@ import { _ } from '@joplin/lib/locale';
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState, AppWindowState } from '../../app.reducer';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
interface NoteToolbarProps {
themeId: number;
@@ -59,10 +60,13 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
'editAlarm',
'toggleVisiblePanes',
'showNoteProperties',
// Always shown — the panel itself surfaces any configuration issue.
'toggleAiChat',
];
// The hide setting only applies while chat is unavailable.
if (chatAvailability().available || state.settings['ai.chat.showToolbarButton']) {
commands.push('toggleAiChat');
}
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
// toggle the core whiteboard editor on whiteboard notes (see the command's
// runtime). The button is the same eye icon either way.
@@ -10,6 +10,7 @@ import Setting from '@joplin/lib/models/Setting';
import { PackageInfo } from '@joplin/lib/versionInfo';
import shim from '@joplin/lib/shim';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import { isRecoverableError } from '@joplin/lib/import-enex';
import Logger from '@joplin/utils/Logger';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
@@ -136,7 +137,9 @@ export const runtime = (control: WindowControl): CommandRuntime => {
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
onError: (error: string|Error) => {
errors.push(error);
// Recovered errors are logged but not reported, since the
// notes were imported successfully anyway.
if (!isRecoverableError(error)) errors.push(error);
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
@@ -21,6 +21,7 @@ import * as newNote from './newNote';
import * as newSubFolder from './newSubFolder';
import * as newTodo from './newTodo';
import * as newWhiteboard from './newWhiteboard';
import * as openAiChatConversation from './openAiChatConversation';
import * as openFolder from './openFolder';
import * as openFolderDialog from './openFolderDialog';
import * as openItem from './openItem';
@@ -81,6 +82,7 @@ const index: any[] = [
newSubFolder,
newTodo,
newWhiteboard,
openAiChatConversation,
openFolder,
openFolderDialog,
openItem,
@@ -0,0 +1,17 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import ChatConversation from '@joplin/lib/models/ChatConversation';
export const declaration: CommandDeclaration = {
name: 'openAiChatConversation',
};
export const runtime = (): CommandRuntime => ({
execute: async (context: CommandContext, conversationId: string) => {
const messages = await ChatConversation.messages(conversationId);
context.dispatch({ type: 'AI_CHAT_OPEN', windowId: context.state.windowId, conversationId, messages });
context.dispatch({
type: 'WINDOW_LAYOUT_SET_ITEM_PROP', windowId: context.state.windowId,
itemKey: 'chatPanel', propName: 'visible', propValue: true,
});
},
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.7.18",
"version": "3.7.19",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -139,7 +139,7 @@
"entry": {
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;",
"StartupWMClass": "@joplin/app-desktop"
"StartupWMClass": "appimagekit-joplin"
}
},
"target": [
+2 -2
View File
@@ -83,8 +83,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097819
versionName "3.7.10"
versionCode 2097820
versionName "3.7.11"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -606,7 +606,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.7.5;
MARKETING_VERSION = 13.7.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -640,7 +640,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.7.5;
MARKETING_VERSION = 13.7.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -670,7 +670,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
FORCE_BUNDLING = YES;
@@ -709,7 +709,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
FORCE_BUNDLING = YES;
@@ -910,7 +910,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 165;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -921,7 +921,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.7.5;
MARKETING_VERSION = 13.7.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -953,7 +953,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 164;
CURRENT_PROJECT_VERSION = 165;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -964,7 +964,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.7.5;
MARKETING_VERSION = 13.7.6;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
+2 -2
View File
@@ -1536,7 +1536,7 @@ PODS:
- React-Core
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.7.1):
- react-native-saf-x (3.7.2):
- React-Core
- react-native-safe-area-context (5.7.0):
- hermes-engine
@@ -2623,7 +2623,7 @@ SPEC CHECKSUMS:
react-native-netinfo: 7e0b1936e928fa8ddb60da9e572a33767db7805f
react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 0e7b108f24b0e3e7b10ad14008b32b70f57d227f
react-native-saf-x: b2b8b9374ad6759bf7bfdb8f7af0c40f08d94009
react-native-safe-area-context: ae7587b95fb580d1800c5b0b2a7bd48c2868e67a
react-native-vector-icons: 8215b0e62bf79d3be628cf658ec23777f9bec53c
react-native-vector-icons-fontawesome5: 787102f6050e011721d7368d990532debd19bc08
@@ -39,6 +39,13 @@ describe('renderTables', () => {
{ input: 'a **b** c', expected: 'a b c', inner: 'a <strong>b</strong> c' },
// Escaped pipes are unescaped for display.
{ input: 'a \\| b', expected: 'a | b', inner: 'a | b' },
// Nested markup renders both layers.
{ input: '**[label](https://example.com)**', expected: 'label', inner: '<strong><a href="https://example.com">label</a></strong>' },
{ input: '[**label**](https://example.com)', expected: 'label', inner: '<a href="https://example.com"><strong>label</strong></a>' },
{ input: '*[label](https://example.com)*', expected: 'label', inner: '<em><a href="https://example.com">label</a></em>' },
{ input: '**~~strike~~**', expected: 'strike', inner: '<strong><del>strike</del></strong>' },
// Code spans stay literal.
{ input: '`**not bold**`', expected: '**not bold**', inner: '<code>**not bold**</code>' },
])('renderInlineMarkdown should render $input', ({ input, expected, inner }) => {
const div = document.createElement('div');
renderInlineMarkdown(div, input);
@@ -202,4 +209,63 @@ describe('renderTables', () => {
}
});
test.each([
'**[label](https://example.com)**',
'**bold**',
'*italic*',
'`code`',
])('focus/blur cycles should not degrade cell markdown: %s', async (cellSource) => {
jest.useFakeTimers();
let editor: EditorView | null = null;
try {
const markdown = `| a | b |\n|---|---|\n| ${cellSource} | y |`;
editor = await createEditor(markdown);
document.body.appendChild(editor.dom);
// Each cycle re-renders the cell without editing it.
for (let i = 0; i < 3; i++) {
const cell = findCellTextDivs(editor)[2];
focusCell(cell);
cell.dispatchEvent(new Event('blur'));
jest.advanceTimersByTime(200);
jest.runOnlyPendingTimers();
const rendered = findCellTextDivs(editor)[2];
expect(rendered.classList.contains('cm-tw-raw')).toBe(false);
}
expect(editor.state.doc.toString()).toContain(cellSource);
} finally {
editor?.destroy();
jest.useRealTimers();
}
});
test('a blurred cell should not write its rendered text back to the document', async () => {
jest.useFakeTimers();
let editor: EditorView | null = null;
try {
editor = await createEditor('| a | b |\n|---|---|\n| **[x](http://e.com)** | y |');
document.body.appendChild(editor.dom);
const cell = findCellTextDivs(editor)[2];
focusCell(cell);
cell.dispatchEvent(new Event('blur'));
jest.advanceTimersByTime(200);
jest.runOnlyPendingTimers();
// An input event on the now-rendered cell, as a stray mutation
// would fire, must not harvest its stripped text.
const rendered = findCellTextDivs(editor)[2];
rendered.dispatchEvent(new Event('input'));
jest.advanceTimersByTime(700);
jest.runOnlyPendingTimers();
expect(editor.state.doc.toString()).toContain('**[x](http://e.com)**');
} finally {
editor?.destroy();
jest.useRealTimers();
}
});
});
@@ -27,6 +27,8 @@ const W = 'cm-tw';
const CELL = 'cm-tw-c';
const HDR = 'cm-tw-h';
const CTX = 'cm-tw-ctx';
// Marks a cell holding raw source. Only these may be read back into the model.
const RAW = 'cm-tw-raw';
// Cache for rendered table widget heights so CodeMirror can estimate
// heights correctly for scroll position and coordinate mapping.
@@ -59,45 +61,54 @@ const escapeHtml = (s: string): string => {
// shown as plain |. The assembled HTML is run through DOMPurify before
// insertion, so unsafe URL schemes (javascript:, data:, ...) and any tags
// or attributes that slipped through the regex are removed.
// Wrapper contents recurse so nested markup works (**[label](url)** is a bold
// link). Code spans do not: their contents are literal in markdown.
const inlineMarkdownToHtml = (segment: string): string => {
// Single regex with alternatives, scanned left-to-right. Each branch
// captures its inner content. Single * and _ emphasis use word-
// boundary guards so identifiers like `foo_bar_baz` or `a*b*c` are
// not rendered as emphasis.
const re = /\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|(?<![A-Za-z0-9])\*([^*]+)\*(?![A-Za-z0-9])|(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])|`([^`]+)`|~~([\s\S]+?)~~|\[([^\]]*)\]\(([^)\s]+)\)/g;
const parts: string[] = [];
let lastIdx = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(segment)) !== null) {
if (m.index > lastIdx) {
parts.push(escapeHtml(segment.slice(lastIdx, m.index)));
}
if (m[1] !== undefined || m[2] !== undefined) {
parts.push(`<strong>${inlineMarkdownToHtml((m[1] ?? m[2])!)}</strong>`);
} else if (m[3] !== undefined || m[4] !== undefined) {
parts.push(`<em>${inlineMarkdownToHtml((m[3] ?? m[4])!)}</em>`);
} else if (m[5] !== undefined) {
parts.push(`<code>${escapeHtml(m[5])}</code>`);
} else if (m[6] !== undefined) {
parts.push(`<del>${inlineMarkdownToHtml(m[6])}</del>`);
} else {
parts.push(`<a href="${escapeHtml(m[8]!)}">${inlineMarkdownToHtml(m[7]!)}</a>`);
}
lastIdx = m.index + m[0].length;
}
if (lastIdx < segment.length) {
parts.push(escapeHtml(segment.slice(lastIdx)));
}
return parts.join('');
};
export const renderInlineMarkdown = (parent: HTMLElement, text: string) => {
// Normalise: escaped pipes → |, and split on literal <br> for soft breaks.
const normalised = text.replace(/\\\|/g, '|');
const segments = normalised.split(/<br\s*\/?>/i);
const parts: string[] = [];
for (let s = 0; s < segments.length; s++) {
if (s > 0) parts.push('<br>');
const segment = segments[s];
// Single regex with alternatives, scanned left-to-right. Each branch
// captures its inner content. Single * and _ emphasis use word-
// boundary guards so identifiers like `foo_bar_baz` or `a*b*c` are
// not rendered as emphasis.
const re = /\*\*([^*]+)\*\*|__([^_]+)__|(?<![A-Za-z0-9])\*([^*]+)\*(?![A-Za-z0-9])|(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])|`([^`]+)`|~~([^~]+)~~|\[([^\]]+)\]\(([^)\s]+)\)/g;
let lastIdx = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(segment)) !== null) {
if (m.index > lastIdx) {
parts.push(escapeHtml(segment.slice(lastIdx, m.index)));
}
if (m[1] !== undefined || m[2] !== undefined) {
parts.push(`<strong>${escapeHtml((m[1] ?? m[2])!)}</strong>`);
} else if (m[3] !== undefined || m[4] !== undefined) {
parts.push(`<em>${escapeHtml((m[3] ?? m[4])!)}</em>`);
} else if (m[5] !== undefined) {
parts.push(`<code>${escapeHtml(m[5])}</code>`);
} else if (m[6] !== undefined) {
parts.push(`<del>${escapeHtml(m[6])}</del>`);
} else {
parts.push(`<a href="${escapeHtml(m[8]!)}">${escapeHtml(m[7]!)}</a>`);
}
lastIdx = m.index + m[0].length;
}
if (lastIdx < segment.length) {
parts.push(escapeHtml(segment.slice(lastIdx)));
}
}
parent.innerHTML = sanitizeHtml(parts.join(''));
const html = normalised
.split(/<br\s*\/?>/i)
.map(inlineMarkdownToHtml)
.join('<br>');
parent.innerHTML = sanitizeHtml(html);
};
// Stashed on the container so destroy() can reach toDOM()'s closure state.
const teardownKey = Symbol('tableWidgetTeardown');
type TableWidgetContainer = HTMLElement & { [teardownKey]?: ()=> void };
class TableWidget extends WidgetType {
public constructor(
private tableText: string,
@@ -202,6 +213,9 @@ class TableWidget extends WidgetType {
let scrollbarDragging = false;
let lastFocusedTextDiv: HTMLElement | null = null;
// Disconnected on destroy so a detached DOM cannot still schedule syncs.
const cellObservers: MutationObserver[] = [];
// Debounced dispatch so the document source stays in sync with cell
// edits — important so the preview pane reflects in-cell changes
// (e.g. deleting an image) without waiting for blur or a structural
@@ -214,19 +228,14 @@ class TableWidget extends WidgetType {
}
};
// Sync the focused cell's current text into the table model. Other
// cells are kept in sync continuously via their oninput handler, so
// this is just a final read of whichever cell is being edited right
// now. Reading textContent of an unfocused cell would be wrong —
// rendered cells have stripped markdown markers (** etc).
// Sync the raw-mode cell's text into the model. Reading a rendered cell
// would drop one layer of markup per round-trip, since its textContent
// has the markers stripped (#16498).
const syncDirtyCells = () => {
const active = doc.activeElement as HTMLElement | null;
if (!active || !active.classList.contains('cm-tw-text')) return;
if (!container.contains(active)) return;
for (let ri = 0; ri < allCells.length; ri++) {
for (let ci = 0; ci < allCells[ri].length; ci++) {
const td = allCells[ri][ci].querySelector('.cm-tw-text') as HTMLElement;
if (td !== active) continue;
if (!td || !td.classList.contains(RAW)) continue;
const v = (td.textContent || '').trim().replace(/\n/g, '<br>').replace(/\|/g, '\\|');
const isH = ri === 0;
if (isH) table.header.cells[ci].content = v;
@@ -250,6 +259,37 @@ class TableWidget extends WidgetType {
// swap to the raw source so the user edits the markdown text.
renderInlineMarkdown(textDiv, text);
// Track IME composition so we don't rebuild the cell DOM
// mid-composition — rebuilding would cancel the IME and drop
// any in-progress candidates.
let isComposing = false;
// Some browsers do not fire `input` reliably when non-text nodes
// (e.g. <img>) are removed via Backspace inside contentEditable.
// A MutationObserver catches DOM-level changes that `input` misses.
const mo = new win.MutationObserver(() => {
if (isComposing) return;
if (textDiv.classList.contains(RAW)) scheduleLiveSync();
});
mo.observe(textDiv, { childList: true, characterData: true, subtree: true });
cellObservers.push(mo);
// Observer is suspended while swapping: the render is itself a
// childList mutation, which would otherwise schedule a sync.
const showRendered = (src: string) => {
mo.disconnect();
textDiv.classList.remove(RAW);
renderInlineMarkdown(textDiv, src);
mo.observe(textDiv, { childList: true, characterData: true, subtree: true });
};
const showRaw = (src: string) => {
mo.disconnect();
textDiv.textContent = src.replace(/\\\|/g, '|');
textDiv.classList.add(RAW);
mo.observe(textDiv, { childList: true, characterData: true, subtree: true });
};
// Push this cell's current edit-mode text into the table model.
// Called on every input so the model stays in sync even if a
// rebuild is triggered by an external event (image paste, toolbar
@@ -257,6 +297,7 @@ class TableWidget extends WidgetType {
// Not trimmed: an edge space the user just typed is real content
// and must stay visible while editing (see #15918).
const pushToModel = () => {
if (!textDiv.classList.contains(RAW)) return;
const v = (textDiv.textContent || '')
.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
if (isHdr) table.header.cells[c].content = v;
@@ -277,6 +318,7 @@ class TableWidget extends WidgetType {
};
const scheduleLiveSync = () => {
if (!textDiv.classList.contains(RAW)) return;
pushToModel();
cancelLiveSync();
const offset = caretOffset();
@@ -330,10 +372,6 @@ class TableWidget extends WidgetType {
}, 500);
};
// Track IME composition so we don't rebuild the cell DOM
// mid-composition — rebuilding would cancel the IME and drop
// any in-progress candidates.
let isComposing = false;
textDiv.addEventListener('compositionstart', () => {
isComposing = true;
cancelLiveSync();
@@ -347,14 +385,6 @@ class TableWidget extends WidgetType {
if (isComposing) return;
scheduleLiveSync();
};
// Some browsers do not fire `input` reliably when non-text nodes
// (e.g. <img>) are removed via Backspace inside contentEditable.
// A MutationObserver catches DOM-level changes that `input` misses.
const mo = new win.MutationObserver(() => {
if (isComposing) return;
if (doc.activeElement === textDiv) scheduleLiveSync();
});
mo.observe(textDiv, { childList: true, characterData: true, subtree: true });
// Sync CM cursor to this cell so toolbar commands work, and
// swap the rendered DOM for the raw markdown source for editing.
@@ -367,7 +397,7 @@ class TableWidget extends WidgetType {
const src = isHdr
? table.header.cells[c]?.content ?? ''
: table.body[r - 1]?.cells[c]?.content ?? '';
textDiv.textContent = src.replace(/\\\|/g, '|');
showRaw(src);
// Place caret at end so typing appends (matches prior behaviour
// where cells started empty of selection).
const sel = doc.defaultView!.getSelection();
@@ -401,6 +431,9 @@ class TableWidget extends WidgetType {
// context menu action), the old container is detached.
// Do nothing — the rebuild already has the latest data.
if (!container.isConnected) return;
// Already re-rendered (second blur, or a race with this
// timer) — its textContent is no longer source.
if (!textDiv.classList.contains(RAW)) return;
const v = (textDiv.textContent || '').trim();
const orig = isHdr
? table.header.cells[c]?.content
@@ -426,8 +459,7 @@ class TableWidget extends WidgetType {
const src = isHdr
? table.header.cells[c]?.content ?? ''
: table.body[r - 1]?.cells[c]?.content ?? '';
textDiv.textContent = '';
renderInlineMarkdown(textDiv, src);
showRendered(src);
}, 80);
};
@@ -861,9 +893,21 @@ class TableWidget extends WidgetType {
container.classList.toggle('cm-tw-mod-link', overLink);
});
// Runs on every rebuild and on teardown. No dispatch here: destroy()
// runs inside CodeMirror's update cycle, where it is not allowed.
(container as TableWidgetContainer)[teardownKey] = () => {
for (const observer of cellObservers) observer.disconnect();
cellObservers.length = 0;
cancelLiveSync();
};
return container;
}
public destroy(dom: HTMLElement) {
(dom as TableWidgetContainer)[teardownKey]?.();
}
public ignoreEvent() { return true; }
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@joplin/fork-htmlparser2",
"description": "Fast & forgiving HTML/XML/RSS parser",
"version": "4.1.64",
"version": "4.1.66",
"author": "Felix Boehm <me@feedic.com>",
"publishConfig": {
"access": "public"
@@ -64,5 +64,5 @@
"prettier": {
"tabWidth": 4
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@joplin/fork-sax",
"description": "An evented streaming XML parser in JavaScript",
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
"version": "1.2.68",
"version": "1.2.70",
"main": "lib/sax.js",
"publishConfig": {
"access": "public"
@@ -18,5 +18,5 @@
"standard": "17.1.2",
"tap": "16.3.10"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/fork-uslug",
"version": "2.0.7",
"version": "2.0.9",
"description": "A permissive slug generator that works with unicode.",
"author": "Jeremy Selier <jerem.selier@gmail.com>",
"publishConfig": {
@@ -46,5 +46,5 @@
"url": "http://github.com/jeremys/uslug/raw/master/LICENSE"
}
],
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/htmlpack",
"version": "3.7.1",
"version": "3.7.3",
"description": "Pack an HTML file and all its linked resources into a single HTML file",
"main": "dist/index.js",
"types": "index.ts",
@@ -30,7 +30,7 @@
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "4.5.0",
"@joplin/fork-htmlparser2": "^4.1.64",
"@joplin/fork-htmlparser2": "^4.1.66",
"datauri": "4.1.0",
"fs-extra": "11.3.5",
"html-entities": "1.4.0"
@@ -41,5 +41,5 @@
"jest": "29.7.0",
"typescript": "5.9.3"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+6
View File
@@ -28,6 +28,8 @@ export enum ModelType {
Command = 16,
NoteEmbedding = 17,
ConflictNoteState = 18,
ChatConversation = 19,
ChatMessage = 20,
}
export interface SearchOptions {
@@ -90,6 +92,8 @@ class BaseModel {
['TYPE_COMMAND', ModelType.Command],
['TYPE_NOTE_EMBEDDING', ModelType.NoteEmbedding],
['TYPE_CONFLICT_NOTE_STATE', ModelType.ConflictNoteState],
['TYPE_CHAT_CONVERSATION', ModelType.ChatConversation],
['TYPE_CHAT_MESSAGE', ModelType.ChatMessage],
];
private static uuidGenerator: ()=> string = uuid.create;
@@ -112,6 +116,8 @@ class BaseModel {
public static TYPE_COMMAND = ModelType.Command;
public static TYPE_NOTE_EMBEDDING = ModelType.NoteEmbedding;
public static TYPE_CONFLICT_NOTE_STATE = ModelType.ConflictNoteState;
public static TYPE_CHAT_CONVERSATION = ModelType.ChatConversation;
public static TYPE_CHAT_MESSAGE = ModelType.ChatMessage;
// 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() {};
@@ -127,13 +127,21 @@ export const checkAiConfig = async (comp: ConfigScreenComponent) => {
comp.setState({ checkAiConfigResult: 'checking' });
try {
const { default: AiService } = await import('../../../services/ai/AiService');
// Reasoning models spend hundreds of tokens before emitting any content,
// so a small budget returns empty text with finish_reason "length".
const result = await AiService.instance().chat([
{ role: ChatRole.System, content: 'Keep replies brief, but non-empty.' },
{ role: ChatRole.User, content: 'Reply with the single word OK.' },
], { maxTokens: 20 });
], { maxTokens: 512 });
const text = (result.text || '').trim();
if (!text) {
comp.setState({ checkAiConfigResult: { ok: false, message: _('The provider returned an empty response. Check that the base URL ends with /v1 and that a model is loaded.') } });
// Truncation or a reasoning trace means the model was reached, so the
// base URL, key and model name are all fine.
if (result.finishReason === 'length' || result.reasoningText) {
comp.setState({ checkAiConfigResult: { ok: true, message: _('Connected successfully, but the model used its entire output budget on reasoning and returned no text.') } });
} else {
comp.setState({ checkAiConfigResult: { ok: false, message: _('The provider returned an empty response. Check that the base URL ends with /v1 and that a model is loaded.') } });
}
} else {
comp.setState({ checkAiConfigResult: { ok: true, message: text } });
}
@@ -0,0 +1,62 @@
import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
import JoplinServerApi from './JoplinServerApi';
// Errors as they are returned by JoplinServerApi when the server rejects a
// request - see JoplinServerApi.exec()
const serverError = (httpCode: number, message: string) => {
const error = new Error(message) as Error & { code: number; httpCode: number };
error.code = httpCode;
error.httpCode = httpCode;
return error;
};
const newDriver = (execHandler: ()=> unknown) => {
const api = { exec: execHandler } as unknown as JoplinServerApi;
return new FileApiDriverJoplinServer(api);
};
describe('file-api-driver-joplinServer', () => {
test.each([
[409, 'Conflict'],
[413, 'Payload too large'],
[422, 'Item 1.md cannot be saved because its body contains a null byte'],
])('should convert a %s error on put() to a rejectedByTarget error', async (httpCode, message) => {
const driver = newDriver(() => {
throw serverError(httpCode, message);
});
await expect(driver.put('1.md', 'content')).rejects.toMatchObject({
code: 'rejectedByTarget',
message,
});
});
test('should not convert unrelated errors on put()', async () => {
const driver = newDriver(() => {
throw serverError(500, 'Internal server error');
});
// Such errors must keep bubbling up so that the sync is retried later
await expect(driver.put('1.md', 'content')).rejects.toMatchObject({
code: 500,
});
});
test('should flag items rejected by the target in a multiPut() response', async () => {
const driver = newDriver(() => ({
items: {
'1.md': { error: serverError(422, 'body contains a null byte') },
'2.md': { error: serverError(500, 'Internal server error') },
'3.md': {},
},
}));
const output = await driver.multiPut([]);
expect(output.items['1.md'].error.code).toBe('rejectedByTarget');
expect(output.items['2.md'].error.code).toBe(500);
expect(output.items['3.md'].error).toBeUndefined();
});
});
+5 -1
View File
@@ -186,7 +186,11 @@ export default class FileApiDriverJoplinServer {
}
private isRejectedBySyncTargetError(error: { code?: number | string; httpCode?: number }) {
return error.code === 413 || error.code === 409 || error.httpCode === 413 || error.httpCode === 409;
// 422 means the server permanently refuses the item (for example its
// content contains a null byte). Without it the sync would abort and
// retry the same item forever.
const rejectedCodes = [409, 413, 422];
return rejectedCodes.includes(error.code as number) || rejectedCodes.includes(error.httpCode);
}
private isReadyOnlyError(error: { code?: string }) {
+47 -5
View File
@@ -273,6 +273,12 @@ interface NoteResourceRecognition {
objID?: string;
}
interface SaxParser {
line: number;
column: number;
resume: ()=> SaxParser;
}
const preProcessFile = async (filePath: string): Promise<string> => {
// Disabled pre-processing for now because it runs out of memory:
// https://github.com/laurent22/joplin/issues/5543
@@ -308,6 +314,27 @@ const preProcessFile = async (filePath: string): Promise<string> => {
// return newFilePath;
};
// Evernote doesn't escape "&" in the "evernote.caption" fields. The parser
// recovers from this on its own, but in strict mode it's fatal, so one stray
// character would prevent the whole file from being imported.
const recoverableParsingErrors = [
'Invalid character in entity name',
'Invalid character entity',
];
const isRecoverableParsingError = (error: Error) => {
return recoverableParsingErrors.some(m => error.message.includes(m));
};
// An error the importer recovered from - the import still completed.
export interface RecoverableError extends Error {
recoverable?: boolean;
}
export const isRecoverableError = (error: Error | string) => {
return typeof error !== 'string' && !!(error as RecoverableError).recoverable;
};
const isEvernoteUrl = (url: string) => {
url = url.toLowerCase();
return url.startsWith('evernote://') || url.startsWith('https://share.evernote.com/note/');
@@ -419,7 +446,7 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption
const createdNoteIds: string[] = [];
const noteTitlesToIds = new Map<string, string[]>();
const createErrorWithNoteTitle = (fnThis: { _parser?: { line: number; column: number } } | null, error: Error) => {
const createErrorWithNoteTitle = (fnThis: { _parser?: SaxParser } | null, error: Error) => {
const line = [];
const parser = fnThis ? fnThis._parser : null;
@@ -439,7 +466,7 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption
};
stream.on('error', function(error: Error) {
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: { line: number; column: number } }, error));
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: SaxParser }, error));
});
function currentNodeName() {
@@ -532,8 +559,23 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption
return true;
}
saxStream.on('error', function(error: Error) {
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: { line: number; column: number } }, error));
saxStream.on('error', function(error: RecoverableError) {
const parser = (this as { _parser?: SaxParser })._parser;
const canRecover = !!parser && isRecoverableParsingError(error);
// So that the caller can log it without reporting a failed import.
error.recoverable = canRecover;
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: SaxParser }, error));
if (canRecover) {
// Node disconnects the source stream whenever the destination
// emits "error", so without piping it again the rest of the file
// - and the "end" event - would never be received.
parser.resume();
stream.pipe(saxStream);
return;
}
// We need to reject the promise here, or parsing will get stuck
// ("end" handler will never be called).
@@ -650,7 +692,7 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption
if (notes.length >= importOptions.batchSize) {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
processNotes().catch(error => {
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: { line: number; column: number } }, error));
importOptions.onError(createErrorWithNoteTitle(this as { _parser?: SaxParser }, error));
});
}
note = null;
@@ -0,0 +1,93 @@
import BaseModel, { ModelType } from '../BaseModel';
import Folder from './Folder';
import Note from './Note';
import { setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import ChatConversation, { ChatHistoryMessage } from './ChatConversation';
import ChatMessage from './ChatMessage';
import uuid from '../uuid';
import { ChatRole, ChatStandardMessage, ChatToolMessage } from '../services/ai/types';
const makeMessage = (props: Partial<ChatHistoryMessage> = {}): ChatHistoryMessage => ({
id: uuid.create(), createdTime: 100, role: 'user', text: 'Question', raw: [], noteId: '', noteTitle: '', ...props,
});
describe('ChatConversation', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
BaseModel.dispatch = jest.fn();
});
afterEach(() => {
BaseModel.dispatch = () => {};
});
it('should fill the conversation from the first message, save it through the message model and dispatch it', async () => {
const id = uuid.create();
const message = makeMessage();
await ChatConversation.addMessage(id, message);
const conversation = await ChatConversation.load(id);
expect(conversation).toMatchObject({ id, title: 'Question', created_time: 100, updated_time: 100, type_: ModelType.ChatConversation });
expect(await ChatMessage.load(message.id)).toMatchObject({ conversation_id: id, position: 0, created_time: 100, type_: ModelType.ChatMessage });
expect(BaseModel.modelNameToType('chat_message')).toBe(ModelType.ChatMessage);
expect(await ChatConversation.messages(id)).toEqual([{ ...message, hide: false }]);
expect(BaseModel.dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_APPEND', conversationId: id, message });
});
it('should keep messages in order and only bump updated_time for user and assistant messages', async () => {
const id = uuid.create();
const first = makeMessage({ role: 'error', text: 'Open a note to start chatting.', createdTime: 100 });
const second = makeMessage({ role: 'user', text: 'Hello', createdTime: 200 });
const third = makeMessage({ role: 'separator', text: 'Note B', createdTime: 300 });
await Promise.all([first, second, third].map(message => ChatConversation.addMessage(id, message)));
expect(await ChatConversation.load(id)).toMatchObject({ title: 'Hello', created_time: 100, updated_time: 200 });
expect((await ChatConversation.messages(id)).map(message => message.text)).toEqual([first.text, second.text, third.text]);
});
it('should rename a conversation without changing its last-message timestamp or messages', async () => {
const id = uuid.create();
const message = makeMessage();
await ChatConversation.addMessage(id, message);
await ChatConversation.renameConversation(id, 'Renamed');
expect(await ChatConversation.load(id)).toMatchObject({ title: 'Renamed', updated_time: 100 });
await ChatConversation.addMessage(id, makeMessage({ text: 'Another question', createdTime: 200 }));
expect(await ChatConversation.load(id)).toMatchObject({ title: 'Renamed', updated_time: 200 });
});
it('should remove a message and add tool results to the matching assistant message', async () => {
const id = uuid.create();
const removed = makeMessage({ createdTime: 100 });
const assistantTurn: ChatStandardMessage = { role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'search', callId: 'call-1', arguments: {}, parseError: null }] };
const assistant = makeMessage({ role: 'assistant', text: '', raw: [assistantTurn], createdTime: 200 });
await ChatConversation.addMessage(id, removed);
await ChatConversation.addMessage(id, assistant);
await ChatConversation.removeMessage(id, removed.id);
const toolResult: ChatToolMessage = { role: ChatRole.Tool, content: 'result', toolName: 'search', toolCallId: 'call-1', isError: false, userDescription: '', isEdit: false };
await ChatConversation.addToolResult(id, toolResult);
expect(await ChatConversation.messages(id)).toEqual([{ ...assistant, hide: false, raw: [assistantTurn, toolResult] }]);
expect(BaseModel.dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_REMOVE', conversationId: id, id: removed.id });
expect(BaseModel.dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_ADD_TOOL_RESULT', conversationId: id, toolCall: toolResult });
});
it('should delete the conversation and its messages', async () => {
const id = uuid.create();
const kept = uuid.create();
await ChatConversation.addMessage(id, makeMessage());
await ChatConversation.addMessage(kept, makeMessage({ text: 'Keep' }));
await ChatConversation.deleteConversation(id);
expect(await ChatConversation.load(id)).toBeFalsy();
expect(await ChatMessage.byConversationId(id)).toEqual([]);
expect((await ChatConversation.history()).map(conversation => conversation.id)).toEqual([kept]);
expect(await ChatConversation.messages(kept)).toMatchObject([{ text: 'Keep' }]);
});
it('should keep saved note snapshots and separator text after the note is deleted', async () => {
const folder = await Folder.save({ title: 'Notebook' });
const note = await Note.save({ title: 'Original title', parent_id: folder.id });
const id = uuid.create();
await ChatConversation.addMessage(id, makeMessage({ role: 'separator', text: 'Note: Original title', noteId: note.id, noteTitle: note.title }));
await Note.delete(note.id, { toTrash: false });
expect(await ChatConversation.load(id)).toMatchObject({ title: '', created_time: 100, updated_time: 100 });
expect(await ChatConversation.messages(id)).toMatchObject([{ text: 'Note: Original title', noteId: note.id, noteTitle: 'Original title' }]);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { Mutex } from 'async-mutex';
import BaseModel, { ModelType } from '../BaseModel';
import markdownUtils from '../markdownUtils';
import { ChatMessage as ChatTurn, ChatRole, ChatToolMessage } from '../services/ai/types';
import ChatMessage from './ChatMessage';
export interface ChatHistoryMessage {
id: string;
createdTime: number;
noteId: string;
noteTitle: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
raw: ChatTurn[];
hide?: boolean;
}
interface Conversation {
id: string;
title: string;
updated_time: number;
}
export default class ChatConversation extends BaseModel {
private static messageMutex_ = new Mutex();
public static tableName() {
return 'chat_conversations';
}
public static modelType() {
return ModelType.ChatConversation;
}
public static useUuid() {
return true;
}
public static async history(search = ''): Promise<Conversation[]> {
const searchQuery = search.trim().toLowerCase();
if (!searchQuery) {
return this.db().selectAll<Conversation>('SELECT id, title, updated_time FROM chat_conversations ORDER BY updated_time DESC');
}
return this.db().selectAll<Conversation>(`
SELECT id, title, updated_time FROM chat_conversations
WHERE instr(LOWER(title), ?) > 0
OR EXISTS (
SELECT 1 FROM chat_messages
WHERE chat_messages.conversation_id = chat_conversations.id
AND instr(LOWER(chat_messages.text), ?) > 0
)
ORDER BY updated_time DESC
`, [searchQuery, searchQuery]);
}
public static async renameConversation(id: string, title: string) {
await this.save({ id, title }, { autoTimestamp: false });
}
public static async deleteConversation(id: string) {
await ChatMessage.deleteByConversationId(id);
await this.delete(id);
}
public static async messages(conversationId: string): Promise<ChatHistoryMessage[]> {
const conversation = await this.load(conversationId, { fields: ['id'] });
if (!conversation) throw new Error(`No such chat conversation: ${conversationId}`);
const rows = await ChatMessage.byConversationId(conversationId);
return rows.map(row => ({
id: row.id,
createdTime: row.created_time,
role: row.role as ChatHistoryMessage['role'],
text: row.text,
raw: JSON.parse(row.raw),
hide: !!row.hide,
noteId: row.note_id,
noteTitle: row.note_title,
}));
}
public static async addMessage(conversationId: string, message: ChatHistoryMessage) {
const release = await this.messageMutex_.acquire();
try {
await this.saveForMessage(conversationId, message);
await ChatMessage.save({
id: message.id,
conversation_id: conversationId,
role: message.role,
text: message.text,
raw: JSON.stringify(message.raw),
hide: message.hide ? 1 : 0,
position: await ChatMessage.nextPosition(conversationId),
created_time: message.createdTime,
note_id: message.noteId,
note_title: message.noteTitle,
}, { isNew: true, autoTimestamp: false });
} finally {
release();
}
this.dispatch({ type: 'AI_CHAT_APPEND', conversationId, message });
}
public static async removeMessage(conversationId: string, id: string) {
const release = await this.messageMutex_.acquire();
try {
await ChatMessage.delete(id);
} finally {
release();
}
this.dispatch({ type: 'AI_CHAT_REMOVE', conversationId, id });
}
public static async addToolResult(conversationId: string, toolCall: ChatToolMessage) {
const release = await this.messageMutex_.acquire();
try {
for (const row of await ChatMessage.byToolCallId(conversationId, toolCall.toolCallId)) {
const raw: ChatTurn[] = JSON.parse(row.raw);
const isTarget = raw.some(entry => entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === toolCall.toolCallId));
if (!isTarget) continue;
raw.push(toolCall);
await ChatMessage.save({ id: row.id, raw: JSON.stringify(raw) }, { autoTimestamp: false });
break;
}
} finally {
release();
}
this.dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', conversationId, toolCall });
}
private static async saveForMessage(conversationId: string, message: ChatHistoryMessage) {
const title = message.role === 'user' ? markdownUtils.titleFromBody(message.text) : '';
const bumpsUpdatedTime = message.role === 'user' || message.role === 'assistant';
const conversation = await this.load(conversationId, { fields: ['id', 'title', 'updated_time'] });
if (!conversation) {
await this.save({ id: conversationId, title, created_time: message.createdTime, updated_time: message.createdTime }, { isNew: true, autoTimestamp: false });
return;
}
const newTitle = conversation.title || title;
const newUpdatedTime = bumpsUpdatedTime ? Math.max(conversation.updated_time, message.createdTime) : conversation.updated_time;
if (newTitle === conversation.title && newUpdatedTime === conversation.updated_time) return;
await this.save({ id: conversationId, title: newTitle, updated_time: newUpdatedTime }, { autoTimestamp: false });
}
}
+33
View File
@@ -0,0 +1,33 @@
import BaseModel, { ModelType } from '../BaseModel';
import { ChatMessageEntity } from '../services/database/types';
export default class ChatMessage extends BaseModel {
public static tableName() {
return 'chat_messages';
}
public static modelType() {
return ModelType.ChatMessage;
}
public static useUuid() {
return true;
}
public static async byConversationId(conversationId: string): Promise<ChatMessageEntity[]> {
return this.modelSelectAll('SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY position', [conversationId]);
}
public static async byToolCallId(conversationId: string, toolCallId: string): Promise<ChatMessageEntity[]> {
return this.modelSelectAll('SELECT * FROM chat_messages WHERE conversation_id = ? AND role = ? AND instr(raw, ?) > 0 ORDER BY position', [conversationId, 'assistant', toolCallId]);
}
public static async nextPosition(conversationId: string) {
const row = await this.db().selectOne('SELECT MAX(position) AS position FROM chat_messages WHERE conversation_id = ?', [conversationId]);
return row.position === null ? 0 : row.position + 1;
}
public static async deleteByConversationId(conversationId: string) {
await this.batchDelete([conversationId], { idFieldName: 'conversation_id' });
}
}
@@ -11,6 +11,14 @@ const publishedFolderShareState = (folderId: string): StateShare => ({
master_key_id: '',
});
const publishedNoteShareState = (noteId: string): StateShare => ({
id: `share-note-${noteId}`,
type: ShareType.Note,
note_id: noteId,
folder_id: '',
master_key_id: '',
});
type ItemSlice = { title: string };
const expectPublished = async (items: ItemSlice[], published = true) => {
@@ -101,4 +109,79 @@ describe('models/Folder.publishing', () => {
'unpublished note',
].map(title => ({ title })));
});
it('should clear is_shared when a folder is no longer published', async () => {
await createFolderTree('', [
{
title: 'unpublished',
is_shared: 1,
children: [
{
title: 'sub-folder 1',
is_shared: 1,
children: [
{
title: 'sub-sub-folder 1',
is_shared: 1,
children: [
{ title: 'now unpublished note', is_shared: 1 },
{ title: 'directly published note', is_shared: 1 },
],
},
],
},
],
},
{
title: 'still published',
is_shared: 1,
children: [
{
title: 'still published sub-folder',
is_shared: 1,
children: [
{ title: 'still published note', is_shared: 1 },
{ title: 'deleted published note', is_shared: 1, deleted_time: Date.now() },
],
},
],
},
{
title: 'never published',
children: [
{
title: 'never published sub-folder',
children: [],
},
],
},
]);
const shareState: StateShare[] = [
publishedFolderShareState((await Folder.loadByTitle('still published')).id),
publishedNoteShareState((await Note.loadByTitle('directly published note')).id),
];
await Folder.updateNoLongerPublishedFolders(shareState);
await Note.updateNoLongerPublishedNotes(shareState);
await expectUnpublished([
'unpublished',
'sub-folder 1',
'sub-sub-folder 1',
'never published',
'never published sub-folder',
'now unpublished note',
'deleted published note',
].map(title => ({ title })));
await expectPublished([
'still published',
'still published sub-folder',
'still published note',
'directly published note',
].map(title => ({ title })));
});
});
+38 -3
View File
@@ -34,6 +34,14 @@ export interface SortFolderOptions {
includeDeleted?: boolean;
}
const activeSharesToPublishedFolderRootIds = (activeShares: StateShare[]) => {
const publishedFolderRootIds = activeShares
.filter(share => share.type === ShareType.PublishedFolder && !!share.folder_id)
.map(share => share.folder_id);
return publishedFolderRootIds;
};
export default class Folder extends BaseItem {
public static tableName() {
return 'folders';
@@ -789,9 +797,7 @@ export default class Folder extends BaseItem {
}
private static async updateFolderPublishStatus_(activeShares: StateShare[]) {
const publishedFolderRootIds = activeShares
.filter(share => share.type === ShareType.PublishedFolder && !!share.folder_id)
.map(share => share.folder_id);
const publishedFolderRootIds = activeSharesToPublishedFolderRootIds(activeShares);
const publishedFolderIds = unique(publishedFolderRootIds.concat(
(await Promise.all(
publishedFolderRootIds.map(id => this.allChildrenFolders(id)),
@@ -808,6 +814,35 @@ export default class Folder extends BaseItem {
}
}
public static async updateNoLongerPublishedFolders(activeShares: StateShare[]) {
const remotePublishedRootIds = new Set(activeSharesToPublishedFolderRootIds(activeShares));
let unsharedFolders = true;
while (unsharedFolders) {
unsharedFolders = false;
const fields = ['id', 'parent_id', 'is_shared', 'share_id'];
const fieldsString = fields.join(', ');
// For now, don't adjust is_shared for folders in the trash -- older Joplin versions will
// immediately re-publish those folders on sync
const allLocalToplevelPublishedFolders = await this.modelSelectAll(`
SELECT ${fields.map(f => `child.${f}`).join(', ')} FROM folders AS child
JOIN folders AS parent ON parent.id = child.parent_id
WHERE child.is_shared = 1 AND parent.is_shared = 0
UNION ALL -- Toplevel folders
SELECT ${fieldsString} FROM folders
WHERE is_shared = 1 AND parent_id = ''
`);
for (const folder of allLocalToplevelPublishedFolders) {
if (remotePublishedRootIds.has(folder.id)) continue;
// For now, exclude published folders within a share -- as of Sept 2026, share participants
// can't accurately know whether a folder in the share is directly published:
if (folder.share_id) continue;
await this.updateShareStatus({ ...folder, type_: BaseModel.TYPE_FOLDER }, false);
unsharedFolders = true;
}
}
}
// Clear the "share_id" property for the items that are associated with a
// share that no longer exists.
public static async updateNoLongerSharedItems(activeShareIds: string[]) {
+33 -3
View File
@@ -62,6 +62,12 @@ interface ByTitleAndParentOptions {
fields: string[];
}
const getDirectlyPublishedNoteIds = (shares: StateShare[]) => {
return shares
.filter(share => share.type === ShareType.Note && !!share.note_id)
.map(share => share.note_id);
};
export default class Note extends BaseItem {
public static defaultIntevalBetweenNotes = 60 * 60 * 1000;
@@ -606,9 +612,7 @@ export default class Note extends BaseItem {
}
public static async updatePublishedNotes(activeShares: StateShare[]) {
const directlyPublishedNoteIds = activeShares
.filter(share => share.type === ShareType.Note && !!share.note_id)
.map(share => share.note_id);
const directlyPublishedNoteIds = getDirectlyPublishedNoteIds(activeShares);
const loadUnpublishedWithDirectShare = async (): Promise<NoteEntity[]> => {
if (directlyPublishedNoteIds.length === 0) return [];
@@ -637,6 +641,32 @@ export default class Note extends BaseItem {
}
}
public static async updateNoLongerPublishedNotes(activeShares: StateShare[]) {
const directlyPublishedNoteIds = new Set(getDirectlyPublishedNoteIds(activeShares));
// Exclude notes in shared folders, since share participants don't have access to
// the full list of published items:
const andConditions = 'AND notes.share_id = \'\'';
const publishedNotesInUnpublishedFolders: NoteEntity[] = await this.db().selectAll(`
SELECT notes.id, notes.parent_id, notes.is_shared, notes.share_id
FROM notes
JOIN folders ON notes.parent_id = folders.id
WHERE notes.is_shared = 1 AND folders.is_shared = 0
${andConditions}
UNION ALL -- Deleted notes
SELECT id, parent_id, is_shared, share_id
FROM notes
WHERE is_shared = 1 AND deleted_time > 0
${andConditions}
`);
for (const note of publishedNotesInUnpublishedFolders) {
if (directlyPublishedNoteIds.has(note.id)) continue;
await this.updateShareStatus({ ...note, type_: BaseModel.TYPE_NOTE }, false);
}
}
public static async updateGeolocation(noteId: string): Promise<NoteEntity | null> {
if (!Setting.value('trackLocation')) return null;
if (!Note.updateGeolocationEnabled_) return null;
@@ -771,6 +771,18 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.File,
},
// Not gated on `ai.enabled` since it only applies when chat is off.
'ai.chat.showToolbarButton': {
value: true,
type: SettingItemType.Bool,
public: true,
advanced: true,
section: 'ai',
appTypes: [AppType.Desktop],
label: () => _('Show the AI Chat button in the note toolbar'),
storage: SettingStorage.File,
},
// Cumulative token counters for the currently configured provider.
// Reset whenever the user changes the active provider in settings —
// totals always reflect the provider in use.
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/lib",
"version": "3.7.1",
"version": "3.7.3",
"description": "Joplin Core library",
"author": "Laurent Cozic",
"homepage": "",
@@ -50,15 +50,15 @@
"@adobe/css-tools": "4.5.0",
"@aws-sdk/client-s3": "3.296.0",
"@aws-sdk/s3-request-presigner": "3.296.0",
"@joplin/fork-htmlparser2": "^4.1.64",
"@joplin/fork-sax": "^1.2.68",
"@joplin/fork-uslug": "^2.0.7",
"@joplin/htmlpack": "^3.7.1",
"@joplin/onenote-converter": "^3.7.1",
"@joplin/renderer": "^3.7.1",
"@joplin/turndown": "^4.0.86",
"@joplin/turndown-plugin-gfm": "^1.0.68",
"@joplin/utils": "^3.7.1",
"@joplin/fork-htmlparser2": "^4.1.66",
"@joplin/fork-sax": "^1.2.70",
"@joplin/fork-uslug": "^2.0.9",
"@joplin/htmlpack": "^3.7.3",
"@joplin/onenote-converter": "^3.7.3",
"@joplin/renderer": "^3.7.3",
"@joplin/turndown": "^4.0.88",
"@joplin/turndown-plugin-gfm": "^1.0.70",
"@joplin/utils": "^3.7.3",
"adm-zip": "0.5.17",
"async-mutex": "0.5.0",
"base-64": "1.0.0",
@@ -85,7 +85,7 @@
"moment": "2.30.1",
"multiparty": "4.3.0",
"mustache": "4.2.0",
"nanoid": "5.1.16",
"nanoid": "3.3.11",
"node-diff3": "3.2.1",
"node-notifier": "10.0.1",
"node-persist": "3.1.3",
@@ -110,5 +110,5 @@
"word-wrap": "1.2.5",
"xml2js": "0.4.23"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+6
View File
@@ -1,3 +1,4 @@
import uuid from '../../uuid';
import { chunkText, defaultChunkOptions, cjkChunkOptions, optionsForText } from './chunker';
describe('chunker', () => {
@@ -12,6 +13,11 @@ describe('chunker', () => {
expect(chunkText(body)).toEqual([body]);
});
it('removes item IDs from chunked text chunking', () => {
const body = `![image](:/${uuid.create()}), [link](:/${uuid.create()}), :/${uuid.create()}, and :/${uuid.create()}`;
expect(chunkText(body)).toEqual(['![image], [link], , and']);
});
it('produces overlapping chunks for long input', () => {
// Pick a smaller chunk size for the test so we can reason about boundaries.
const options = { chunkSize: 100, chunkOverlap: 20 };
+9 -1
View File
@@ -58,8 +58,16 @@ const stepFromOptions = (options: ChunkOptions): number => {
return step;
};
const removeItemIds = (text: string) => {
// Remove `(:/item-id)`s from most Markdown images and links. Include the parentheses
// to avoid trailing empty parentheses after links and images:
return text.replace(/\(:\/[a-z0-9]{32}\)/g, '')
// Remove other item URLs (e.g. in `<img src=":/id">` HTML)
.replace(/:\/[a-z0-9]{32}([^a-z0-9]|$)/g, '$1');
};
export const chunkText = (text: string, options?: ChunkOptions): string[] => {
const normalised = (text ?? '').trim();
const normalised = removeItemIds(text ?? '').trim();
if (!normalised) return [];
const effective = options ?? optionsForText(normalised);
if (normalised.length <= effective.chunkSize) return [normalised];
@@ -1,7 +1,7 @@
import shim from '../../../shim';
import JoplinError from '../../../JoplinError';
import Logger from '@joplin/utils/Logger';
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ChatToolMessage, ProviderClassification } from '../types';
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ChatToolMessage, ProviderClassification } from '../types';
import ChatProviderBase from './ChatProviderBase';
const logger = Logger.create('AnthropicProvider');
@@ -38,14 +38,28 @@ interface AnthropicToolResultContentBlock {
content: string|AnthropicImageContent[];
}
type AnthropicContentBlock = AnthropicToolUseContentBlock|AnthropicToolResultContentBlock|AnthropicTextContentBlock;
interface AnthropicThinkingContentBlock {
type: 'thinking';
thinking: string;
}
type AnthropicContentBlock = AnthropicToolUseContentBlock|AnthropicToolResultContentBlock|AnthropicTextContentBlock|AnthropicThinkingContentBlock;
interface AnthropicResponse {
content?: AnthropicContentBlock[];
usage?: AnthropicUsage;
stop_reason?: string;
error?: { message?: string };
}
const toChatFinishReason = (reason: string|undefined): ChatFinishReason|undefined => {
if (!reason) return undefined;
if (reason === 'max_tokens') return 'length';
if (reason === 'end_turn' || reason === 'stop_sequence') return 'stop';
if (reason === 'tool_use') return 'tool_calls';
return 'other';
};
interface Options {
apiKey: string;
model: string;
@@ -251,6 +265,7 @@ export default class AnthropicProvider extends ChatProviderBase {
const toolCalls: ChatToolCall[] = [];
const textMessages = [];
const thinkingMessages = [];
for (const response of json.content) {
if (response.type === 'tool_use' && typeof response.input === 'object') {
toolCalls.push({
@@ -261,10 +276,18 @@ export default class AnthropicProvider extends ChatProviderBase {
});
} else if (response.type === 'text' && typeof response.text === 'string') {
textMessages.push(response.text);
} else if (response.type === 'thinking' && typeof response.thinking === 'string') {
thinkingMessages.push(response.thinking);
}
}
return { text: textMessages.join(''), toolCalls, usage: { inputTokens, outputTokens } };
return {
text: textMessages.join(''),
toolCalls,
usage: { inputTokens, outputTokens },
finishReason: toChatFinishReason(json.stop_reason),
reasoningText: thinkingMessages.length ? thinkingMessages.join('') : undefined,
};
}
}
@@ -0,0 +1,64 @@
import { mockFetch, setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
import OpenAiCompatibleProvider from './OpenAiCompatible';
import { ChatRole } from '../types';
const mockChatResponse = (message: Record<string, unknown>, finishReason: string|undefined) => {
return mockFetch(() => {
return new Response(JSON.stringify({
choices: [{ message, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 20 },
}), { status: 200 });
});
};
const newProvider = () => {
return new OpenAiCompatibleProvider({
baseUrl: 'http://localhost:1234/v1',
apiKey: 'test-key',
model: 'test-model',
classification: 'local',
});
};
const chat = async () => {
return newProvider().chat([{ role: ChatRole.User, content: 'Hello' }]);
};
describe('ai/providers/OpenAiCompatible', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
test.each([
['length', 'length'],
['max_tokens', 'length'],
['MAX_TOKENS', 'length'],
['stop', 'stop'],
['tool_calls', 'tool_calls'],
['content_filter', 'other'],
[undefined, undefined],
])('maps finish_reason %s to %s', async (finishReason, expected) => {
mockChatResponse({ content: 'OK' }, finishReason);
const result = await chat();
expect(result.finishReason).toBe(expected);
});
test.each([
['reasoning_content'],
['reasoning'],
])('surfaces the reasoning trace from %s', async (field) => {
mockChatResponse({ content: '', [field]: 'Let me think...' }, 'length');
const result = await chat();
expect(result.text).toBe('');
expect(result.reasoningText).toBe('Let me think...');
});
it('leaves reasoningText undefined when the provider reports none', async () => {
mockChatResponse({ content: 'OK' }, 'stop');
const result = await chat();
expect(result.reasoningText).toBeUndefined();
});
});
@@ -2,9 +2,10 @@ import shim from '../../../shim';
import JoplinError from '../../../JoplinError';
import Logger from '@joplin/utils/Logger';
import { rtrimSlashes } from '@joplin/utils/path';
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification } from '../types';
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification } from '../types';
import ChatProviderBase from './ChatProviderBase';
import { ToolSpec } from '../tools/types';
import extractReasoning from '../utils/extractReasoning';
const logger = Logger.create('OpenAiCompatibleProvider');
@@ -25,11 +26,15 @@ interface OpenAiToolCall {
interface OpenAiMessage {
content?: string;
// Non-standard: `reasoning_content` (DeepSeek, vLLM), `reasoning` (OpenRouter).
reasoning_content?: string;
reasoning?: string;
tool_calls?: OpenAiToolCall[];
}
interface OpenAiChoice {
message?: OpenAiMessage;
finish_reason?: string;
}
interface OpenAiResponse {
@@ -45,6 +50,15 @@ interface Options {
classification: ProviderClassification;
}
const toChatFinishReason = (reason: string|undefined): ChatFinishReason|undefined => {
if (!reason) return undefined;
// Some providers use "max_tokens" or "MAX_TOKENS" instead of "length".
if (['length', 'max_tokens'].includes(reason.toLowerCase())) return 'length';
if (reason === 'stop') return 'stop';
if (reason === 'tool_calls') return 'tool_calls';
return 'other';
};
const convertTool = (tool: ToolSpec) => {
return {
type: 'function',
@@ -203,8 +217,10 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
);
}
const responseMessage = json.choices?.[0]?.message;
const content = responseMessage?.content ?? '';
const choice = json.choices?.[0];
const responseMessage = choice?.message;
const { text: content, reasoning: inlineReasoning } = extractReasoning(responseMessage?.content ?? '');
const reasoningText = responseMessage?.reasoning_content ?? responseMessage?.reasoning ?? (inlineReasoning || undefined);
// Some "OpenAI-compatible" providers (notably older Ollama versions)
// omit `usage` entirely. Default to zeros rather than throw.
const inputTokens = json.usage?.prompt_tokens ?? 0;
@@ -232,7 +248,13 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
};
}).filter(toolCall => !!toolCall);
return { text: content, toolCalls, usage: { inputTokens, outputTokens } };
return {
text: content,
toolCalls,
usage: { inputTokens, outputTokens },
finishReason: toChatFinishReason(choice?.finish_reason),
reasoningText,
};
}
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
@@ -210,6 +210,7 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
description: 'Get the current, up-to-date content of the note. This returns the full content of the note that\'s currently open in Joplin\'s editor.',
inputSchema: {
type: 'object',
properties: {},
required: [],
additionalProperties: false,
},
+8
View File
@@ -67,10 +67,18 @@ export interface ChatToolCall {
parseError: string|null;
}
export type ChatFinishReason = 'stop' | 'length' | 'tool_calls' | 'other';
export interface ChatResult {
text: string;
toolCalls: ChatToolCall[];
usage: ChatUsage;
// 'length' means the output budget ran out, which is how a reasoning model
// with a low maxTokens returns empty text. Undefined if unreported.
finishReason?: ChatFinishReason;
// Model reasoning, from a dedicated provider field or extracted from inline
// markers. Kept separate so it doesn't pollute the assistant reply.
reasoningText?: string;
// 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,73 @@
import extractReasoning from './extractReasoning';
describe('extractReasoning', () => {
test.each([
[
'Gemma channel markers',
'<|channel>thought\nI should greet them.<channel|>Hello there!',
'Hello there!',
'thought\nI should greet them.',
],
[
'think tags',
'<think>Let me work this out.</think>The answer is 42.',
'The answer is 42.',
'Let me work this out.',
],
[
'thinking tags',
'<thinking>Considering options.</thinking>Done.',
'Done.',
'Considering options.',
],
[
'no markers',
'Just a plain reply.',
'Just a plain reply.',
'',
],
[
'empty content',
'',
'',
'',
],
[
'leading whitespace before the marker',
'\n <think>Deliberating.</think>The answer.',
'The answer.',
'Deliberating.',
],
[
'a marker-like tag that is part of the reply',
'The `<think>` tag marks internal reasoning, like <think>this</think>.',
'The `<think>` tag marks internal reasoning, like <think>this</think>.',
'',
],
[
'a reply whose code block shows the tag',
'<think>They want an example.</think>Here:\n\n```\n<think>hello</think>\n```',
'Here:\n\n```\n<think>hello</think>\n```',
'They want an example.',
],
])('should split %s', (_name, content, expectedText, expectedReasoning) => {
const result = extractReasoning(content);
expect(result.text).toBe(expectedText);
expect(result.reasoning).toBe(expectedReasoning);
});
it('should treat an unterminated marker as reasoning rather than leaking it as the reply', () => {
const result = extractReasoning('<|channel>thought\nI was cut off mid-sent');
expect(result.text).toBe('');
expect(result.reasoning).toBe('thought\nI was cut off mid-sent');
});
it('should preserve a reply that opens with a fenced block showing the tag', () => {
const content = '```\n<think>hello</think>\n```\n\nThat tag marks internal reasoning.';
const result = extractReasoning(content);
expect(result.text).toBe(content.trim());
expect(result.reasoning).toBe('');
});
});
@@ -0,0 +1,37 @@
// Some models emit reasoning inline instead of in a separate field. Gemma's
// pair is asymmetric, so generic special-token filters miss it.
const markerPairs = [
{ open: '<|channel>', close: '<channel|>' },
{ open: '<think>', close: '</think>' },
{ open: '<thinking>', close: '</thinking>' },
];
interface ExtractedReasoning {
text: string;
reasoning: string;
}
// Only a leading marker counts: anything later is the model talking *about*
// these tags, and stripping it would corrupt the answer.
const extractReasoning = (content: string): ExtractedReasoning => {
if (!content) return { text: '', reasoning: '' };
const trimmed = content.trimStart();
const pair = markerPairs.find(candidate => trimmed.startsWith(candidate.open));
if (!pair) return { text: content.trim(), reasoning: '' };
const bodyStart = pair.open.length;
const closeIndex = trimmed.indexOf(pair.close, bodyStart);
// Cut off mid-thought: keep it as reasoning rather than leak it as the reply.
if (closeIndex === -1) {
return { text: '', reasoning: trimmed.slice(bodyStart).trim() };
}
return {
text: trimmed.slice(closeIndex + pair.close.length).trim(),
reasoning: trimmed.slice(bodyStart, closeIndex).trim(),
};
};
export default extractReasoning;
@@ -0,0 +1,25 @@
import { SqlQuery } from '../types';
export default (): (SqlQuery|string)[] => {
return [
`CREATE TABLE chat_conversations (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL DEFAULT "",
created_time INT NOT NULL,
updated_time INT NOT NULL
)`,
`CREATE TABLE chat_messages (
id TEXT PRIMARY KEY NOT NULL,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL DEFAULT "",
raw TEXT NOT NULL DEFAULT "[]",
hide INT NOT NULL DEFAULT 0,
position INT NOT NULL,
created_time INT NOT NULL,
note_id TEXT NOT NULL DEFAULT "",
note_title TEXT NOT NULL DEFAULT ""
)`,
'CREATE UNIQUE INDEX chat_messages_conversation_position ON chat_messages (conversation_id, position)',
];
};
@@ -11,6 +11,7 @@ import migration50 from './50';
import migration51 from './51';
import migration52 from './52';
import migration53 from './53';
import migration54 from './54';
import { Migration } from '../types';
@@ -27,6 +28,7 @@ const index: Migration[] = [
migration51,
migration52,
migration53,
migration54,
];
export default index;
+40
View File
@@ -96,6 +96,26 @@ export interface AlarmEntity {
'trigger_time'?: number;
'type_'?: number;
}
export interface ChatConversationEntity {
'created_time'?: number;
'id'?: string;
'title'?: string;
'updated_time'?: number;
'type_'?: number;
}
export interface ChatMessageEntity {
'conversation_id'?: string;
'created_time'?: number;
'hide'?: number;
'id'?: string;
'note_id'?: string;
'note_title'?: string;
'position'?: number;
'raw'?: string;
'role'?: string;
'text'?: string;
'type_'?: number;
}
export interface ConflictNoteStateEntity {
'base_body'?: string;
'base_title'?: string;
@@ -727,4 +747,24 @@ export const databaseSchema: DatabaseTables = {
remote_updated_time: { type: 'number' },
type_: { type: 'number' },
},
chat_conversations: {
created_time: { type: 'number' },
id: { type: 'string' },
title: { type: 'string' },
updated_time: { type: 'number' },
type_: { type: 'number' },
},
chat_messages: {
conversation_id: { type: 'string' },
created_time: { type: 'number' },
hide: { type: 'number' },
id: { type: 'string' },
note_id: { type: 'string' },
note_title: { type: 'string' },
position: { type: 'number' },
raw: { type: 'string' },
role: { type: 'string' },
text: { type: 'string' },
type_: { type: 'number' },
},
};
@@ -3,20 +3,22 @@ import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../te
import { ImportModuleOutputFormat, ImportOptions } from './types';
import InteropService from './InteropService';
import Folder from '../../models/Folder';
import { isRecoverableError } from '../../import-enex';
const importFolder = async (path: string) => {
const importFolder = async (path: string, onError: ImportOptions['onError'] = null) => {
const importOptions: ImportOptions = {
path: path,
format: 'enex',
outputFormat: ImportModuleOutputFormat.Markdown,
onError,
};
await InteropService.instance().import(importOptions);
};
const importTestFile = async (name: string) => {
const importTestFile = async (name: string, onError: ImportOptions['onError'] = null) => {
const enexSampleBaseDir = `${supportDir}/../enex_to_md`;
await importFolder(`${enexSampleBaseDir}/${name}`);
await importFolder(`${enexSampleBaseDir}/${name}`, onError);
};
describe('InteropService_Importer_EnexToMd', () => {
@@ -54,4 +56,21 @@ describe('InteropService_Importer_EnexToMd', () => {
expect(notes[2].body).toContain(`[Test note](:/${notes[3].id})`);
expect(notes[4].body).toContain(`[Test](:/${notes[2].id})`);
});
it('should import notes even if the file contains unescaped ampersands', async () => {
const errors: Error[] = [];
await importTestFile('unescaped_ampersand.enex', error => errors.push(error));
const notes = await Note.all({ order: [{ by: 'title', dir: 'ASC' }] });
expect(notes.map(n => n.title)).toEqual([
'Note after the bad one',
'Unescaped ampersand',
]);
expect(notes[0].body).toContain('This note must still be imported');
expect(notes[1].body).toContain('Mobilize & Measure');
// Reported, but flagged so it's not presented as a failed import.
expect(errors.length).toBe(1);
expect(isRecoverableError(errors[0])).toBe(true);
});
});
@@ -36,4 +36,14 @@ describe('SearchEngine.semantic', () => {
const rows = await engine.search('body:letters letter letter');
expect(rows).toHaveLength(0);
});
it('should not use semantic search when searching for an item ID-like pattern', async () => {
const testUuid = '5d17d3649e0449209db61fdbf6b27d14';
await Note.save({ title: 'test', body: testUuid });
await updateSearchIndex();
expect(await engine.search(testUuid)).toHaveLength(1);
expect(await engine.search(testUuid.replace(/.$/, 'a'))).toHaveLength(0);
});
});
+13 -3
View File
@@ -21,6 +21,7 @@ import PerformanceLogger from '../../PerformanceLogger';
import SearchService from '../ai/SearchService';
import { unique } from '../../ArrayUtils';
import { embeddingAvailability } from '../ai/availability';
import isItemId from '../../models/utils/isItemId';
const perfLogger = PerformanceLogger.create();
@@ -728,7 +729,7 @@ export default class SearchEngine {
private determineSearchType_(query: string, parsedQuery: ParsedQuery, preferredSearchType: SearchType) {
if (preferredSearchType === SearchType.Basic) return SearchType.Basic;
if (preferredSearchType === SearchType.Nonlatin) return SearchType.Nonlatin;
if (preferredSearchType === SearchType.Semantic && this.canSemanticSearch_(parsedQuery)) {
if (preferredSearchType === SearchType.Semantic && this.canSemanticSearch_(query, parsedQuery)) {
return SearchType.Semantic;
}
@@ -757,12 +758,21 @@ export default class SearchEngine {
return SearchEngine.SEARCH_TYPE_FTS;
}
private canSemanticSearch_(parsedQuery: ParsedQuery) {
private canSemanticSearch_(rawQuery: string, parsedQuery: ParsedQuery) {
// Disable semantic search if the user has explicitly specified a field to search in
if (parsedQuery.allTerms.some(term => term.name !== 'text')) {
return false;
}
const trimQuotes = (text: string) => text.replace(/^"(.*)"$/, '$1');
const isItemIdQuery = (query: string) => isItemId(trimQuotes(query));
// Some plugins search for item IDs and expect only full-text matches.
// See https://github.com/laurent22/joplin/issues/16644
if (isItemIdQuery(rawQuery.trim())) {
return false;
}
return Setting.value('featureFlag.enableSemanticSearch')
&& embeddingAvailability().available;
}
@@ -926,7 +936,7 @@ export default class SearchEngine {
rows = await this.searchFromItemIds(searchString);
}
if (this.canSemanticSearch_(parsedQuery)
if (this.canSemanticSearch_(searchString, parsedQuery)
// Don't use semantic search if another search type was explicitly requested
&& options.searchType === SearchType.Auto
// Avoid doing semantic search twice
@@ -654,6 +654,8 @@ export default class ShareService {
private async updateNoLongerSharedItems() {
const shareIds = this.shares.map(share => share.id).concat(this.shareInvitations.map(si => si.share.id));
await Folder.updateNoLongerSharedItems(shareIds);
await Folder.updateNoLongerPublishedFolders(this.shares);
await Note.updateNoLongerPublishedNotes(this.shares);
}
public async maintenance() {
+2
View File
@@ -324,6 +324,8 @@ async function clearDatabase(id: number = null) {
await ItemChange.waitForAllSaved();
const tableNames = [
'chat_conversations',
'chat_messages',
'deleted_items',
'folders',
'item_changes',
+2 -2
View File
@@ -4,7 +4,7 @@
"access": "public"
},
"description": "Used to import a OneNote archive into Joplin",
"version": "3.7.1",
"version": "3.7.3",
"license": "MPL-2.0-no-copyleft-exception",
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/onenote-converter",
"main": "./renderer/pkg/renderer.js",
@@ -17,5 +17,5 @@
"wasm-pack": "0.13.1",
"yargs": "17.7.2"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/plugin-repo-cli",
"version": "3.7.4",
"version": "3.7.6",
"description": "",
"main": "./dist/index.js",
"bin": "./dist/index.js",
@@ -25,9 +25,9 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@joplin/lib": "^3.7.1",
"@joplin/tools": "^3.7.1",
"@joplin/utils": "^3.7.1",
"@joplin/lib": "^3.7.3",
"@joplin/tools": "^3.7.3",
"@joplin/utils": "^3.7.3",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "22.19.21",
@@ -37,5 +37,5 @@
"webpack": "5.97.1",
"webpack-cli": "4.10.0"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/react-native-saf-x",
"version": "3.7.1",
"version": "3.7.3",
"description": "a module to help work with scoped storages on android easily",
"main": "src/index",
"react-native": "src/index",
@@ -53,5 +53,5 @@
"react": "*",
"react-native": "*"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/renderer",
"version": "3.7.1",
"version": "3.7.3",
"description": "The Joplin note renderer, used the mobile and desktop application",
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
"main": "index.js",
@@ -30,9 +30,9 @@
"typescript": "5.9.3"
},
"dependencies": {
"@joplin/fork-htmlparser2": "^4.1.64",
"@joplin/fork-uslug": "^2.0.7",
"@joplin/utils": "^3.7.1",
"@joplin/fork-htmlparser2": "^4.1.66",
"@joplin/fork-uslug": "^2.0.9",
"@joplin/utils": "^3.7.3",
"@types/json5": "2.2.0",
"abcjs": "6.6.3",
"font-awesome-filetypes": "2.1.0",
@@ -57,5 +57,5 @@
"markdown-it-toc-done-right": "4.2.0",
"md5": "2.3.0"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+18
View File
@@ -356,3 +356,21 @@ syncable
selfsigned
combobox
Brieuc
beorg
calca
dayone
devonthink
hostless
instapaper
omnifocus
onewriter
outlinely
prizmo
pyto
shortcutsiosopen
textastic
timepage
todoist
twodo
Gemma
instr
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/tools",
"version": "3.7.1",
"version": "3.7.3",
"description": "Various tools for Joplin",
"main": "index.js",
"author": "Laurent Cozic",
@@ -21,9 +21,9 @@
},
"license": "AGPL-3.0-or-later",
"dependencies": {
"@joplin/lib": "^3.7.1",
"@joplin/renderer": "^3.7.1",
"@joplin/utils": "^3.7.1",
"@joplin/lib": "^3.7.3",
"@joplin/renderer": "^3.7.3",
"@joplin/utils": "^3.7.3",
"compare-versions": "6.1.1",
"dayjs": "1.11.21",
"execa": "4.1.0",
@@ -45,7 +45,7 @@
},
"devDependencies": {
"@docusaurus/plugin-sitemap": "3.10.1",
"@joplin/fork-htmlparser2": "^4.1.64",
"@joplin/fork-htmlparser2": "^4.1.66",
"@rmp135/sql-ts": "1.18.1",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
@@ -67,5 +67,5 @@
"ts-node": "10.9.2",
"typescript": "5.9.3"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+2 -2
View File
@@ -4,7 +4,7 @@
"publishConfig": {
"access": "public"
},
"version": "1.0.68",
"version": "1.0.70",
"author": "Dom Christie",
"main": "lib/turndown-plugin-gfm.cjs.js",
"devDependencies": {
@@ -41,5 +41,5 @@
"build-test": "browserify test/turndown-plugin-gfm-test.js --outfile test/turndown-plugin-gfm-test.browser.js",
"prepare": "npm run build"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@joplin/turndown",
"description": "A library that converts HTML to Markdown",
"version": "4.0.86",
"version": "4.0.88",
"author": "Dom Christie",
"main": "lib/turndown.cjs.js",
"publishConfig": {
@@ -47,5 +47,5 @@
"build": "npm run build-cjs",
"prepare": "npm run build"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/utils",
"version": "3.7.1",
"version": "3.7.3",
"description": "Utilities for Joplin",
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/utils",
"exports": {
@@ -90,7 +90,7 @@
"author": "",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@joplin/fork-htmlparser2": "^4.1.64",
"@joplin/fork-htmlparser2": "^4.1.66",
"async-mutex": "0.5.0",
"dayjs": "1.11.21",
"execa": "5.1.1",
@@ -110,5 +110,5 @@
"ts-jest": "29.4.11",
"typescript": "5.9.3"
},
"gitHead": "500e4c02809d3ebe463aa590e86f541d1ef0661c"
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
}
+11
View File
@@ -111,6 +111,17 @@ export const formatMsToRelative = (ms: number) => {
return d.fromNow(false);
};
export const formatMsToRelativeTime = (ms: number) => {
const date = dayjs(ms);
if (!date.isValid()) return 'Invalid date';
const elapsed = Math.max(0, Date.now() - ms);
if (elapsed < Minute) return `${Math.floor(elapsed / Second)}s`;
if (elapsed < Hour) return `${Math.floor(elapsed / Minute)}m`;
if (elapsed < Day) return `${Math.floor(elapsed / Hour)}h`;
return `${Math.floor(elapsed / Day)}d`;
};
const joplinLocaleToDayJsLocale = (locale: string) => {
locale = locale.toLowerCase().replace(/_/, '-');
if (supportedLocales[locale]) return locale;
+7
View File
@@ -1,5 +1,12 @@
# Joplin Android Changelog
## [android-v3.7.11](https://github.com/laurent22/joplin/releases/tag/android-v3.7.11) - 2026-09-25T11:28:40Z
- Fixed: Fix AI configuration test failing on reasoning models (#16594)
- Fixed: Fix notes and sub-folders are still marked as published after being moved out of a published folder (#16648) (#16647 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix sync deadlock when Joplin Server rejects an item with HTTP 422 (#16544) (#16504)
- Fixed: Fix table editor losing and mis-rendering inline markdown (#16632)
## [android-v3.7.10](https://github.com/laurent22/joplin/releases/tag/android-v3.7.10) - 2026-09-10T17:52:30Z
- Fixed: Fix WebDAV sync error "Protocol https: not supported" on servers that redirect (#16488) (#16486)
+7
View File
@@ -1,5 +1,12 @@
# Joplin iOS Changelog
## [ios-v13.7.6](https://github.com/laurent22/joplin/releases/tag/ios-v13.7.6) - 2026-09-25T11:32:23Z
- Fixed: Fix AI configuration test failing on reasoning models (#16594)
- Fixed: Fix notes and sub-folders are still marked as published after being moved out of a published folder (#16648) (#16647 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix sync deadlock when Joplin Server rejects an item with HTTP 422 (#16544) (#16504)
- Fixed: Fix table editor losing and mis-rendering inline markdown (#16632)
## [ios-v13.7.5](https://github.com/laurent22/joplin/releases/tag/ios-v13.7.5) - 2026-09-10T19:10:44Z
- Improved: Remove sync wizard button from config screen (#16458) (#16442 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
+5
View File
@@ -28,6 +28,11 @@ After the initial scan, new and edited notes are picked up within a few minutes.
When enabled, semantic search results are included in Joplin's built-in search UI. Semantic search results are mixed with the default [full-text search matches](https://github.com/laurent22/joplin/blob/dev/readme/apps/search.md).
Joplin's search panel will not use semantic search if:
- Searching for item IDs (e.g. `ce0e738385b25927a716171e00d8ebcf`).
- The query uses search filters (e.g. `title:"Title search"`) or [basic search](https://github.com/laurent22/joplin/blob/dev/readme/apps/search.md#supported-queries).
- Semantic search is unsupported or isn't enabled in settings.
Semantic search is also exposed to:
- **Plugins** can call `joplin.ai.search()` to look up notes by meaning. The plugin's description tells you whether it uses this.
+23
View File
@@ -10,6 +10,29 @@ To create a link, right click a note, a folder, or a tag in the sidebar and sele
* `joplin://x-callback-url/openFolder?id=<folder id>` for folder
* `joplin://x-callback-url/openTag?id=<tag id>` for tag
## Commands that return a result
Two desktop-only commands send a result back to the calling application, using the [x-callback-url](https://x-callback-url.com/) convention:
* `joplin://x-callback-url/getCurrentNote?x-success=<url>&x-error=<url>` returns the currently selected note
* `joplin://x-callback-url/createNote?title=<title>&body=<body>&x-success=<url>&x-error=<url>` creates a note in the current notebook and returns it
Joplin opens the `x-success` URL with `title` and `url` appended, where `url` links back to the note, or the `x-error` URL with `errorMessage`. Existing parameters are preserved, so the callback URL must be percent-encoded. For example `getCurrentNote` with an `x-success` of `hook://x-callback-url/setCurrentNode` opens:
<!-- cSpell:disable -->
```
hook://x-callback-url/setCurrentNode?title=My%20note&url=joplin%3A%2F%2Fx-callback-url%2FopenNote%3Fid%3D...
```
<!-- cSpell:enable -->
### Valid response URLs
These commands can be triggered from a web page, and the response URL is passed to the operating system, so `x-success` and `x-error` are accepted only if the scheme belongs to a known application, such as `editorial://workflow-callback`, or the host is `x-callback-url`, such as `hook://x-callback-url/setCurrentNode`. Anything else is ignored with a warning in the log. `http` and `https` URLs are rejected since they cannot return to the calling application and would send the note title and ID to a web server.
If your application's callback is rejected, please [open an issue](https://github.com/laurent22/joplin/issues) so its scheme can be added.
## Known problems
On macOS if Joplin isn't running it will start but it won't open the note. If Joplin is running but on a different space as the external link, then Joplin will come to the foreground but without displaying the note.
+1
View File
@@ -163,6 +163,7 @@
"react-native-reanimated",
// Cannot upgrade further due to ESM support
"nanoid",
"node-fetch",
"execa",
"open",
+33 -33
View File
@@ -12806,7 +12806,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/fork-htmlparser2@npm:^4.1.64, @joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2":
"@joplin/fork-htmlparser2@npm:^4.1.66, @joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2":
version: 0.0.0-use.local
resolution: "@joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2"
dependencies:
@@ -12827,7 +12827,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/fork-sax@npm:^1.2.68, @joplin/fork-sax@workspace:packages/fork-sax":
"@joplin/fork-sax@npm:^1.2.70, @joplin/fork-sax@workspace:packages/fork-sax":
version: 0.0.0-use.local
resolution: "@joplin/fork-sax@workspace:packages/fork-sax"
dependencies:
@@ -12836,7 +12836,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/fork-uslug@npm:^2.0.0, @joplin/fork-uslug@npm:^2.0.7, @joplin/fork-uslug@workspace:packages/fork-uslug":
"@joplin/fork-uslug@npm:^2.0.0, @joplin/fork-uslug@npm:^2.0.9, @joplin/fork-uslug@workspace:packages/fork-uslug":
version: 0.0.0-use.local
resolution: "@joplin/fork-uslug@workspace:packages/fork-uslug"
dependencies:
@@ -12848,12 +12848,12 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/htmlpack@npm:^3.7.1, @joplin/htmlpack@workspace:packages/htmlpack":
"@joplin/htmlpack@npm:^3.7.3, @joplin/htmlpack@workspace:packages/htmlpack":
version: 0.0.0-use.local
resolution: "@joplin/htmlpack@workspace:packages/htmlpack"
dependencies:
"@adobe/css-tools": "npm:4.5.0"
"@joplin/fork-htmlparser2": "npm:^4.1.64"
"@joplin/fork-htmlparser2": "npm:^4.1.66"
"@types/fs-extra": "npm:11.0.4"
"@types/jest": "npm:29.5.14"
datauri: "npm:4.1.0"
@@ -12864,7 +12864,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/lib@npm:^3.7.1, @joplin/lib@npm:~3.7, @joplin/lib@workspace:packages/lib":
"@joplin/lib@npm:^3.7.3, @joplin/lib@npm:~3.7, @joplin/lib@workspace:packages/lib":
version: 0.0.0-use.local
resolution: "@joplin/lib@workspace:packages/lib"
dependencies:
@@ -12872,15 +12872,15 @@ __metadata:
"@aws-sdk/client-s3": "npm:3.296.0"
"@aws-sdk/s3-request-presigner": "npm:3.296.0"
"@huggingface/transformers": "npm:4.2.0"
"@joplin/fork-htmlparser2": "npm:^4.1.64"
"@joplin/fork-sax": "npm:^1.2.68"
"@joplin/fork-uslug": "npm:^2.0.7"
"@joplin/htmlpack": "npm:^3.7.1"
"@joplin/onenote-converter": "npm:^3.7.1"
"@joplin/renderer": "npm:^3.7.1"
"@joplin/turndown": "npm:^4.0.86"
"@joplin/turndown-plugin-gfm": "npm:^1.0.68"
"@joplin/utils": "npm:^3.7.1"
"@joplin/fork-htmlparser2": "npm:^4.1.66"
"@joplin/fork-sax": "npm:^1.2.70"
"@joplin/fork-uslug": "npm:^2.0.9"
"@joplin/htmlpack": "npm:^3.7.3"
"@joplin/onenote-converter": "npm:^3.7.3"
"@joplin/renderer": "npm:^3.7.3"
"@joplin/turndown": "npm:^4.0.88"
"@joplin/turndown-plugin-gfm": "npm:^1.0.70"
"@joplin/utils": "npm:^3.7.3"
"@testing-library/dom": "npm:10.4.1"
"@testing-library/react": "npm:16.3.2"
"@types/adm-zip": "npm:0.5.8"
@@ -12925,7 +12925,7 @@ __metadata:
moment: "npm:2.30.1"
multiparty: "npm:4.3.0"
mustache: "npm:4.2.0"
nanoid: "npm:5.1.16"
nanoid: "npm:3.3.11"
node-diff3: "npm:3.2.1"
node-notifier: "npm:10.0.1"
node-persist: "npm:3.1.3"
@@ -12961,7 +12961,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/onenote-converter@npm:^3.7.1, @joplin/onenote-converter@npm:~3.7, @joplin/onenote-converter@workspace:packages/onenote-converter":
"@joplin/onenote-converter@npm:^3.7.3, @joplin/onenote-converter@npm:~3.7, @joplin/onenote-converter@workspace:packages/onenote-converter":
version: 0.0.0-use.local
resolution: "@joplin/onenote-converter@workspace:packages/onenote-converter"
dependencies:
@@ -13005,9 +13005,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@joplin/plugin-repo-cli@workspace:packages/plugin-repo-cli"
dependencies:
"@joplin/lib": "npm:^3.7.1"
"@joplin/tools": "npm:^3.7.1"
"@joplin/utils": "npm:^3.7.1"
"@joplin/lib": "npm:^3.7.3"
"@joplin/tools": "npm:^3.7.3"
"@joplin/utils": "npm:^3.7.3"
"@types/fs-extra": "npm:11.0.4"
"@types/jest": "npm:29.5.14"
"@types/node": "npm:22.19.21"
@@ -13055,13 +13055,13 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/renderer@npm:^3.7.1, @joplin/renderer@npm:~3.7, @joplin/renderer@workspace:packages/renderer":
"@joplin/renderer@npm:^3.7.3, @joplin/renderer@npm:~3.7, @joplin/renderer@workspace:packages/renderer":
version: 0.0.0-use.local
resolution: "@joplin/renderer@workspace:packages/renderer"
dependencies:
"@joplin/fork-htmlparser2": "npm:^4.1.64"
"@joplin/fork-uslug": "npm:^2.0.7"
"@joplin/utils": "npm:^3.7.1"
"@joplin/fork-htmlparser2": "npm:^4.1.66"
"@joplin/fork-uslug": "npm:^2.0.9"
"@joplin/utils": "npm:^3.7.3"
"@types/jest": "npm:29.5.14"
"@types/json5": "npm:2.2.0"
"@types/markdown-it": "npm:13.0.9"
@@ -13165,15 +13165,15 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/tools@npm:^3.7.1, @joplin/tools@npm:~3.7, @joplin/tools@workspace:packages/tools":
"@joplin/tools@npm:^3.7.3, @joplin/tools@npm:~3.7, @joplin/tools@workspace:packages/tools":
version: 0.0.0-use.local
resolution: "@joplin/tools@workspace:packages/tools"
dependencies:
"@docusaurus/plugin-sitemap": "npm:3.10.1"
"@joplin/fork-htmlparser2": "npm:^4.1.64"
"@joplin/lib": "npm:^3.7.1"
"@joplin/renderer": "npm:^3.7.1"
"@joplin/utils": "npm:^3.7.1"
"@joplin/fork-htmlparser2": "npm:^4.1.66"
"@joplin/lib": "npm:^3.7.3"
"@joplin/renderer": "npm:^3.7.3"
"@joplin/utils": "npm:^3.7.3"
"@rmp135/sql-ts": "npm:1.18.1"
"@types/fs-extra": "npm:11.0.4"
"@types/jest": "npm:29.5.14"
@@ -13243,7 +13243,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/turndown-plugin-gfm@npm:^1.0.68, @joplin/turndown-plugin-gfm@npm:~1.0.62, @joplin/turndown-plugin-gfm@workspace:packages/turndown-plugin-gfm":
"@joplin/turndown-plugin-gfm@npm:^1.0.70, @joplin/turndown-plugin-gfm@npm:~1.0.62, @joplin/turndown-plugin-gfm@workspace:packages/turndown-plugin-gfm":
version: 0.0.0-use.local
resolution: "@joplin/turndown-plugin-gfm@workspace:packages/turndown-plugin-gfm"
dependencies:
@@ -13255,7 +13255,7 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/turndown@npm:^4.0.86, @joplin/turndown@npm:~4.0.80, @joplin/turndown@workspace:packages/turndown":
"@joplin/turndown@npm:^4.0.88, @joplin/turndown@npm:~4.0.80, @joplin/turndown@workspace:packages/turndown":
version: 0.0.0-use.local
resolution: "@joplin/turndown@workspace:packages/turndown"
dependencies:
@@ -13272,11 +13272,11 @@ __metadata:
languageName: unknown
linkType: soft
"@joplin/utils@npm:^3.7.1, @joplin/utils@npm:~3.7, @joplin/utils@workspace:packages/utils":
"@joplin/utils@npm:^3.7.3, @joplin/utils@npm:~3.7, @joplin/utils@workspace:packages/utils":
version: 0.0.0-use.local
resolution: "@joplin/utils@workspace:packages/utils"
dependencies:
"@joplin/fork-htmlparser2": "npm:^4.1.64"
"@joplin/fork-htmlparser2": "npm:^4.1.66"
"@types/fs-extra": "npm:11.0.4"
"@types/jest": "npm:29.5.14"
"@types/markdown-it": "npm:13.0.9"