Merge branch 'develop' of https://github.com/TriliumNext/Notes into develop

This commit is contained in:
Elian Doran
2025-06-06 23:26:34 +03:00
246 changed files with 16097 additions and 4724 deletions

View File

@@ -269,14 +269,32 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
const blob = await this.note.getBlob();
if (!blob) {
return false;
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {
this.resetViewScope();
}
const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode");
const viewScope = this.viewScope!;
return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled");
if (viewScope.isReadOnly === undefined) {
const blob = await this.note.getBlob();
if (!blob) {
viewScope.isReadOnly = false;
return false;
}
const sizeLimit = this.note.type === "text"
? options.getInt("autoReadonlySizeText")
: options.getInt("autoReadonlySizeCode");
viewScope.isReadOnly = Boolean(sizeLimit &&
blob.contentLength > sizeLimit &&
!this.note.isLabelTruthy("autoReadOnlyDisabled"));
}
// Return the cached decision, which won't change until viewScope is reset
return viewScope.isReadOnly || false;
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {

View File

@@ -192,13 +192,16 @@ class ContextMenu {
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
})
.on("mouseup", (e) =>{
});
if (!this.isMobile) {
$item.on("mouseup", (e) =>{
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
});
}
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");

View File

@@ -8,7 +8,7 @@ interface Entity {
export interface EntityChange {
id?: number | null;
noteId?: string;
entityName: EntityRowNames;
entityName: EntityType;
entityId: string;
entity?: Entity;
positions?: Record<string, number>;
@@ -22,3 +22,5 @@ export interface EntityChange {
changeId?: string | null;
instanceId?: string | null;
}
export type EntityType = "notes" | "branches" | "attributes" | "note_reordering" | "revisions" | "options" | "attachments" | "blobs" | "etapi_tokens" | "note_embeddings";

View File

@@ -35,8 +35,8 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
// NOOP
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens" || ec.entityName === "note_embeddings") {
// NOOP - these entities are handled at the backend level and don't require frontend processing
} else {
throw new Error(`Unknown entityName '${ec.entityName}'`);
}

View File

@@ -115,6 +115,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
export default {
updateDisplayedShortcuts,
setupActionsForElement,
getAction,
getActions,
getActionsForScope
};

View File

@@ -16,4 +16,24 @@ describe("Link", () => {
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("parses notePath with spaces", () => {
const output = parseNavigationStateFromUrl(` #root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
});
it("ignores external URL with internal hash anchor", () => {
const output = parseNavigationStateFromUrl(`https://en.wikipedia.org/wiki/Bearded_Collie#Health`);
expect(output).toMatchObject({});
});
it("ignores malformed but hash-containing external URL", () => {
const output = parseNavigationStateFromUrl("https://abc.com/#drop?searchString=firefox");
expect(output).toStrictEqual({});
});
it("ignores non-hash internal path", () => {
const output = parseNavigationStateFromUrl("/root/abc123");
expect(output).toStrictEqual({});
});
});

View File

@@ -48,6 +48,13 @@ export interface ViewScope {
viewMode?: ViewMode;
attachmentId?: string;
readOnlyTemporarilyDisabled?: boolean;
/**
* If true, it indicates that the note in the view should be opened in read-only mode (for supported note types such as text or code).
*
* The reason why we store this information here is that a note can become read-only as the user types content in it, and we wouldn't want
* to immediately enter read-only mode.
*/
isReadOnly?: boolean;
highlightsListPreviousVisible?: boolean;
highlightsListTemporarilyHidden?: boolean;
tocTemporarilyHidden?: boolean;
@@ -204,11 +211,17 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
return {};
}
url = url.trim();
const hashIdx = url.indexOf("#");
if (hashIdx === -1) {
return {};
}
// Exclude external links that contain #
if (hashIdx !== 0 && !url.includes("/#root") && !url.includes("/#?searchString")) {
return {};
}
const hash = url.substr(hashIdx + 1); // strip also the initial '#'
let [notePath, paramString] = hash.split("?");

View File

@@ -44,9 +44,17 @@ interface OptionRow {}
interface NoteReorderingRow {}
interface ContentNoteIdToComponentIdRow {
interface NoteEmbeddingRow {
embedId: string;
noteId: string;
componentId: string;
providerId: string;
modelId: string;
dimension: number;
version: number;
dateCreated: string;
utcDateCreated: string;
dateModified: string;
utcDateModified: string;
}
type EntityRowMappings = {
@@ -56,6 +64,7 @@ type EntityRowMappings = {
options: OptionRow;
revisions: RevisionRow;
note_reordering: NoteReorderingRow;
note_embeddings: NoteEmbeddingRow;
};
export type EntityRowNames = keyof EntityRowMappings;

View File

@@ -124,8 +124,12 @@ function formatDateISO(date: Date) {
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
}
function formatDateTime(date: Date) {
return `${formatDate(date)} ${formatTime(date)}`;
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
if (userSuppliedFormat?.trim()) {
return dayjs(date).format(userSuppliedFormat);
} else {
return `${formatDate(date)} ${formatTime(date)}`;
}
}
function localNowDateTime() {

View File

@@ -24,7 +24,7 @@
--bs-body-font-family: var(--main-font-family) !important;
--bs-body-font-weight: var(--main-font-weight) !important;
--bs-body-color: var(--main-text-color) !important;
--bs-body-bg: var(--main-background-color) !important;
--bs-body-bg: var(--main-background-color) !important;
}
.table {
@@ -326,6 +326,7 @@ button kbd {
user-select: none;
-webkit-user-select: none;
--bs-dropdown-zindex: 999;
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
}
body.desktop .dropdown-menu {

View File

@@ -70,6 +70,7 @@
--scrollbar-border-color: #666;
--scrollbar-background-color: #333;
--selection-background-color: #3399FF70;
--tooltip-background-color: #333;
--link-color: lightskyblue;

View File

@@ -74,6 +74,7 @@ html {
--scrollbar-border-color: #ddd;
--scrollbar-background-color: #ddd;
--selection-background-color: #3399FF70;
--tooltip-background-color: #f8f8f8;
--link-color: blue;

View File

@@ -108,6 +108,25 @@ div.editability-dropdown a.dropdown-item {
font-size: 0.85em;
}
/*
* Edited notes (for calendar notes)
*/
/* The path of the note */
.edited-notes-list small {
margin-inline-start: 4px;
font-size: inherit;
color: var(--muted-text-color);
}
.edited-notes-list small::before {
content: "(";
}
.edited-notes-list small::after {
content: ")";
}
/*
* Owned attributes
*/

View File

@@ -1402,6 +1402,7 @@ div.floating-buttons .show-floating-buttons-button:active {
div.floating-buttons-children .close-floating-buttons-button::before,
div.floating-buttons .show-floating-buttons-button::before {
display: block;
line-height: 1;
}
/* "Show buttons" button */

View File

@@ -1431,6 +1431,12 @@
"label": "Automatic read-only size (text notes)",
"unit": "characters"
},
"custom_date_time_format": {
"title": "Custom Date/Time Format",
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
"format_string": "Format string:",
"formatted_time": "Formatted date/time:"
},
"i18n": {
"title": "Localization",
"language": "Language",

View File

@@ -6,8 +6,10 @@ import type { SessionResponse } from "./types.js";
/**
* Create a new chat session
* @param currentNoteId - Optional current note ID for context
* @returns The noteId of the created chat note
*/
export async function createChatSession(currentNoteId?: string): Promise<{chatNoteId: string | null, noteId: string | null}> {
export async function createChatSession(currentNoteId?: string): Promise<string | null> {
try {
const resp = await server.post<SessionResponse>('llm/chat', {
title: 'Note Chat',
@@ -15,48 +17,42 @@ export async function createChatSession(currentNoteId?: string): Promise<{chatNo
});
if (resp && resp.id) {
// The backend might provide the noteId separately from the chatNoteId
// If noteId is provided, use it; otherwise, we'll need to query for it separately
return {
chatNoteId: resp.id,
noteId: resp.noteId || null
};
// Backend returns the chat note ID as 'id'
return resp.id;
}
} catch (error) {
console.error('Failed to create chat session:', error);
}
return {
chatNoteId: null,
noteId: null
};
return null;
}
/**
* Check if a session exists
* Check if a chat note exists
* @param noteId - The ID of the chat note
*/
export async function checkSessionExists(chatNoteId: string): Promise<boolean> {
export async function checkSessionExists(noteId: string): Promise<boolean> {
try {
// Validate that we have a proper note ID format, not a session ID
// Note IDs in Trilium are typically longer or in a different format
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.warn(`Invalid note ID format detected: ${chatNoteId} appears to be a legacy session ID`);
return false;
}
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${chatNoteId}`);
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${noteId}`);
return !!(sessionCheck && sessionCheck.id);
} catch (error: any) {
console.log(`Error checking chat note ${chatNoteId}:`, error);
console.log(`Error checking chat note ${noteId}:`, error);
return false;
}
}
/**
* Set up streaming response via WebSocket
* @param noteId - The ID of the chat note
* @param messageParams - Message parameters
* @param onContentUpdate - Callback for content updates
* @param onThinkingUpdate - Callback for thinking updates
* @param onToolExecution - Callback for tool execution
* @param onComplete - Callback for completion
* @param onError - Callback for errors
*/
export async function setupStreamingResponse(
chatNoteId: string,
noteId: string,
messageParams: any,
onContentUpdate: (content: string, isDone?: boolean) => void,
onThinkingUpdate: (thinking: string) => void,
@@ -64,35 +60,24 @@ export async function setupStreamingResponse(
onComplete: () => void,
onError: (error: Error) => void
): Promise<void> {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
onError(new Error("Invalid note ID format - using a legacy session ID"));
return;
}
return new Promise((resolve, reject) => {
let assistantResponse = '';
let postToolResponse = ''; // Separate accumulator for post-tool execution content
let receivedAnyContent = false;
let receivedPostToolContent = false; // Track if we've started receiving post-tool content
let timeoutId: number | null = null;
let initialTimeoutId: number | null = null;
let cleanupTimeoutId: number | null = null;
let receivedAnyMessage = false;
let toolsExecuted = false; // Flag to track if tools were executed in this session
let toolExecutionCompleted = false; // Flag to track if tool execution is completed
let eventListener: ((event: Event) => void) | null = null;
let lastMessageTimestamp = 0;
// Create a unique identifier for this response process
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${chatNoteId}`);
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${noteId}`);
// Send the initial request to initiate streaming
(async () => {
try {
const streamResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages/stream`, {
const streamResponse = await server.post<any>(`llm/chat/${noteId}/messages/stream`, {
content: messageParams.content,
useAdvancedContext: messageParams.useAdvancedContext,
showThinking: messageParams.showThinking,
@@ -129,28 +114,14 @@ export async function setupStreamingResponse(
resolve();
};
// Function to schedule cleanup with ability to cancel
const scheduleCleanup = (delay: number) => {
// Clear any existing cleanup timeout
if (cleanupTimeoutId) {
window.clearTimeout(cleanupTimeoutId);
// Set initial timeout to catch cases where no message is received at all
initialTimeoutId = window.setTimeout(() => {
if (!receivedAnyMessage) {
console.error(`[${responseId}] No initial message received within timeout`);
performCleanup();
reject(new Error('No response received from server'));
}
console.log(`[${responseId}] Scheduling listener cleanup in ${delay}ms`);
// Set new cleanup timeout
cleanupTimeoutId = window.setTimeout(() => {
// Only clean up if no messages received recently (in last 2 seconds)
const timeSinceLastMessage = Date.now() - lastMessageTimestamp;
if (timeSinceLastMessage > 2000) {
performCleanup();
} else {
console.log(`[${responseId}] Received message recently, delaying cleanup`);
// Reschedule cleanup
scheduleCleanup(2000);
}
}, delay);
};
}, 10000);
// Create a message handler for CustomEvents
eventListener = (event: Event) => {
@@ -158,7 +129,7 @@ export async function setupStreamingResponse(
const message = customEvent.detail;
// Only process messages for our chat note
if (!message || message.chatNoteId !== chatNoteId) {
if (!message || message.chatNoteId !== noteId) {
return;
}
@@ -172,12 +143,12 @@ export async function setupStreamingResponse(
cleanupTimeoutId = null;
}
console.log(`[${responseId}] LLM Stream message received via CustomEvent: chatNoteId=${chatNoteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}, type=${message.type || 'llm-stream'}`);
console.log(`[${responseId}] LLM Stream message received: content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`);
// Mark first message received
if (!receivedAnyMessage) {
receivedAnyMessage = true;
console.log(`[${responseId}] First message received for chat note ${chatNoteId}`);
console.log(`[${responseId}] First message received for chat note ${noteId}`);
// Clear the initial timeout since we've received a message
if (initialTimeoutId !== null) {
@@ -186,109 +157,33 @@ export async function setupStreamingResponse(
}
}
// Handle specific message types
if (message.type === 'tool_execution_start') {
toolsExecuted = true; // Mark that tools were executed
onThinkingUpdate('Executing tools...');
// Also trigger tool execution UI with a specific format
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Executing tools...'
});
return; // Skip accumulating content from this message
// Handle error
if (message.error) {
console.error(`[${responseId}] Stream error: ${message.error}`);
performCleanup();
reject(new Error(message.error));
return;
}
if (message.type === 'tool_result' && message.toolExecution) {
toolsExecuted = true; // Mark that tools were executed
console.log(`[${responseId}] Processing tool result: ${JSON.stringify(message.toolExecution)}`);
// Handle thinking updates - only show if showThinking is enabled
if (message.thinking && messageParams.showThinking) {
console.log(`[${responseId}] Received thinking: ${message.thinking.substring(0, 100)}...`);
onThinkingUpdate(message.thinking);
}
// If tool execution doesn't have an action, add 'result' as the default
if (!message.toolExecution.action) {
message.toolExecution.action = 'result';
}
// First send a 'start' action to ensure the container is created
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Tool execution initialized'
});
// Then send the actual tool execution data
// Handle tool execution updates
if (message.toolExecution) {
console.log(`[${responseId}] Tool execution update:`, message.toolExecution);
onToolExecution(message.toolExecution);
// Mark tool execution as completed if this is a result or error
if (message.toolExecution.action === 'result' || message.toolExecution.action === 'complete' || message.toolExecution.action === 'error') {
toolExecutionCompleted = true;
console.log(`[${responseId}] Tool execution completed`);
}
return; // Skip accumulating content from this message
}
if (message.type === 'tool_execution_error' && message.toolExecution) {
toolsExecuted = true; // Mark that tools were executed
toolExecutionCompleted = true; // Mark tool execution as completed
onToolExecution({
...message.toolExecution,
action: 'error',
error: message.toolExecution.error || 'Unknown error during tool execution'
});
return; // Skip accumulating content from this message
}
if (message.type === 'tool_completion_processing') {
toolsExecuted = true; // Mark that tools were executed
toolExecutionCompleted = true; // Tools are done, now processing the result
onThinkingUpdate('Generating response with tool results...');
// Also trigger tool execution UI with a specific format
onToolExecution({
action: 'generating',
tool: 'tools',
result: 'Generating response with tool results...'
});
return; // Skip accumulating content from this message
}
// Handle content updates
if (message.content) {
console.log(`[${responseId}] Received content chunk of length ${message.content.length}, preview: "${message.content.substring(0, 50)}${message.content.length > 50 ? '...' : ''}"`);
// If tools were executed and completed, and we're now getting new content,
// this is likely the final response after tool execution from Anthropic
if (toolsExecuted && toolExecutionCompleted && message.content) {
console.log(`[${responseId}] Post-tool execution content detected`);
// If this is the first post-tool chunk, indicate we're starting a new response
if (!receivedPostToolContent) {
receivedPostToolContent = true;
postToolResponse = ''; // Clear any previous post-tool response
console.log(`[${responseId}] First post-tool content chunk, starting fresh accumulation`);
}
// Accumulate post-tool execution content
postToolResponse += message.content;
console.log(`[${responseId}] Accumulated post-tool content, now ${postToolResponse.length} chars`);
// Update the UI with the accumulated post-tool content
// This replaces the pre-tool content with our accumulated post-tool content
onContentUpdate(postToolResponse, message.done || false);
} else {
// Standard content handling for non-tool cases or initial tool response
// Check if this is a duplicated message containing the same content we already have
if (message.done && assistantResponse.includes(message.content)) {
console.log(`[${responseId}] Ignoring duplicated content in done message`);
} else {
// Add to our accumulated response
assistantResponse += message.content;
}
// Update the UI immediately with each chunk
onContentUpdate(assistantResponse, message.done || false);
}
// Simply append the new content - no complex deduplication
assistantResponse += message.content;
// Update the UI immediately with each chunk
onContentUpdate(assistantResponse, message.done || false);
receivedAnyContent = true;
// Reset timeout since we got content
@@ -298,151 +193,33 @@ export async function setupStreamingResponse(
// Set new timeout
timeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`);
// Clean up
console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`);
performCleanup();
reject(new Error('Stream timeout'));
}, 30000);
}
// Handle tool execution updates (legacy format and standard format with llm-stream type)
if (message.toolExecution) {
// Only process if we haven't already handled this message via specific message types
if (message.type === 'llm-stream' || !message.type) {
console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`);
toolsExecuted = true; // Mark that tools were executed
// Mark tool execution as completed if this is a result or error
if (message.toolExecution.action === 'result' ||
message.toolExecution.action === 'complete' ||
message.toolExecution.action === 'error') {
toolExecutionCompleted = true;
console.log(`[${responseId}] Tool execution completed via toolExecution message`);
}
onToolExecution(message.toolExecution);
}
}
// Handle tool calls from the raw data or direct in message (OpenAI format)
const toolCalls = message.tool_calls || (message.raw && message.raw.tool_calls);
if (toolCalls && Array.isArray(toolCalls)) {
console.log(`[${responseId}] Received tool calls: ${toolCalls.length} tools`);
toolsExecuted = true; // Mark that tools were executed
// First send a 'start' action to ensure the container is created
onToolExecution({
action: 'start',
tool: 'tools',
result: 'Tool execution initialized'
});
// Then process each tool call
for (const toolCall of toolCalls) {
let args = toolCall.function?.arguments || {};
// Try to parse arguments if they're a string
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
console.log(`[${responseId}] Could not parse tool arguments as JSON: ${e}`);
args = { raw: args };
}
}
onToolExecution({
action: 'executing',
tool: toolCall.function?.name || 'unknown',
toolCallId: toolCall.id,
args: args
});
}
}
// Handle thinking state updates
if (message.thinking) {
console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`);
onThinkingUpdate(message.thinking);
}
// Handle completion
if (message.done) {
console.log(`[${responseId}] Stream completed for chat note ${chatNoteId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`);
console.log(`[${responseId}] Stream completed for chat note ${noteId}, final response: ${assistantResponse.length} chars`);
// Dump message content to console for debugging
if (message.content) {
console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`);
// Check if the done message contains the exact same content as our accumulated response
// We normalize by removing whitespace to avoid false negatives due to spacing differences
const normalizedMessage = message.content.trim();
const normalizedResponse = assistantResponse.trim();
if (normalizedMessage === normalizedResponse) {
console.log(`[${responseId}] Final message is identical to accumulated response, no need to update`);
}
// If the done message is longer but contains our accumulated response, use the done message
else if (normalizedMessage.includes(normalizedResponse) && normalizedMessage.length > normalizedResponse.length) {
console.log(`[${responseId}] Final message is more complete than accumulated response, using it`);
assistantResponse = message.content;
}
// If the done message is different and not already included, append it to avoid duplication
else if (!normalizedResponse.includes(normalizedMessage) && normalizedMessage.length > 0) {
console.log(`[${responseId}] Final message has unique content, using it`);
assistantResponse = message.content;
}
// Otherwise, we already have the content accumulated, so no need to update
else {
console.log(`[${responseId}] Already have this content accumulated, not updating`);
}
}
// Clear timeout if set
// Clear all timeouts
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
// Always mark as done when we receive the done flag
onContentUpdate(assistantResponse, true);
// Set a longer delay before cleanup to allow for post-tool execution messages
// Especially important for Anthropic which may send final message after tool execution
const cleanupDelay = toolsExecuted ? 15000 : 1000; // 15 seconds if tools were used, otherwise 1 second
console.log(`[${responseId}] Setting cleanup delay of ${cleanupDelay}ms since toolsExecuted=${toolsExecuted}`);
scheduleCleanup(cleanupDelay);
// Schedule cleanup after a brief delay to ensure all processing is complete
cleanupTimeoutId = window.setTimeout(() => {
performCleanup();
}, 100);
}
};
// Register event listener for the custom event
try {
window.addEventListener('llm-stream-message', eventListener);
console.log(`[${responseId}] Event listener added for llm-stream-message events`);
} catch (err) {
console.error(`[${responseId}] Error setting up event listener:`, err);
reject(err);
return;
}
// Register the event listener for WebSocket messages
window.addEventListener('llm-stream-message', eventListener);
// Set initial timeout for receiving any message
initialTimeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] No messages received for initial period in chat note ${chatNoteId}`);
if (!receivedAnyMessage) {
console.error(`[${responseId}] WebSocket connection not established for chat note ${chatNoteId}`);
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
// Clean up
cleanupEventListener(eventListener);
// Show error message to user
reject(new Error('WebSocket connection not established'));
}
}, 10000);
console.log(`[${responseId}] Event listener registered, waiting for messages...`);
});
}
@@ -463,15 +240,9 @@ function cleanupEventListener(listener: ((event: Event) => void) | null): void {
/**
* Get a direct response from the server without streaming
*/
export async function getDirectResponse(chatNoteId: string, messageParams: any): Promise<any> {
export async function getDirectResponse(noteId: string, messageParams: any): Promise<any> {
try {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
throw new Error("Invalid note ID format - using a legacy session ID");
}
const postResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages`, {
const postResponse = await server.post<any>(`llm/chat/${noteId}/messages`, {
message: messageParams.content,
includeContext: messageParams.useAdvancedContext,
options: {

View File

@@ -37,9 +37,10 @@ export default class LlmChatPanel extends BasicWidget {
private thinkingBubble!: HTMLElement;
private thinkingText!: HTMLElement;
private thinkingToggle!: HTMLElement;
private chatNoteId: string | null = null;
private noteId: string | null = null; // The actual noteId for the Chat Note
private currentNoteId: string | null = null;
// Simplified to just use noteId - this represents the AI Chat note we're working with
private noteId: string | null = null;
private currentNoteId: string | null = null; // The note providing context (for regular notes)
private _messageHandlerId: number | null = null;
private _messageHandler: any = null;
@@ -68,7 +69,6 @@ export default class LlmChatPanel extends BasicWidget {
totalTokens?: number;
};
} = {
model: 'default',
temperature: 0.7,
toolExecutions: []
};
@@ -90,12 +90,21 @@ export default class LlmChatPanel extends BasicWidget {
this.messages = messages;
}
public getChatNoteId(): string | null {
return this.chatNoteId;
public getNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(chatNoteId: string | null): void {
this.chatNoteId = chatNoteId;
public setNoteId(noteId: string | null): void {
this.noteId = noteId;
}
// Deprecated - keeping for backward compatibility but mapping to noteId
public getChatNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(noteId: string | null): void {
this.noteId = noteId;
}
public getNoteContextChatMessages(): HTMLElement {
@@ -307,16 +316,22 @@ export default class LlmChatPanel extends BasicWidget {
}
}
const dataToSave: ChatData = {
// Only save if we have a valid note ID
if (!this.noteId) {
console.warn('Cannot save chat data: no noteId available');
return;
}
const dataToSave = {
messages: this.messages,
chatNoteId: this.chatNoteId,
noteId: this.noteId,
chatNoteId: this.noteId, // For backward compatibility
toolSteps: toolSteps,
// Add sources if we have them
sources: this.sources || [],
// Add metadata
metadata: {
model: this.metadata?.model || 'default',
model: this.metadata?.model || undefined,
provider: this.metadata?.provider || undefined,
temperature: this.metadata?.temperature || 0.7,
lastUpdated: new Date().toISOString(),
@@ -325,7 +340,7 @@ export default class LlmChatPanel extends BasicWidget {
}
};
console.log(`Saving chat data with chatNoteId: ${this.chatNoteId}, noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
console.log(`Saving chat data with noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
// Save the data to the note attribute via the callback
// This is the ONLY place we should save data, letting the container widget handle persistence
@@ -347,16 +362,52 @@ export default class LlmChatPanel extends BasicWidget {
const savedData = await this.onGetData() as ChatData;
if (savedData?.messages?.length > 0) {
// Check if we actually have new content to avoid unnecessary UI rebuilds
const currentMessageCount = this.messages.length;
const savedMessageCount = savedData.messages.length;
// If message counts are the same, check if content is different
const hasNewContent = savedMessageCount > currentMessageCount ||
JSON.stringify(this.messages) !== JSON.stringify(savedData.messages);
if (!hasNewContent) {
console.log("No new content detected, skipping UI rebuild");
return true;
}
console.log(`Loading saved data: ${currentMessageCount} -> ${savedMessageCount} messages`);
// Store current scroll position if we need to preserve it
const shouldPreserveScroll = savedMessageCount > currentMessageCount && currentMessageCount > 0;
const currentScrollTop = shouldPreserveScroll ? this.chatContainer.scrollTop : 0;
const currentScrollHeight = shouldPreserveScroll ? this.chatContainer.scrollHeight : 0;
// Load messages
const oldMessages = [...this.messages];
this.messages = savedData.messages;
// Clear and rebuild the chat UI
this.noteContextChatMessages.innerHTML = '';
// Only rebuild UI if we have significantly different content
if (savedMessageCount > currentMessageCount) {
// We have new messages - just add the new ones instead of rebuilding everything
const newMessages = savedData.messages.slice(currentMessageCount);
console.log(`Adding ${newMessages.length} new messages to UI`);
this.messages.forEach(message => {
const role = message.role as 'user' | 'assistant';
this.addMessageToChat(role, message.content);
});
newMessages.forEach(message => {
const role = message.role as 'user' | 'assistant';
this.addMessageToChat(role, message.content);
});
} else {
// Content changed but count is same - need to rebuild
console.log("Message content changed, rebuilding UI");
// Clear and rebuild the chat UI
this.noteContextChatMessages.innerHTML = '';
this.messages.forEach(message => {
const role = message.role as 'user' | 'assistant';
this.addMessageToChat(role, message.content);
});
}
// Restore tool execution steps if they exist
if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) {
@@ -400,13 +451,33 @@ export default class LlmChatPanel extends BasicWidget {
// Load Chat Note ID if available
if (savedData.noteId) {
console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`);
this.chatNoteId = savedData.noteId;
this.noteId = savedData.noteId;
} else {
console.log(`No noteId found in saved data, cannot load chat session`);
return false;
}
// Restore scroll position if we were preserving it
if (shouldPreserveScroll) {
// Calculate the new scroll position to maintain relative position
const newScrollHeight = this.chatContainer.scrollHeight;
const scrollDifference = newScrollHeight - currentScrollHeight;
const newScrollTop = currentScrollTop + scrollDifference;
// Only scroll down if we're near the bottom, otherwise preserve exact position
const wasNearBottom = (currentScrollTop + this.chatContainer.clientHeight) >= (currentScrollHeight - 50);
if (wasNearBottom) {
// User was at bottom, scroll to new bottom
this.chatContainer.scrollTop = newScrollHeight;
console.log("User was at bottom, scrolling to new bottom");
} else {
// User was not at bottom, try to preserve their position
this.chatContainer.scrollTop = newScrollTop;
console.log(`Preserving scroll position: ${currentScrollTop} -> ${newScrollTop}`);
}
}
return true;
}
} catch (error) {
@@ -550,6 +621,15 @@ export default class LlmChatPanel extends BasicWidget {
// Get current note context if needed
const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null;
// For AI Chat notes, the note itself IS the chat session
// So currentNoteId and noteId should be the same
if (this.noteId && currentActiveNoteId === this.noteId) {
// We're in an AI Chat note - don't reset, just load saved data
console.log(`Refreshing AI Chat note ${this.noteId} - loading saved data`);
await this.loadSavedData();
return;
}
// If we're switching to a different note, we need to reset
if (this.currentNoteId !== currentActiveNoteId) {
console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`);
@@ -557,7 +637,6 @@ export default class LlmChatPanel extends BasicWidget {
// Reset the UI and data
this.noteContextChatMessages.innerHTML = '';
this.messages = [];
this.chatNoteId = null;
this.noteId = null; // Also reset the chat note ID
this.hideSources(); // Hide any sources from previous note
@@ -569,7 +648,7 @@ export default class LlmChatPanel extends BasicWidget {
const hasSavedData = await this.loadSavedData();
// Only create a new session if we don't have a session or saved data
if (!this.chatNoteId || !this.noteId || !hasSavedData) {
if (!this.noteId || !hasSavedData) {
// Create a new chat session
await this.createChatSession();
}
@@ -580,19 +659,15 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async createChatSession() {
try {
// Create a new chat session, passing the current note ID if it exists
const { chatNoteId, noteId } = await createChatSession(
this.currentNoteId ? this.currentNoteId : undefined
);
// If we already have a noteId (for AI Chat notes), use it
const contextNoteId = this.noteId || this.currentNoteId;
if (chatNoteId) {
// If we got back an ID from the API, use it
this.chatNoteId = chatNoteId;
// For new sessions, the noteId should equal the chatNoteId
// This ensures we're using the note ID consistently
this.noteId = noteId || chatNoteId;
// Create a new chat session, passing the context note ID
const noteId = await createChatSession(contextNoteId ? contextNoteId : undefined);
if (noteId) {
// Set the note ID for this chat
this.noteId = noteId;
console.log(`Created new chat session with noteId: ${this.noteId}`);
} else {
throw new Error("Failed to create chat session - no ID returned");
@@ -645,7 +720,7 @@ export default class LlmChatPanel extends BasicWidget {
const showThinking = this.showThinkingCheckbox.checked;
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@@ -695,11 +770,11 @@ export default class LlmChatPanel extends BasicWidget {
await validateEmbeddingProviders(this.validationWarning);
// Make sure we have a valid session
if (!this.chatNoteId) {
if (!this.noteId) {
// If no session ID, create a new session
await this.createChatSession();
if (!this.chatNoteId) {
if (!this.noteId) {
// If still no session ID, show error and return
console.error("Failed to create chat session");
toastService.showError("Failed to create chat session");
@@ -730,7 +805,7 @@ export default class LlmChatPanel extends BasicWidget {
await this.saveCurrentData();
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@@ -767,12 +842,12 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async handleDirectResponse(messageParams: any): Promise<boolean> {
try {
if (!this.chatNoteId) return false;
if (!this.noteId) return false;
console.log(`Getting direct response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Getting direct response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Get a direct response from the server
const postResponse = await getDirectResponse(this.chatNoteId, messageParams);
const postResponse = await getDirectResponse(this.noteId, messageParams);
// If the POST request returned content directly, display it
if (postResponse && postResponse.content) {
@@ -845,11 +920,11 @@ export default class LlmChatPanel extends BasicWidget {
* Set up streaming response via WebSocket
*/
private async setupStreamingResponse(messageParams: any): Promise<void> {
if (!this.chatNoteId) {
if (!this.noteId) {
throw new Error("No session ID available");
}
console.log(`Setting up streaming response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Setting up streaming response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Store tool executions captured during streaming
const toolExecutionsCache: Array<{
@@ -862,7 +937,7 @@ export default class LlmChatPanel extends BasicWidget {
}> = [];
return setupStreamingResponse(
this.chatNoteId,
this.noteId,
messageParams,
// Content update handler
(content: string, isDone: boolean = false) => {
@@ -898,7 +973,7 @@ export default class LlmChatPanel extends BasicWidget {
similarity?: number;
content?: string;
}>;
}>(`llm/chat/${this.chatNoteId}`)
}>(`llm/chat/${this.noteId}`)
.then((sessionData) => {
console.log("Got updated session data:", sessionData);
@@ -933,9 +1008,9 @@ export default class LlmChatPanel extends BasicWidget {
}
}
// Save the updated data to the note
this.saveCurrentData()
.catch(err => console.error("Failed to save data after streaming completed:", err));
// DON'T save here - let the server handle saving the complete conversation
// to avoid race conditions between client and server saves
console.log("Updated metadata after streaming completion, server should save");
})
.catch(err => console.error("Error fetching session data after streaming:", err));
}
@@ -973,11 +1048,9 @@ export default class LlmChatPanel extends BasicWidget {
console.log(`Cached tool execution for ${toolData.tool} to be saved later`);
// Save immediately after receiving a tool execution
// This ensures we don't lose tool execution data if streaming fails
this.saveCurrentData().catch(err => {
console.error("Failed to save tool execution data:", err);
});
// DON'T save immediately during streaming - let the server handle saving
// to avoid race conditions between client and server saves
console.log(`Tool execution cached, will be saved by server`);
}
},
// Complete handler
@@ -995,23 +1068,19 @@ export default class LlmChatPanel extends BasicWidget {
* Update the UI with streaming content
*/
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
// Parse and handle thinking content if present
if (!isDone) {
const thinkingContent = this.parseThinkingContent(assistantResponse);
if (thinkingContent) {
this.updateThinkingText(thinkingContent);
// Don't display the raw response with think tags in the chat
return;
}
}
// Get the existing assistant message or create a new one
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
if (!assistantMessageEl) {
// If no assistant message yet, create one
// Track if we have a streaming message in progress
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
// Create a new message element or use the existing streaming one
let assistantMessageEl: HTMLElement;
if (hasStreamingMessage) {
// Use the existing streaming message
assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!;
} else {
// Create a new message element
assistantMessageEl = document.createElement('div');
assistantMessageEl.className = 'assistant-message message mb-3';
assistantMessageEl.className = 'assistant-message message mb-3 streaming';
this.noteContextChatMessages.appendChild(assistantMessageEl);
// Add assistant profile icon
@@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget {
assistantMessageEl.appendChild(messageContent);
}
// Clean the response to remove thinking tags before displaying
const cleanedResponse = this.removeThinkingTags(assistantResponse);
// Update the content
// Update the content with the current response
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
messageContent.innerHTML = formatMarkdown(cleanedResponse);
messageContent.innerHTML = formatMarkdown(assistantResponse);
// Apply syntax highlighting if this is the final update
// When the response is complete
if (isDone) {
// Remove the streaming class to mark this message as complete
assistantMessageEl.classList.remove('streaming');
// Apply syntax highlighting
formatCodeBlocks($(assistantMessageEl as HTMLElement));
// Hide the thinking display when response is complete
this.hideThinkingDisplay();
// Update message in the data model for storage
// Find the last assistant message to update, or add a new one if none exists
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
const lastAssistantMsgIndex = assistantMessages.length > 0 ?
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
if (lastAssistantMsgIndex >= 0) {
// Update existing message with cleaned content
this.messages[lastAssistantMsgIndex].content = cleanedResponse;
} else {
// Add new message with cleaned content
this.messages.push({
role: 'assistant',
content: cleanedResponse
});
}
// Hide loading indicator
hideLoadingIndicator(this.loadingIndicator);
// Save the final state to the Chat Note
this.saveCurrentData().catch(err => {
console.error("Failed to save assistant response to note:", err);
// Always add a new message to the data model
// This ensures we preserve all distinct assistant messages
this.messages.push({
role: 'assistant',
content: assistantResponse,
timestamp: new Date()
});
// Save the updated message list
this.saveCurrentData();
}
// Scroll to bottom
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
/**
* Remove thinking tags from response content
*/
private removeThinkingTags(content: string): string {
if (!content) return content;
// Remove <think>...</think> blocks from the content
return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
/**
* Handle general errors in the send message flow
*/

View File

@@ -11,7 +11,7 @@ export interface ChatResponse {
export interface SessionResponse {
id: string;
title: string;
noteId?: string;
noteId: string; // The ID of the chat note
}
export interface ToolExecutionStep {
@@ -33,8 +33,8 @@ export interface MessageData {
export interface ChatData {
messages: MessageData[];
chatNoteId: string | null;
noteId?: string | null;
noteId: string; // The ID of the chat note
chatNoteId?: string; // Deprecated - kept for backward compatibility, should equal noteId
toolSteps: ToolExecutionStep[];
sources?: Array<{
noteId: string;

View File

@@ -19,7 +19,7 @@ const TPL = /*html*/`
<div class="no-edited-notes-found">${t("edited_notes.no_edited_notes_found")}</div>
<div class="edited-notes-list"></div>
<div class="edited-notes-list use-tn-links"></div>
</div>
`;

View File

@@ -94,6 +94,11 @@ export default class AiChatTypeWidget extends TypeWidget {
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
// Set the note ID for the chat panel
if (note) {
this.llmChatPanel.setNoteId(note.noteId);
}
// This will load saved data via the getData callback
await this.llmChatPanel.refresh();
this.isInitialized = true;
@@ -130,7 +135,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Reset the chat panel UI
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
this.llmChatPanel.setChatNoteId(null);
this.llmChatPanel.setNoteId(this.note.noteId);
}
// Call the parent method to refresh
@@ -152,6 +157,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Make sure the chat panel has the current note ID
if (this.note) {
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
this.llmChatPanel.setNoteId(this.note.noteId);
}
this.initPromise = (async () => {
@@ -186,7 +192,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Format the data properly - this is the canonical format of the data
const formattedData = {
messages: data.messages || [],
chatNoteId: data.chatNoteId || this.note.noteId,
noteId: this.note.noteId, // Always use the note's own ID
toolSteps: data.toolSteps || [],
sources: data.sources || [],
metadata: {

View File

@@ -189,7 +189,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
{
label: "Insert",
icon: "plus",
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak"]
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
"outdent",
@@ -244,7 +244,7 @@ export function buildFloatingToolbar() {
{
label: "Insert",
icon: "plus",
items: ["internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak"]
items: ["bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
"outdent",

View File

@@ -8,6 +8,7 @@ import HeadingStyleOptions from "./options/text_notes/heading_style.js";
import TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
import HighlightsListOptions from "./options/text_notes/highlights_list.js";
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js";
import DateTimeFormatOptions from "./options/text_notes/date_time_format.js";
import CodeEditorOptions from "./options/code_notes/code_editor.js";
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
@@ -88,7 +89,8 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAw
CodeBlockOptions,
TableOfContentsOptions,
HighlightsListOptions,
TextAutoReadOnlySizeOptions
TextAutoReadOnlySizeOptions,
DateTimeFormatOptions
],
_optionsCodeNotes: [
CodeEditorOptions,

View File

@@ -266,7 +266,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
item.on("change:isOpen", () => {
if (!("isOpen" in item) || !item.isOpen ) {
if (!("isOpen" in item) || !item.isOpen) {
return;
}
@@ -375,9 +375,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
}
insertDateTimeToTextCommand() {
insertDateTimeToTextCommand() {
const date = new Date();
const dateString = utils.formatDateTime(date);
const customDateTimeFormat = options.get("customDateTimeFormat");
const dateString = utils.formatDateTime(date, customDateTimeFormat);
this.addTextToEditor(dateString);
}

View File

@@ -239,6 +239,9 @@ export default class GeoMapTypeWidget extends TypeWidget {
wptIcons: {
"": this.#buildIcon("bx bx-pin")
}
},
polyline_options: {
color: note.getLabelValue("color") ?? "blue"
}
});
track.addTo(this.geoMapWidget.map);

View File

@@ -0,0 +1,67 @@
import OptionsWidget from "../options_widget.js";
import { t } from "../../../../services/i18n.js";
import type { OptionMap } from "@triliumnext/commons";
import utils from "../../../../services/utils.js";
import keyboardActionsService from "../../../../services/keyboard_actions.js";
import linkService from "../../../.././services/link.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("custom_date_time_format.title")}</h4>
<p class="description">
${t("custom_date_time_format.description")}
</p>
<div class="form-group row align-items-center">
<div class="col-6">
<label for="custom-date-time-format">${t("custom_date_time_format.format_string")}</label>
<input type="text" id="custom-date-time-format" class="form-control custom-date-time-format" placeholder="YYYY-MM-DD HH:mm">
</div>
<div class="col-6">
<label>${t("custom_date_time_format.formatted_time")}</label>
<div class="formatted-date"></div>
</div>
</div>
</div>
`;
export default class DateTimeFormatOptions extends OptionsWidget {
private $formatInput!: JQuery<HTMLInputElement>;
private $formattedDate!: JQuery<HTMLInputElement>;
doRender() {
this.$widget = $(TPL);
this.$formatInput = this.$widget.find("input.custom-date-time-format");
this.$formattedDate = this.$widget.find(".formatted-date");
this.$formatInput.on("input", () => {
const dateString = utils.formatDateTime(new Date(), this.$formatInput.val());
this.$formattedDate.text(dateString);
});
this.$formatInput.on('blur keydown', (e) => {
if (e.type === 'blur' || (e.type === 'keydown' && e.key === 'Enter')) {
this.updateOption("customDateTimeFormat", this.$formatInput.val());
}
});
return this.$widget;
}
async optionsLoaded(options: OptionMap) {
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
"title": shortcutKey,
"showTooltip": false
});
this.$widget.find(".description").find("kbd").replaceWith($link);
const customDateTimeFormat = options.customDateTimeFormat || "YYYY-MM-DD HH:mm";
this.$formatInput.val(customDateTimeFormat);
const dateString = utils.formatDateTime(new Date(), customDateTimeFormat);
this.$formattedDate.text(dateString);
}
}