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
This commit is contained in:
raf-vale
2025-12-06 20:29:50 +01:00
committed by GitHub
parent 0c95eb4838
commit c71e752cbe
10 changed files with 572 additions and 38 deletions

View File

@@ -190,6 +190,9 @@ global.Actual = {
openURLInBrowser: url => {
window.open(url, '_blank');
},
openInFileManager: () => {
// File manager not available in browser
},
onEventFromMain: () => {},
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,

View File

@@ -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 (
<>
<span
className={linkStyles}
onMouseDown={e => e.stopPropagation()}
onClick={handleClick}
>
{displayText}
</span>
{separator}
</>
);
}

View File

@@ -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 (
<>
<Text className={linkStyles} onClick={handleClick}>
{displayText}
</Text>
{separator}
</>
);
}

View File

@@ -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 (
<React.Fragment key={index}>{segment.content}</React.Fragment>
);
case 'tag':
if (isNarrowWidth) {
return (
<MobileTaggedNotes
key={`${validTag}${ti}`}
content={validTag}
tag={tag}
separator={separator}
/>
);
} else {
return (
<DesktopTaggedNotes
key={`${validTag}${ti}`}
onPress={onNotesTagClick}
content={validTag}
tag={tag}
key={index}
content={segment.content}
tag={segment.tag}
separator={separator}
/>
);
}
});
return (
<DesktopTaggedNotes
key={index}
onPress={onNotesTagClick}
content={segment.content}
tag={segment.tag}
separator={separator}
/>
);
case 'link':
if (isNarrowWidth) {
return (
<MobileLinkedNotes
key={index}
displayText={segment.displayText}
url={segment.url}
separator={separator}
isFilePath={segment.isFilePath}
/>
);
}
return (
<DesktopLinkedNotes
key={index}
displayText={segment.displayText}
url={segment.url}
separator={separator}
isFilePath={segment.isFilePath}
/>
);
default:
return null;
}
return `${word}${separator}`;
})}
</>
);

View File

@@ -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: ')' });
});
});
});
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
},

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [raf-vale]
---
Add link detection in transaction notes