mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 17:47:00 -05:00
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:
@@ -190,6 +190,9 @@ global.Actual = {
|
||||
openURLInBrowser: url => {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
openInFileManager: () => {
|
||||
// File manager not available in browser
|
||||
},
|
||||
onEventFromMain: () => {},
|
||||
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
||||
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
||||
|
||||
78
packages/desktop-client/src/notes/DesktopLinkedNotes.tsx
Normal file
78
packages/desktop-client/src/notes/DesktopLinkedNotes.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
packages/desktop-client/src/notes/MobileLinkedNotes.tsx
Normal file
68
packages/desktop-client/src/notes/MobileLinkedNotes.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
112
packages/desktop-client/src/notes/linkParser.test.ts
Normal file
112
packages/desktop-client/src/notes/linkParser.test.ts
Normal 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: ')' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
239
packages/desktop-client/src/notes/linkParser.ts
Normal file
239
packages/desktop-client/src/notes/linkParser.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
upcoming-release-notes/6306.md
Normal file
6
upcoming-release-notes/6306.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [raf-vale]
|
||||
---
|
||||
|
||||
Add link detection in transaction notes
|
||||
Reference in New Issue
Block a user