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