mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Merge branch 'release-3.7' into dev
This commit is contained in:
commit
6ae9353849
74 files changed
+2120
-304
No files matched your search
+11
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 })));
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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 = `}), [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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -324,6 +324,8 @@ async function clearDatabase(id: number = null) {
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
const tableNames = [
|
||||
'chat_conversations',
|
||||
'chat_messages',
|
||||
'deleted_items',
|
||||
'folders',
|
||||
'item_changes',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -163,6 +163,7 @@
|
||||
"react-native-reanimated",
|
||||
|
||||
// Cannot upgrade further due to ESM support
|
||||
"nanoid",
|
||||
"node-fetch",
|
||||
"execa",
|
||||
"open",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user