From c71e752cbe5ab2546b2f9bf95220362c42bcc9af Mon Sep 17 00:00:00 2001 From: raf-vale <60397490+raf-vale@users.noreply.github.com> Date: Sat, 6 Dec 2025 20:29:50 +0100 Subject: [PATCH] Add link detection in transaction notes (#6306) * Add link detection in transaction notes * update release notes * Fix trailing punctuation from URLs and add space between segments * rename openFileInExplorer to openInFileManager --- .../src/browser-preload.browser.js | 3 + .../src/notes/DesktopLinkedNotes.tsx | 78 ++++++ .../src/notes/MobileLinkedNotes.tsx | 68 +++++ .../src/notes/NotesTagFormatter.tsx | 95 ++++--- .../src/notes/linkParser.test.ts | 112 ++++++++ .../desktop-client/src/notes/linkParser.ts | 239 ++++++++++++++++++ packages/desktop-electron/index.ts | 4 + packages/desktop-electron/preload.ts | 4 + packages/loot-core/typings/window.ts | 1 + upcoming-release-notes/6306.md | 6 + 10 files changed, 572 insertions(+), 38 deletions(-) create mode 100644 packages/desktop-client/src/notes/DesktopLinkedNotes.tsx create mode 100644 packages/desktop-client/src/notes/MobileLinkedNotes.tsx create mode 100644 packages/desktop-client/src/notes/linkParser.test.ts create mode 100644 packages/desktop-client/src/notes/linkParser.ts create mode 100644 upcoming-release-notes/6306.md diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 7c59a0170e..449253ccef 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -190,6 +190,9 @@ global.Actual = { openURLInBrowser: url => { window.open(url, '_blank'); }, + openInFileManager: () => { + // File manager not available in browser + }, onEventFromMain: () => {}, isUpdateReadyForDownload: () => isUpdateReadyForDownload, waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise, diff --git a/packages/desktop-client/src/notes/DesktopLinkedNotes.tsx b/packages/desktop-client/src/notes/DesktopLinkedNotes.tsx new file mode 100644 index 0000000000..fa3e6f86f9 --- /dev/null +++ b/packages/desktop-client/src/notes/DesktopLinkedNotes.tsx @@ -0,0 +1,78 @@ +import { type MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { theme } from '@actual-app/components/theme'; +import { css } from '@emotion/css'; + +import { isElectron } from 'loot-core/shared/environment'; + +import { normalizeUrl } from './linkParser'; + +import { addNotification } from '@desktop-client/notifications/notificationsSlice'; +import { useDispatch } from '@desktop-client/redux'; + +type DesktopLinkedNotesProps = { + displayText: string; + url: string; + separator: string; + isFilePath: boolean; +}; + +const linkStyles = css({ + color: theme.pageTextLink, + textDecoration: 'underline', + cursor: 'pointer', + '&:hover': { + color: theme.pageTextLinkLight, + }, +}); + +export function DesktopLinkedNotes({ + displayText, + url, + separator, + isFilePath, +}: DesktopLinkedNotesProps) { + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const handleClick = async (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (isFilePath) { + if (isElectron()) { + // Open file in file manager + window.Actual?.openInFileManager(url); + } else { + // Browser fallback: copy to clipboard + await navigator.clipboard.writeText(url); + dispatch( + addNotification({ + notification: { + type: 'message', + message: t('File path copied to clipboard'), + }, + }), + ); + } + } else { + // Open URL in browser + const normalizedUrl = normalizeUrl(url); + window.Actual?.openURLInBrowser(normalizedUrl); + } + }; + + return ( + <> + e.stopPropagation()} + onClick={handleClick} + > + {displayText} + + {separator} + + ); +} diff --git a/packages/desktop-client/src/notes/MobileLinkedNotes.tsx b/packages/desktop-client/src/notes/MobileLinkedNotes.tsx new file mode 100644 index 0000000000..e66fc9a494 --- /dev/null +++ b/packages/desktop-client/src/notes/MobileLinkedNotes.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Text } from '@actual-app/components/text'; +import { theme } from '@actual-app/components/theme'; +import { css } from '@emotion/css'; + +import { isElectron } from 'loot-core/shared/environment'; + +import { normalizeUrl } from './linkParser'; + +import { addNotification } from '@desktop-client/notifications/notificationsSlice'; +import { useDispatch } from '@desktop-client/redux'; + +type MobileLinkedNotesProps = { + displayText: string; + url: string; + separator: string; + isFilePath: boolean; +}; + +const linkStyles = css({ + color: theme.pageTextLink, + textDecoration: 'underline', +}); + +export function MobileLinkedNotes({ + displayText, + url, + separator, + isFilePath, +}: MobileLinkedNotesProps) { + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const handleClick = async () => { + if (isFilePath) { + if (isElectron()) { + // Open file in file manager + window.Actual?.openInFileManager(url); + } else { + // Browser fallback: copy to clipboard + await navigator.clipboard.writeText(url); + dispatch( + addNotification({ + notification: { + type: 'message', + message: t('File path copied to clipboard'), + }, + }), + ); + } + } else { + // Open URL in browser + const normalizedUrl = normalizeUrl(url); + window.Actual?.openURLInBrowser(normalizedUrl); + } + }; + + return ( + <> + + {displayText} + + {separator} + + ); +} diff --git a/packages/desktop-client/src/notes/NotesTagFormatter.tsx b/packages/desktop-client/src/notes/NotesTagFormatter.tsx index 260ba8c0be..61a7c90198 100644 --- a/packages/desktop-client/src/notes/NotesTagFormatter.tsx +++ b/packages/desktop-client/src/notes/NotesTagFormatter.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { useResponsive } from '@actual-app/components/hooks/useResponsive'; +import { DesktopLinkedNotes } from './DesktopLinkedNotes'; import { DesktopTaggedNotes } from './DesktopTaggedNotes'; +import { parseNotes } from './linkParser'; +import { MobileLinkedNotes } from './MobileLinkedNotes'; import { MobileTaggedNotes } from './MobileTaggedNotes'; type NotesTagFormatterProps = { @@ -16,56 +19,72 @@ export function NotesTagFormatter({ }: NotesTagFormatterProps) { const { isNarrowWidth } = useResponsive(); - const words = notes.split(' '); + const segments = parseNotes(notes); + return ( <> - {words.map((word, i, arr) => { - const separator = arr.length - 1 === i ? '' : ' '; - if (word.includes('#') && word.length > 1) { - let lastEmptyTag = -1; - // Treat tags in a single word as separate tags. - // #tag1#tag2 => (#tag1)(#tag2) - // not-a-tag#tag2#tag3 => not-a-tag(#tag2)(#tag3) - return word.split('#').map((tag, ti) => { - if (ti === 0) { - return tag; - } + {segments.map((segment, index) => { + const isLast = index === segments.length - 1; + const nextSegment = segments[index + 1]; + // Add separator (space) after segment if next segment doesn't start with whitespace + const separator = + isLast || + (nextSegment?.type === 'text' && /^\s/.test(nextSegment.content)) + ? '' + : ' '; - if (!tag) { - lastEmptyTag = ti; - return '#'; - } - - if (lastEmptyTag === ti - 1) { - return `${tag} `; - } - lastEmptyTag = -1; - - const validTag = `#${tag}`; + switch (segment.type) { + case 'text': + return ( + {segment.content} + ); + case 'tag': if (isNarrowWidth) { return ( - ); - } else { - return ( - ); } - }); + return ( + + ); + + case 'link': + if (isNarrowWidth) { + return ( + + ); + } + return ( + + ); + + default: + return null; } - return `${word}${separator}`; })} ); diff --git a/packages/desktop-client/src/notes/linkParser.test.ts b/packages/desktop-client/src/notes/linkParser.test.ts new file mode 100644 index 0000000000..50306789f4 --- /dev/null +++ b/packages/desktop-client/src/notes/linkParser.test.ts @@ -0,0 +1,112 @@ +import { parseNotes } from './linkParser'; + +describe('linkParser', () => { + describe('parseNotes', () => { + describe('URL trailing punctuation handling', () => { + it('should strip trailing period from https URL', () => { + const result = parseNotes('Check out https://example.com.'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com', + displayText: 'https://example.com', + url: 'https://example.com', + isFilePath: false, + }); + // The period should remain as a text segment + const lastSegment = result[result.length - 1]; + expect(lastSegment).toEqual({ type: 'text', content: '.' }); + }); + + it('should strip trailing comma from https URL', () => { + const result = parseNotes('Visit https://example.com, then continue.'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com', + displayText: 'https://example.com', + url: 'https://example.com', + isFilePath: false, + }); + }); + + it('should strip trailing punctuation from www URL', () => { + const result = parseNotes('Go to www.example.com!'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'www.example.com', + displayText: 'www.example.com', + url: 'www.example.com', + isFilePath: false, + }); + // The exclamation mark should remain as a text segment + const lastSegment = result[result.length - 1]; + expect(lastSegment).toEqual({ type: 'text', content: '!' }); + }); + + it('should strip multiple trailing punctuation characters', () => { + const result = parseNotes('See https://example.com/path?query=1).'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com/path?query=1', + displayText: 'https://example.com/path?query=1', + url: 'https://example.com/path?query=1', + isFilePath: false, + }); + }); + + it('should strip trailing quotes from URL', () => { + const result = parseNotes('Link: "https://example.com"'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com', + displayText: 'https://example.com', + url: 'https://example.com', + isFilePath: false, + }); + }); + + it('should preserve URL without trailing punctuation', () => { + const result = parseNotes('Visit https://example.com/page'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com/page', + displayText: 'https://example.com/page', + url: 'https://example.com/page', + isFilePath: false, + }); + }); + + it('should handle URL at end of sentence with semicolon', () => { + const result = parseNotes('First link: https://example.com;'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com', + displayText: 'https://example.com', + url: 'https://example.com', + isFilePath: false, + }); + }); + + it('should handle URL followed by closing bracket', () => { + const result = parseNotes('(see https://example.com)'); + const linkSegment = result.find(s => s.type === 'link'); + expect(linkSegment).toEqual({ + type: 'link', + content: 'https://example.com', + displayText: 'https://example.com', + url: 'https://example.com', + isFilePath: false, + }); + // The closing paren should remain as a text segment + const lastSegment = result[result.length - 1]; + expect(lastSegment).toEqual({ type: 'text', content: ')' }); + }); + }); + }); +}); diff --git a/packages/desktop-client/src/notes/linkParser.ts b/packages/desktop-client/src/notes/linkParser.ts new file mode 100644 index 0000000000..bd881e57e7 --- /dev/null +++ b/packages/desktop-client/src/notes/linkParser.ts @@ -0,0 +1,239 @@ +export type ParsedSegment = + | { type: 'text'; content: string } + | { type: 'tag'; content: string; tag: string } + | { + type: 'link'; + content: string; + displayText: string; + url: string; + isFilePath: boolean; + }; + +// Regex patterns for link detection +const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/; +const FULL_URL_REGEX = /https?:\/\/[^\s]+/; +const WWW_URL_REGEX = /www\.[^\s]+/; +const UNIX_PATH_REGEX = /^\/(?:[^\s/]+\/)*[^\s/]+$/; +const WINDOWS_PATH_REGEX = /^[A-Z]:\\(?:[^\s\\]+\\)*[^\s\\]+$/i; + +// Common trailing punctuation that should not be part of URLs +const TRAILING_PUNCTUATION_REGEX = /[.,;:!?)\]"']+$/; + +/** + * Strips trailing punctuation from a URL + */ +function stripTrailingPunctuation(url: string): string { + return url.replace(TRAILING_PUNCTUATION_REGEX, ''); +} + +/** + * Checks if a URL is a file path + */ +export function isFilePathUrl(url: string): boolean { + return ( + url.startsWith('/') || /^[A-Z]:\\/i.test(url) || url.startsWith('file://') + ); +} + +/** + * Normalizes a URL by adding protocol if missing + */ +export function normalizeUrl(rawUrl: string): string { + // Already has protocol + if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { + return rawUrl; + } + + // www. URL - add https:// + if (rawUrl.startsWith('www.')) { + return `https://${rawUrl}`; + } + + // File path - convert to file:// URL + if (rawUrl.startsWith('/') || /^[A-Z]:\\/i.test(rawUrl)) { + return `file://${rawUrl}`; + } + + return rawUrl; +} + +/** + * Parses a single word for hashtags (existing logic from NotesTagFormatter) + * Returns segments for tags found in the word + */ +function parseTagsInWord(word: string): ParsedSegment[] { + const segments: ParsedSegment[] = []; + + if (!word.includes('#') || word.length <= 1) { + return [{ type: 'text', content: word }]; + } + + let lastEmptyTag = -1; + const parts = word.split('#'); + + parts.forEach((tag, ti) => { + if (ti === 0) { + if (tag) { + segments.push({ type: 'text', content: tag }); + } + return; + } + + if (!tag) { + lastEmptyTag = ti; + segments.push({ type: 'text', content: '#' }); + return; + } + + if (lastEmptyTag === ti - 1) { + segments.push({ type: 'text', content: `${tag}` }); + return; + } + lastEmptyTag = -1; + + const validTag = `#${tag}`; + segments.push({ type: 'tag', content: validTag, tag }); + }); + + return segments; +} + +/** + * Parses notes string into segments of text, tags, and links + */ +export function parseNotes(notes: string): ParsedSegment[] { + if (!notes) { + return []; + } + + const segments: ParsedSegment[] = []; + let remaining = notes; + + while (remaining.length > 0) { + // Check for markdown link first (highest priority) + const markdownMatch = remaining.match(MARKDOWN_LINK_REGEX); + if (markdownMatch && markdownMatch.index !== undefined) { + // Add text before the link + if (markdownMatch.index > 0) { + const textBefore = remaining.slice(0, markdownMatch.index); + segments.push(...parseTextWithTags(textBefore)); + } + + // Add the link segment + const [fullMatch, displayText, url] = markdownMatch; + const isFilePath = + url.startsWith('/') || + /^[A-Z]:\\/i.test(url) || + url.startsWith('file://'); + segments.push({ + type: 'link', + content: fullMatch, + displayText, + url, + isFilePath, + }); + + remaining = remaining.slice(markdownMatch.index + fullMatch.length); + continue; + } + + // Check for plain URLs (http://, https://) + const urlMatch = remaining.match(FULL_URL_REGEX); + if (urlMatch && urlMatch.index !== undefined) { + // Add text before the URL + if (urlMatch.index > 0) { + const textBefore = remaining.slice(0, urlMatch.index); + segments.push(...parseTextWithTags(textBefore)); + } + + // Strip trailing punctuation from the URL + const rawUrl = urlMatch[0]; + const url = stripTrailingPunctuation(rawUrl); + + // Add the link segment + segments.push({ + type: 'link', + content: url, + displayText: url, + url, + isFilePath: false, + }); + + remaining = remaining.slice(urlMatch.index + url.length); + continue; + } + + // Check for www. URLs + const wwwMatch = remaining.match(WWW_URL_REGEX); + if (wwwMatch && wwwMatch.index !== undefined) { + // Add text before the URL + if (wwwMatch.index > 0) { + const textBefore = remaining.slice(0, wwwMatch.index); + segments.push(...parseTextWithTags(textBefore)); + } + + // Strip trailing punctuation from the URL + const rawUrl = wwwMatch[0]; + const url = stripTrailingPunctuation(rawUrl); + + // Add the link segment + segments.push({ + type: 'link', + content: url, + displayText: url, + url, + isFilePath: false, + }); + + remaining = remaining.slice(wwwMatch.index + url.length); + continue; + } + + // No more links found, parse remaining text with tags + segments.push(...parseTextWithTags(remaining)); + break; + } + + return segments; +} + +/** + * Parses text that may contain hashtags and file paths + */ +function parseTextWithTags(text: string): ParsedSegment[] { + const segments: ParsedSegment[] = []; + const words = text.split(/(\s+)/); // Split but keep whitespace + + for (const word of words) { + // Check if it's whitespace + if (/^\s+$/.test(word)) { + segments.push({ type: 'text', content: word }); + continue; + } + + // Check if it's a file path + if (UNIX_PATH_REGEX.test(word) || WINDOWS_PATH_REGEX.test(word)) { + segments.push({ + type: 'link', + content: word, + displayText: word, + url: word, + isFilePath: true, + }); + continue; + } + + // Check for hashtags + if (word.includes('#') && word.length > 1) { + segments.push(...parseTagsInWord(word)); + continue; + } + + // Plain text + if (word) { + segments.push({ type: 'text', content: word }); + } + } + + return segments; +} diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index cb7a74e7e6..78189a57c9 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -607,6 +607,10 @@ ipcMain.handle('open-external-url', (event, url) => { shell.openExternal(url); }); +ipcMain.handle('open-in-file-manager', (event, filepath) => { + shell.showItemInFolder(filepath); +}); + ipcMain.on('message', (_event, msg) => { if (!serverProcess) { return; diff --git a/packages/desktop-electron/preload.ts b/packages/desktop-electron/preload.ts index d2de5078b9..7dd31303dc 100644 --- a/packages/desktop-electron/preload.ts +++ b/packages/desktop-electron/preload.ts @@ -65,6 +65,10 @@ contextBridge.exposeInMainWorld('Actual', { ipcRenderer.invoke('open-external-url', url); }, + openInFileManager: (filepath: string) => { + ipcRenderer.invoke('open-in-file-manager', filepath); + }, + onEventFromMain: (type: string, handler: (...args: unknown[]) => void) => { ipcRenderer.on(type, handler); }, diff --git a/packages/loot-core/typings/window.ts b/packages/loot-core/typings/window.ts index 65db30ceef..8980ee3c4c 100644 --- a/packages/loot-core/typings/window.ts +++ b/packages/loot-core/typings/window.ts @@ -13,6 +13,7 @@ type Actual = { IS_DEV: boolean; ACTUAL_VERSION: string; openURLInBrowser: (url: string) => void; + openInFileManager: (filepath: string) => void; saveFile: ( contents: string | Buffer, filename: string, diff --git a/upcoming-release-notes/6306.md b/upcoming-release-notes/6306.md new file mode 100644 index 0000000000..6c1ee7903c --- /dev/null +++ b/upcoming-release-notes/6306.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [raf-vale] +--- + +Add link detection in transaction notes