first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Asserts given condition
|
||||
*/
|
||||
export function assert(condition: unknown, message?: string): asserts condition {
|
||||
if (!condition) {
|
||||
// eslint-disable-next-line n8n-local-rules/no-plain-errors
|
||||
throw new Error(message ?? 'Assertion failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createEventBus } from './event-bus';
|
||||
|
||||
describe('createEventBus()', () => {
|
||||
const eventBus = createEventBus();
|
||||
|
||||
describe('on()', () => {
|
||||
it('should register event handler', () => {
|
||||
const handler = vi.fn();
|
||||
const eventName = 'test';
|
||||
|
||||
eventBus.on(eventName, handler);
|
||||
|
||||
eventBus.emit(eventName, {});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('once()', () => {
|
||||
it('should register event handler', () => {
|
||||
const handler = vi.fn();
|
||||
const eventName = 'test';
|
||||
|
||||
eventBus.once(eventName, handler);
|
||||
|
||||
eventBus.emit(eventName, {});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unregister event handler after first call', () => {
|
||||
const handler = vi.fn();
|
||||
const eventName = 'test';
|
||||
|
||||
eventBus.once(eventName, handler);
|
||||
|
||||
eventBus.emit(eventName, {});
|
||||
eventBus.emit(eventName, {});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('off()', () => {
|
||||
it('should register event handler', () => {
|
||||
const handler = vi.fn();
|
||||
const eventName = 'test';
|
||||
|
||||
eventBus.on(eventName, handler);
|
||||
eventBus.off(eventName, handler);
|
||||
|
||||
eventBus.emit(eventName, {});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit()', () => {
|
||||
it('should call handlers with given event', () => {
|
||||
const handlerA = vi.fn();
|
||||
const handlerB = vi.fn();
|
||||
const eventName = 'test';
|
||||
const event = new Event(eventName);
|
||||
|
||||
eventBus.on(eventName, handlerA);
|
||||
eventBus.on(eventName, handlerB);
|
||||
|
||||
eventBus.emit(eventName, event);
|
||||
|
||||
expect(handlerA).toHaveBeenCalledWith(event);
|
||||
expect(handlerB).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type CallbackFn = (...args: any[]) => any;
|
||||
|
||||
type Payloads<ListenerMap> = {
|
||||
[E in keyof ListenerMap]: unknown;
|
||||
};
|
||||
|
||||
type Listener<Payload> = (payload: Payload) => void;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface EventBus<ListenerMap extends Payloads<ListenerMap> = Record<string, any>> {
|
||||
on<EventName extends keyof ListenerMap & string>(
|
||||
eventName: EventName,
|
||||
fn: Listener<ListenerMap[EventName]>,
|
||||
): void;
|
||||
|
||||
once<EventName extends keyof ListenerMap & string>(
|
||||
eventName: EventName,
|
||||
fn: Listener<ListenerMap[EventName]>,
|
||||
): void;
|
||||
|
||||
off<EventName extends keyof ListenerMap & string>(
|
||||
eventName: EventName,
|
||||
fn: Listener<ListenerMap[EventName]>,
|
||||
): void;
|
||||
|
||||
emit<EventName extends keyof ListenerMap & string>(
|
||||
eventName: EventName,
|
||||
event?: ListenerMap[EventName],
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event bus with the given listener map.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const eventBus = createEventBus<{
|
||||
* 'user-logged-in': { username: string };
|
||||
* 'user-logged-out': never;
|
||||
* }>();
|
||||
*/
|
||||
export function createEventBus<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ListenerMap extends Payloads<ListenerMap> = Record<string, any>,
|
||||
>(): EventBus<ListenerMap> {
|
||||
const handlers = new Map<string, CallbackFn[]>();
|
||||
|
||||
return {
|
||||
on(eventName, fn) {
|
||||
let eventFns = handlers.get(eventName);
|
||||
if (!eventFns) {
|
||||
eventFns = [fn];
|
||||
} else {
|
||||
eventFns.push(fn);
|
||||
}
|
||||
handlers.set(eventName, eventFns);
|
||||
},
|
||||
|
||||
once(eventName, fn) {
|
||||
const handler: typeof fn = (payload) => {
|
||||
this.off(eventName, handler);
|
||||
fn(payload);
|
||||
};
|
||||
this.on(eventName, handler);
|
||||
},
|
||||
|
||||
off(eventName, fn) {
|
||||
const eventFns = handlers.get(eventName);
|
||||
if (eventFns) {
|
||||
eventFns.splice(eventFns.indexOf(fn) >>> 0, 1);
|
||||
}
|
||||
},
|
||||
|
||||
emit(eventName, event) {
|
||||
const eventFns = handlers.get(eventName);
|
||||
if (eventFns) {
|
||||
eventFns.slice().forEach((handler) => {
|
||||
handler(event);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createEventQueue } from './event-queue';
|
||||
|
||||
describe('createEventQueue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should process events in order', async () => {
|
||||
const processedEvents: string[] = [];
|
||||
|
||||
// Create an async handler that pushes events into the processedEvents array.
|
||||
const processEvent = vi.fn(async (event: string) => {
|
||||
processedEvents.push(event);
|
||||
// Simulate asynchronous delay of 10ms.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
// Create the event queue.
|
||||
const { enqueue } = createEventQueue<string>(processEvent);
|
||||
|
||||
// Enqueue events in a specific order.
|
||||
enqueue('Event 1');
|
||||
enqueue('Event 2');
|
||||
enqueue('Event 3');
|
||||
|
||||
// Advance the timers enough to process all events.
|
||||
// runAllTimersAsync() will run all pending timers and wait for any pending promise resolution.
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(processEvent).toHaveBeenCalledTimes(3);
|
||||
expect(processedEvents).toEqual(['Event 1', 'Event 2', 'Event 3']);
|
||||
});
|
||||
|
||||
it('should handle errors and continue processing', async () => {
|
||||
const processedEvents: string[] = [];
|
||||
const processEvent = vi.fn(async (event: string) => {
|
||||
if (event === 'fail') {
|
||||
throw new Error('Processing error'); // eslint-disable-line n8n-local-rules/no-plain-errors
|
||||
}
|
||||
processedEvents.push(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
const { enqueue } = createEventQueue<string>(processEvent);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
enqueue('Event A');
|
||||
enqueue('fail');
|
||||
enqueue('Event B');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(processEvent).toHaveBeenCalledTimes(3);
|
||||
// 'fail' should cause an error but processing continues.
|
||||
expect(processedEvents).toEqual(['Event A', 'Event B']);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error processing event:', expect.any(Error));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not process any events if none are enqueued', async () => {
|
||||
const processEvent = vi.fn(async (_event: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
createEventQueue<string>(processEvent);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Did not enqueue any event.
|
||||
expect(processEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ensure no concurrent processing of events', async () => {
|
||||
let processingCounter = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const processEvent = vi.fn(async (_event: string) => {
|
||||
processingCounter++;
|
||||
maxConcurrent = Math.max(maxConcurrent, processingCounter);
|
||||
// Simulate asynchronous delay.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
processingCounter--;
|
||||
});
|
||||
|
||||
const { enqueue } = createEventQueue<string>(processEvent);
|
||||
|
||||
enqueue('A');
|
||||
enqueue('B');
|
||||
enqueue('C');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Throughout processing, maxConcurrent should remain 1.
|
||||
expect(maxConcurrent).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Create an event queue that processes events sequentially.
|
||||
*
|
||||
* @param processEvent - Async function that processes a single event.
|
||||
* @returns A function that enqueues events for processing.
|
||||
*/
|
||||
export function createEventQueue<T>(processEvent: (event: T) => Promise<void>) {
|
||||
// The internal queue holding events.
|
||||
const queue: T[] = [];
|
||||
|
||||
// Flag to indicate whether an event is currently being processed.
|
||||
let processing = false;
|
||||
|
||||
/**
|
||||
* Process the next event in the queue (if not already processing).
|
||||
*/
|
||||
async function processNext(): Promise<void> {
|
||||
if (processing || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
processing = true;
|
||||
const currentEvent = queue.shift();
|
||||
|
||||
if (currentEvent !== undefined) {
|
||||
try {
|
||||
await processEvent(currentEvent);
|
||||
} catch (error) {
|
||||
console.error('Error processing event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
processing = false;
|
||||
|
||||
// Recursively process the next event.
|
||||
await processNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an event and trigger processing.
|
||||
*
|
||||
* @param event - The event to enqueue.
|
||||
*/
|
||||
function enqueue(event: T): void {
|
||||
queue.push(event);
|
||||
void processNext();
|
||||
}
|
||||
|
||||
return { enqueue };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { isWindowsFilePath } from './path';
|
||||
|
||||
describe('isWindowsFilePath', () => {
|
||||
describe('valid Windows paths', () => {
|
||||
it('should return true for uppercase drive letter with forward slash', () => {
|
||||
expect(isWindowsFilePath('C:/path')).toBe(true);
|
||||
expect(isWindowsFilePath('Z:/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for uppercase drive letter with backslash', () => {
|
||||
expect(isWindowsFilePath('C:\\path')).toBe(true);
|
||||
expect(isWindowsFilePath('Z:\\')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for lowercase drive letter with forward slash', () => {
|
||||
expect(isWindowsFilePath('c:/path')).toBe(true);
|
||||
expect(isWindowsFilePath('z:/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for lowercase drive letter with backslash', () => {
|
||||
expect(isWindowsFilePath('c:\\path')).toBe(true);
|
||||
expect(isWindowsFilePath('z:\\')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid paths', () => {
|
||||
it('should return false for Unix/Linux absolute paths', () => {
|
||||
expect(isWindowsFilePath('/unix/path')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for Unix/Linux relative paths', () => {
|
||||
expect(isWindowsFilePath('./relative/path')).toBe(false);
|
||||
expect(isWindowsFilePath('../parent/path')).toBe(false);
|
||||
expect(isWindowsFilePath('relative/path')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for UNC network paths', () => {
|
||||
expect(isWindowsFilePath('//network/share')).toBe(false);
|
||||
expect(isWindowsFilePath('\\\\network\\share')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isWindowsFilePath('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for paths missing drive letter separator', () => {
|
||||
expect(isWindowsFilePath('C')).toBe(false);
|
||||
expect(isWindowsFilePath('C:')).toBe(false);
|
||||
expect(isWindowsFilePath('CD:/path')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for paths with invalid drive letter format', () => {
|
||||
expect(isWindowsFilePath('1:/path')).toBe(false);
|
||||
expect(isWindowsFilePath('@:/path')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Fast check if file path starts with a windows drive letter, e.g. 'C:/' or 'C:\\'
|
||||
*/
|
||||
export function isWindowsFilePath(str: string) {
|
||||
return /^[a-zA-Z]:[\\/]/.test(str);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { sanitizeFilename } from './sanitize';
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('should return normal filenames unchanged', () => {
|
||||
expect(sanitizeFilename('normalfile')).toBe('normalfile');
|
||||
expect(sanitizeFilename('my-file_v2')).toBe('my-file_v2');
|
||||
});
|
||||
|
||||
it('should handle empty and invalid inputs', () => {
|
||||
expect(sanitizeFilename('')).toBe('untitled');
|
||||
expect(sanitizeFilename(null as unknown as string)).toBe('untitled');
|
||||
expect(sanitizeFilename(undefined as unknown as string)).toBe('untitled');
|
||||
expect(sanitizeFilename('filename ')).toBe('filename');
|
||||
});
|
||||
|
||||
it('should replace forbidden characters', () => {
|
||||
expect(sanitizeFilename('hello:world')).toBe('hello_world');
|
||||
expect(sanitizeFilename('file<name>')).toBe('file_name_');
|
||||
expect(sanitizeFilename('file/name')).toBe('file_name');
|
||||
expect(sanitizeFilename('file|name')).toBe('file_name');
|
||||
});
|
||||
|
||||
it('should handle Unicode characters', () => {
|
||||
expect(sanitizeFilename('file\u200Bname')).toBe('filename'); // Zero-width space
|
||||
expect(sanitizeFilename('file\u00A0name')).toBe('file name'); // Non-breaking space
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(sanitizeFilename('.')).toBe('untitled');
|
||||
expect(sanitizeFilename('..')).toBe('untitled');
|
||||
expect(sanitizeFilename(' ... ')).toBe('untitled');
|
||||
});
|
||||
|
||||
it('should handle length limits', () => {
|
||||
const longName = 'a'.repeat(250);
|
||||
const result = sanitizeFilename(longName, 50);
|
||||
expect(result.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
// 15 most complex world languages (by writing system complexity)
|
||||
it('should support complex writing systems', () => {
|
||||
// 1. Arabic - Right-to-left, complex ligatures
|
||||
expect(sanitizeFilename('سير العمل الخاص بي')).toBe('سير العمل الخاص بي');
|
||||
|
||||
// 2. Burmese - Complex script with stacked characters
|
||||
expect(sanitizeFilename('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်')).toBe('ကျွန်ုပ်၏ လုပ်ငန်းစဉ်');
|
||||
|
||||
// 3. Thai - Complex script, no word separators
|
||||
expect(sanitizeFilename('เวิร์กโฟลว์ของฉัน')).toBe('เวิร์กโฟลว์ของฉัน');
|
||||
|
||||
// 4. Hindi - Devanagari script with complex conjuncts
|
||||
expect(sanitizeFilename('मेरा वर्कफ़्लो')).toBe('मेरा वर्कफ़्लो');
|
||||
|
||||
// 5. Bengali - Complex script with conjunct consonants
|
||||
expect(sanitizeFilename('আমার ওয়ার্কফ্লো')).toBe('আমার ওয়ার্কফ্লো');
|
||||
|
||||
// 6. Urdu - Right-to-left, Arabic-based script
|
||||
expect(sanitizeFilename('میرا ورک فلو')).toBe('میرا ورک فلو');
|
||||
|
||||
// 7. Chinese - Logographic writing system
|
||||
expect(sanitizeFilename('我的工作流')).toBe('我的工作流');
|
||||
|
||||
// 8. Japanese - Mixed scripts (Hiragana, Katakana, Kanji)
|
||||
expect(sanitizeFilename('私のワークフロー')).toBe('私のワークフロー');
|
||||
|
||||
// 9. Korean - Hangul syllabic blocks
|
||||
expect(sanitizeFilename('내 워크플로우')).toBe('내 워크플로우');
|
||||
|
||||
// 10. Russian - Cyrillic script
|
||||
expect(sanitizeFilename('Мой рабочий процесс')).toBe('Мой рабочий процесс');
|
||||
|
||||
// 11. Tamil - Complex script with vowel marks
|
||||
expect(sanitizeFilename('எனது பணிப்பாய்வு')).toBe('எனது பணிப்பாய்வு');
|
||||
|
||||
// 12. Telugu - Complex script with conjunct consonants
|
||||
expect(sanitizeFilename('నా వర్క్ఫ్లో')).toBe('నా వర్క్ఫ్లో');
|
||||
|
||||
// 13. Marathi - Devanagari script
|
||||
expect(sanitizeFilename('माझा वर्कफ्लो')).toBe('माझा वर्कफ्लो');
|
||||
|
||||
// 14. Gujarati - Complex script with vowel modifications
|
||||
expect(sanitizeFilename('મારો વર્કફ્લો')).toBe('મારો વર્કફ્લો');
|
||||
|
||||
// 15. Punjabi - Gurmukhi script
|
||||
expect(sanitizeFilename('ਮੇਰਾ ਵਰਕਫਲੋ')).toBe('ਮੇਰਾ ਵਰਕਫਲੋ');
|
||||
});
|
||||
|
||||
it('should handle mixed complex scripts with special characters', () => {
|
||||
expect(sanitizeFilename('工作流程/ワークフロー')).toBe('工作流程_ワークフロー');
|
||||
expect(sanitizeFilename('वर्कफ्लो:العمل')).toBe('वर्कफ्लो_العمل');
|
||||
expect(sanitizeFilename('프로세스|процесс')).toBe('프로세스_процесс');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Constants definition
|
||||
/* eslint-disable no-control-regex */
|
||||
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
|
||||
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
|
||||
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
|
||||
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
|
||||
/* eslint-enable no-control-regex */
|
||||
|
||||
const WINDOWS_RESERVED_NAMES = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
]);
|
||||
|
||||
const DEFAULT_FALLBACK_NAME = 'untitled';
|
||||
const MAX_FILENAME_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
|
||||
*
|
||||
* Main features:
|
||||
* - Replace invalid characters (e.g. ":" in hello:world)
|
||||
* - Handle Windows reserved names
|
||||
* - Limit filename length
|
||||
* - Normalize Unicode characters
|
||||
*
|
||||
* @param filename - The filename to sanitize (without extension)
|
||||
* @param maxLength - Maximum filename length (default: 200)
|
||||
* @returns A sanitized filename (without extension)
|
||||
*
|
||||
* @example
|
||||
* sanitizeFilename('hello:world') // returns 'hello_world'
|
||||
* sanitizeFilename('CON') // returns '_CON'
|
||||
* sanitizeFilename('') // returns 'untitled'
|
||||
*/
|
||||
export const sanitizeFilename = (
|
||||
filename: string,
|
||||
maxLength: number = MAX_FILENAME_LENGTH,
|
||||
): string => {
|
||||
// Input validation
|
||||
if (!filename) {
|
||||
return DEFAULT_FALLBACK_NAME;
|
||||
}
|
||||
|
||||
let baseName = filename
|
||||
.trim()
|
||||
.replace(INVALID_CHARS_REGEX, '_')
|
||||
.replace(ZERO_WIDTH_CHARS_REGEX, '')
|
||||
.replace(UNICODE_SPACES_REGEX, ' ')
|
||||
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
|
||||
|
||||
// Handle empty or invalid filenames after cleaning
|
||||
if (!baseName) {
|
||||
baseName = DEFAULT_FALLBACK_NAME;
|
||||
}
|
||||
|
||||
// Handle Windows reserved names
|
||||
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
|
||||
baseName = `_${baseName}`;
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (baseName.length > maxLength) {
|
||||
baseName = baseName.slice(0, maxLength);
|
||||
}
|
||||
|
||||
return baseName;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './assert';
|
||||
export * from './event-bus';
|
||||
export * from './event-queue';
|
||||
export * from './retry';
|
||||
export * from './workflowId';
|
||||
export * from './number/smartDecimal';
|
||||
export * from './search/reRankSearchResults';
|
||||
export * from './search/sublimeSearch';
|
||||
export * from './sort/sortByProperty';
|
||||
export * from './string/truncate';
|
||||
export * from './files/sanitize';
|
||||
export * from './files/path';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { smartDecimal } from './smartDecimal';
|
||||
|
||||
describe('smartDecimal', () => {
|
||||
it('should return the same value if it is an integer', () => {
|
||||
expect(smartDecimal(42)).toBe(42);
|
||||
});
|
||||
|
||||
it('should return the same value if it has only one decimal place', () => {
|
||||
expect(smartDecimal(42.5)).toBe(42.5);
|
||||
});
|
||||
|
||||
it('should round to two decimal places by default', () => {
|
||||
expect(smartDecimal(42.567)).toBe(42.57);
|
||||
});
|
||||
|
||||
it('should round to the specified number of decimal places', () => {
|
||||
expect(smartDecimal(42.567, 1)).toBe(42.6);
|
||||
});
|
||||
|
||||
it('should handle negative numbers correctly', () => {
|
||||
expect(smartDecimal(-42.567, 2)).toBe(-42.57);
|
||||
});
|
||||
|
||||
it('should handle zero correctly', () => {
|
||||
expect(smartDecimal(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle very small numbers correctly', () => {
|
||||
expect(smartDecimal(0.000567, 5)).toBe(0.00057);
|
||||
});
|
||||
|
||||
it('should round to two decimal if it is smaller than the given one', () => {
|
||||
expect(smartDecimal(42.56, 3)).toBe(42.56);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const smartDecimal = (value: number, decimals = 2): number => {
|
||||
// Check if integer
|
||||
if (Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Check if it has only one decimal place
|
||||
if (value.toString().split('.')[1].length <= decimals) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(value.toFixed(decimals));
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { retry } from './retry';
|
||||
|
||||
describe('retry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it('should resolve true when the function eventually returns true', async () => {
|
||||
let callCount = 0;
|
||||
const fn = vi.fn(async () => {
|
||||
callCount++;
|
||||
// Return true on the second attempt.
|
||||
return callCount === 2;
|
||||
});
|
||||
|
||||
const promise = retry(fn, 1000, 2, null);
|
||||
|
||||
// The first call happens immediately.
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance timers by 1000ms asynchronously to allow the waiting period to complete.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// After advancing, the second attempt should have occurred.
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The promise should now resolve with true.
|
||||
const result = await promise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve false if maximum retries are reached with no success', async () => {
|
||||
let callCount = 0;
|
||||
const fn = vi.fn(async () => {
|
||||
callCount++;
|
||||
return false;
|
||||
});
|
||||
|
||||
const promise = retry(fn, 1000, 3, null);
|
||||
|
||||
// The first attempt fires immediately.
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance timers for the delay after the first attempt.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance timers for the delay after the second attempt.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
|
||||
// With maxRetries reached (3 calls), promise should resolve to false.
|
||||
const result = await promise;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject if the function throws an error', async () => {
|
||||
const fn = vi.fn(async () => {
|
||||
throw new Error('Test error'); // eslint-disable-line n8n-local-rules/no-plain-errors
|
||||
});
|
||||
|
||||
// Since the error is thrown on the first call, no timer advancement is needed.
|
||||
await expect(retry(fn, 1000, 3, null)).rejects.toThrow('Test error');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use linear backoff strategy', async () => {
|
||||
let callCount = 0;
|
||||
const fn = vi.fn(async () => {
|
||||
callCount++;
|
||||
return callCount === 4; // Return true on the fourth attempt.
|
||||
});
|
||||
|
||||
const promise = retry(fn, 1000, 4, 'linear');
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000); // First backoff
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000); // Second backoff
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000); // Third backoff
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should use exponential backoff strategy', async () => {
|
||||
let callCount = 0;
|
||||
const fn = vi.fn(async () => {
|
||||
callCount++;
|
||||
return callCount === 5; // Return true on the fifth attempt.
|
||||
});
|
||||
|
||||
const promise = retry(fn, 1000, 5, 'exponential');
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000); // First backoff
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000); // Second backoff
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000); // Third backoff
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8000); // Fourth backoff
|
||||
expect(fn).toHaveBeenCalledTimes(5);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
type RetryFn = () => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* A utility that retries a function every `interval` milliseconds
|
||||
* until the function returns true or the maximum number of retries is reached.
|
||||
*
|
||||
* @param fn - A function that returns a boolean or a Promise resolving to a boolean.
|
||||
* @param interval - The time interval (in milliseconds) between each retry. Defaults to 1000.
|
||||
* @param maxRetries - The maximum number of retry attempts. Defaults to 3.
|
||||
* @param backoff - The backoff strategy to use: 'linear', 'exponential', or null.
|
||||
* @returns {Promise<boolean>} - A promise that resolves to:
|
||||
* - true: If the function returns true before reaching maxRetries.
|
||||
* - false: If the function never returns true or if an error occurs.
|
||||
*/
|
||||
export async function retry(
|
||||
fn: RetryFn,
|
||||
interval: number = 1000,
|
||||
maxRetries: number = 3,
|
||||
backoff: 'exponential' | 'linear' | null = 'linear',
|
||||
): Promise<boolean> {
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
attempt++;
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during retry:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait for the specified interval before the next attempt, if any attempts remain.
|
||||
if (attempt < maxRetries) {
|
||||
let computedInterval = interval;
|
||||
|
||||
if (backoff === 'linear') {
|
||||
computedInterval = interval * attempt;
|
||||
} else if (backoff === 'exponential') {
|
||||
computedInterval = Math.pow(2, attempt - 1) * interval;
|
||||
computedInterval = Math.min(computedInterval, 30000); // Cap the maximum interval to 30 seconds
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, computedInterval));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { reRankSearchResults } from './reRankSearchResults';
|
||||
import topLevel from './snapshots/toplevel.snapshot.json';
|
||||
import { sublimeSearch } from './sublimeSearch';
|
||||
|
||||
describe('reRankSearchResults', () => {
|
||||
describe('should re-rank search results based on additional factors', () => {
|
||||
it('should return Coda before Code without additional factors for query "cod"', () => {
|
||||
const searchResults = sublimeSearch('cod', topLevel);
|
||||
const resultNames = searchResults.map((result) => result.item.properties.displayName);
|
||||
|
||||
// Without re-ranking, Coda should appear before Code
|
||||
expect(resultNames[0]).toBe('Coda');
|
||||
expect(resultNames[1]).toBe('Code');
|
||||
});
|
||||
|
||||
it('should return Code before Coda with additional factors favoring Code for query "cod"', () => {
|
||||
const searchResults = sublimeSearch('cod', topLevel);
|
||||
|
||||
// Add popularity scores that heavily favor Code node
|
||||
const additionalFactors = {
|
||||
popularity: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'n8n-nodes-base.code': 90, // High popularity for Code node
|
||||
'n8n-nodes-base.coda': 10, // Lower popularity for Coda node
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
};
|
||||
|
||||
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
|
||||
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
|
||||
|
||||
// After re-ranking with additional factors, Code should appear before Coda
|
||||
expect(resultNames[0]).toBe('Code');
|
||||
expect(resultNames[1]).toBe('Coda');
|
||||
});
|
||||
|
||||
it('should handle multiple additional factors', () => {
|
||||
const searchResults = sublimeSearch('cod', topLevel);
|
||||
|
||||
// Add multiple factors: popularity and recent usage
|
||||
const additionalFactors = {
|
||||
popularity: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'n8n-nodes-base.code': 50,
|
||||
'n8n-nodes-base.coda': 40,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
recentUsage: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'n8n-nodes-base.code': 80, // Code was used more recently
|
||||
'n8n-nodes-base.coda': 20,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
};
|
||||
|
||||
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
|
||||
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
|
||||
|
||||
// Code should rank higher due to combined score (50 + 80 = 130 vs Coda's 40 + 20 = 60)
|
||||
expect(resultNames[0]).toBe('Code');
|
||||
expect(resultNames[1]).toBe('Coda');
|
||||
});
|
||||
|
||||
it('should preserve original order when additional factors are equal', () => {
|
||||
const searchResults = sublimeSearch('cod', topLevel);
|
||||
|
||||
// Add equal factors for both nodes
|
||||
const additionalFactors = {
|
||||
popularity: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'n8n-nodes-base.code': 50,
|
||||
'n8n-nodes-base.coda': 50,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
};
|
||||
|
||||
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
|
||||
const resultNames = reRankedResults.map((result) => result.item.properties.displayName);
|
||||
|
||||
// When additional factors are equal, original order should be preserved
|
||||
// Since Coda has a higher base score from sublimeSearch, it should remain first
|
||||
expect(resultNames[0]).toBe('Coda');
|
||||
expect(resultNames[1]).toBe('Code');
|
||||
});
|
||||
|
||||
it('should handle empty additional factors object', () => {
|
||||
const searchResults = sublimeSearch('cod', topLevel);
|
||||
const reRankedResults = reRankSearchResults(searchResults, {});
|
||||
|
||||
// Results should be identical to original search results
|
||||
expect(reRankedResults).toEqual(searchResults);
|
||||
});
|
||||
|
||||
it('should handle nodes not present in additional factors', () => {
|
||||
const searchResults = sublimeSearch('git', topLevel);
|
||||
|
||||
// Only provide factor for some items
|
||||
const additionalFactors = {
|
||||
popularity: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'n8n-nodes-base.github': 100,
|
||||
// Other git-related nodes are not included
|
||||
},
|
||||
};
|
||||
|
||||
const reRankedResults = reRankSearchResults(searchResults, additionalFactors);
|
||||
|
||||
// GitHub should rank higher due to additional factor
|
||||
const githubIndex = reRankedResults.findIndex(
|
||||
(r) => r.item.properties.displayName === 'GitHub',
|
||||
);
|
||||
const gitIndex = reRankedResults.findIndex((r) => r.item.properties.displayName === 'Git');
|
||||
|
||||
if (githubIndex !== -1 && gitIndex !== -1) {
|
||||
expect(githubIndex).toBeLessThan(gitIndex);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export function reRankSearchResults<T extends { key: string }>(
|
||||
searchResults: Array<{ score: number; item: T }>,
|
||||
additionalFactors: Record<string, Record<string, number>>,
|
||||
): Array<{ score: number; item: T }> {
|
||||
return searchResults
|
||||
.map(({ score, item }) => {
|
||||
// For each additional factor, we check if it exists for the item and type,
|
||||
// and if so, we add the score to the item's score.
|
||||
const additionalScore = Object.entries(additionalFactors).reduce((acc, [_, factorScores]) => {
|
||||
const factorScore = factorScores[item.key];
|
||||
if (factorScore) {
|
||||
return acc + factorScore;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
score: score + additionalScore,
|
||||
item,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.score - a.score;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Search snapshots
|
||||
|
||||
This directory contains snapshots containing real data fed into the sublimeSearch function.
|
||||
|
||||
These were obtained via `console.log(items)` right before the sublimeSearch call in `editor-ui` (currently in `packages/frontend/editor-ui/src/components/Node/NodeCreator/utils.ts`)
|
||||
Which is triggered by typing in the search bar in varying states of the application:
|
||||
|
||||
- toplevel: From an empty workflow (so missing e.g. tools)
|
||||
|
||||
|
||||
After typing in the search bar you should see an object in the console you can copy via `Right Click->Copy Object" which will cleanly paste to json.
|
||||
|
||||
**Please use Chrome for capturing these - the recovered object in Chrome is about 3x larger than in Firefox due to Firefox dropping some nested values**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import topLevel from './snapshots/toplevel.snapshot.json';
|
||||
import { sublimeSearch } from './sublimeSearch';
|
||||
|
||||
describe('sublimeSearch', () => {
|
||||
describe('search finds specific matches first', () => {
|
||||
// Note that this only tests the order of the specified matches
|
||||
// Further results may appear after the listed ones
|
||||
const testCases: Array<[string, string[]]> = [
|
||||
['set', ['Edit Fields (Set)']],
|
||||
['agent', ['AI Agent', 'Magento 2']],
|
||||
];
|
||||
|
||||
test.each(testCases)(
|
||||
'should return at least "$expectedOrder" for filter "$filter"',
|
||||
(filter, expectedOrder) => {
|
||||
// These match the weights in the production use case
|
||||
const results = sublimeSearch(filter, topLevel);
|
||||
|
||||
const resultNames = results.map((result) => result.item.properties.displayName);
|
||||
expectedOrder.forEach((expectedName, index) => {
|
||||
expect(resultNames[index]).toBe(expectedName);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* Constants and utility functions used for searching for node types in node creator component
|
||||
* based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js
|
||||
*/
|
||||
|
||||
const SEQUENTIAL_BONUS = 60; // bonus for adjacent matches
|
||||
const SEPARATOR_BONUS = 38; // bonus if match occurs after a separator
|
||||
const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower
|
||||
const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched
|
||||
|
||||
const LEADING_LETTER_PENALTY = -20; // penalty applied for every letter in str before the first match
|
||||
const MAX_LEADING_LETTER_PENALTY = -200; // maximum penalty for leading letters
|
||||
const UNMATCHED_LETTER_PENALTY = -5;
|
||||
|
||||
export const DEFAULT_KEYS = [
|
||||
{ key: 'properties.displayName', weight: 1.3 },
|
||||
{ key: 'properties.codex.alias', weight: 1 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if each character in pattern is found sequentially within target
|
||||
* @param {*} pattern string
|
||||
* @param {*} target string
|
||||
*/
|
||||
function fuzzyMatchSimple(pattern: string, target: string): boolean {
|
||||
let patternIdx = 0;
|
||||
let strIdx = 0;
|
||||
|
||||
while (patternIdx < pattern.length && strIdx < target.length) {
|
||||
const patternChar = pattern.charAt(patternIdx).toLowerCase();
|
||||
const targetChar = target.charAt(strIdx).toLowerCase();
|
||||
if (patternChar === targetChar) {
|
||||
patternIdx++;
|
||||
}
|
||||
++strIdx;
|
||||
}
|
||||
|
||||
return pattern.length !== 0 && target.length !== 0 && patternIdx === pattern.length;
|
||||
}
|
||||
|
||||
function fuzzyMatchRecursive(
|
||||
pattern: string,
|
||||
target: string,
|
||||
patternCurIndex: number,
|
||||
targetCurrIndex: number,
|
||||
targetMatches: null | number[],
|
||||
matches: number[],
|
||||
maxMatches: number,
|
||||
nextMatch: number,
|
||||
recursionCount: number,
|
||||
recursionLimit: number,
|
||||
): { matched: boolean; outScore: number } {
|
||||
let outScore = 0;
|
||||
|
||||
// Return if recursion limit is reached.
|
||||
if (++recursionCount >= recursionLimit) {
|
||||
return { matched: false, outScore };
|
||||
}
|
||||
|
||||
// Return if we reached ends of strings.
|
||||
if (patternCurIndex === pattern.length || targetCurrIndex === target.length) {
|
||||
return { matched: false, outScore };
|
||||
}
|
||||
|
||||
// Recursion params
|
||||
let recursiveMatch = false;
|
||||
let bestRecursiveMatches: number[] = [];
|
||||
let bestRecursiveScore = 0;
|
||||
|
||||
// Loop through pattern and str looking for a match.
|
||||
let firstMatch = true;
|
||||
while (patternCurIndex < pattern.length && targetCurrIndex < target.length) {
|
||||
// Match found.
|
||||
if (pattern[patternCurIndex].toLowerCase() === target[targetCurrIndex].toLowerCase()) {
|
||||
if (nextMatch >= maxMatches) {
|
||||
return { matched: false, outScore };
|
||||
}
|
||||
|
||||
if (firstMatch && targetMatches) {
|
||||
matches = [...targetMatches];
|
||||
firstMatch = false;
|
||||
}
|
||||
|
||||
const recursiveMatches: number[] = [];
|
||||
const recursiveResult = fuzzyMatchRecursive(
|
||||
pattern,
|
||||
target,
|
||||
patternCurIndex,
|
||||
targetCurrIndex + 1,
|
||||
matches,
|
||||
recursiveMatches,
|
||||
maxMatches,
|
||||
nextMatch,
|
||||
recursionCount,
|
||||
recursionLimit,
|
||||
);
|
||||
|
||||
const recursiveScore = recursiveResult.outScore;
|
||||
if (recursiveResult.matched) {
|
||||
// Pick best recursive score.
|
||||
if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
|
||||
bestRecursiveMatches = [...recursiveMatches];
|
||||
bestRecursiveScore = recursiveScore;
|
||||
}
|
||||
recursiveMatch = true;
|
||||
}
|
||||
|
||||
matches[nextMatch++] = targetCurrIndex;
|
||||
++patternCurIndex;
|
||||
}
|
||||
++targetCurrIndex;
|
||||
}
|
||||
|
||||
const matched = patternCurIndex === pattern.length;
|
||||
|
||||
if (matched) {
|
||||
outScore = 100;
|
||||
|
||||
// Apply leading letter penalty (if not n8n-prefixed)
|
||||
if (!target.toLowerCase().startsWith('n8n')) {
|
||||
let penalty = LEADING_LETTER_PENALTY * matches[0];
|
||||
penalty = penalty < MAX_LEADING_LETTER_PENALTY ? MAX_LEADING_LETTER_PENALTY : penalty;
|
||||
outScore += penalty;
|
||||
}
|
||||
|
||||
//Apply unmatched penalty
|
||||
const unmatched = target.length - nextMatch;
|
||||
outScore += UNMATCHED_LETTER_PENALTY * unmatched;
|
||||
|
||||
// Apply ordering bonuses
|
||||
for (let i = 0; i < nextMatch; i++) {
|
||||
const currIdx = matches[i];
|
||||
|
||||
if (i > 0) {
|
||||
const prevIdx = matches[i - 1];
|
||||
if (currIdx === prevIdx + 1) {
|
||||
outScore += SEQUENTIAL_BONUS;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for bonuses based on neighbor character value.
|
||||
if (currIdx > 0) {
|
||||
// Camel case
|
||||
const neighbor = target[currIdx - 1];
|
||||
const curr = target[currIdx];
|
||||
if (neighbor !== neighbor.toUpperCase() && curr !== curr.toLowerCase()) {
|
||||
outScore += CAMEL_BONUS;
|
||||
}
|
||||
const isNeighbourSeparator = neighbor === '_' || neighbor === ' ';
|
||||
if (isNeighbourSeparator) {
|
||||
outScore += SEPARATOR_BONUS;
|
||||
}
|
||||
} else {
|
||||
// First letter
|
||||
outScore += FIRST_LETTER_BONUS;
|
||||
}
|
||||
}
|
||||
|
||||
// Return best result
|
||||
if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
|
||||
// Recursive score is better than "this"
|
||||
matches = [...bestRecursiveMatches];
|
||||
outScore = bestRecursiveScore;
|
||||
return { matched: true, outScore };
|
||||
} else if (matched) {
|
||||
// "this" score is better than recursive
|
||||
return { matched: true, outScore };
|
||||
} else {
|
||||
return { matched: false, outScore };
|
||||
}
|
||||
}
|
||||
return { matched: false, outScore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a fuzzy search to find pattern inside a string.
|
||||
* @param {*} pattern string pattern to search for
|
||||
* @param {*} target string string which is being searched
|
||||
* @returns [boolean, number] a boolean which tells if pattern was
|
||||
* found or not and a search score
|
||||
*/
|
||||
function fuzzyMatch(pattern: string, target: string): { matched: boolean; outScore: number } {
|
||||
const recursionCount = 0;
|
||||
const recursionLimit = 5;
|
||||
const matches: number[] = [];
|
||||
const maxMatches = 256;
|
||||
|
||||
return fuzzyMatchRecursive(
|
||||
pattern,
|
||||
target,
|
||||
0 /* patternCurIndex */,
|
||||
0 /* strCurrIndex */,
|
||||
null /* srcMatces */,
|
||||
matches,
|
||||
maxMatches,
|
||||
0 /* nextMatch */,
|
||||
recursionCount,
|
||||
recursionLimit,
|
||||
);
|
||||
}
|
||||
|
||||
// prop = 'key'
|
||||
// prop = 'key1.key2'
|
||||
// prop = ['key1', 'key2']
|
||||
function getValue<T extends object>(obj: T, prop: string): unknown {
|
||||
if (obj.hasOwnProperty(prop)) {
|
||||
return obj[prop as keyof T];
|
||||
}
|
||||
|
||||
const segments = prop.split('.');
|
||||
|
||||
let result = obj;
|
||||
let i = 0;
|
||||
while (result && i < segments.length) {
|
||||
const key = segments[i] as keyof T;
|
||||
result = result[key] as T;
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sublimeSearch<T extends object>(
|
||||
filter: string,
|
||||
data: readonly T[],
|
||||
keys: Array<{ key: string; weight: number }> = DEFAULT_KEYS,
|
||||
): Array<{ score: number; item: T }> {
|
||||
const results = data.reduce((accu: Array<{ score: number; item: T }>, item: T) => {
|
||||
let values: Array<{ value: string; weight: number }> = [];
|
||||
keys.forEach(({ key, weight }) => {
|
||||
const value = getValue(item, key);
|
||||
if (Array.isArray(value)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
values = values.concat(value.map((v) => ({ value: v, weight })));
|
||||
} else if (typeof value === 'string') {
|
||||
values.push({
|
||||
value,
|
||||
weight,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// for each item, check every key and get maximum score
|
||||
const itemMatch = values.reduce(
|
||||
(
|
||||
result: null | { matched: boolean; outScore: number },
|
||||
{ value, weight }: { value: string; weight: number },
|
||||
) => {
|
||||
if (!fuzzyMatchSimple(filter, value)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const match = fuzzyMatch(filter, value);
|
||||
match.outScore *= weight;
|
||||
|
||||
const { matched, outScore } = match;
|
||||
if (!result && matched) {
|
||||
return match;
|
||||
}
|
||||
if (matched && result && outScore > result.outScore) {
|
||||
return match;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
if (itemMatch) {
|
||||
accu.push({
|
||||
score: itemMatch.outScore,
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
return accu;
|
||||
}, []);
|
||||
|
||||
results.sort((a, b) => {
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,50 @@
|
||||
import { sortByProperty } from './sortByProperty';
|
||||
|
||||
const arrayOfObjects = [
|
||||
{ name: 'Álvaro', age: 30 },
|
||||
{ name: 'Élodie', age: 28 },
|
||||
{ name: 'Željko', age: 25 },
|
||||
{ name: 'Bob', age: 35 },
|
||||
];
|
||||
|
||||
describe('sortByProperty', () => {
|
||||
it('should sort an array of objects by a property', () => {
|
||||
const sortedArray = sortByProperty('name', arrayOfObjects);
|
||||
expect(sortedArray).toEqual([
|
||||
{ name: 'Álvaro', age: 30 },
|
||||
{ name: 'Bob', age: 35 },
|
||||
{ name: 'Élodie', age: 28 },
|
||||
{ name: 'Željko', age: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort an array of objects by a property in descending order', () => {
|
||||
const sortedArray = sortByProperty('name', arrayOfObjects, 'desc');
|
||||
expect(sortedArray).toEqual([
|
||||
{ name: 'Željko', age: 25 },
|
||||
{ name: 'Élodie', age: 28 },
|
||||
{ name: 'Bob', age: 35 },
|
||||
{ name: 'Álvaro', age: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort an array of objects by a property if its number', () => {
|
||||
const sortedArray = sortByProperty('age', arrayOfObjects);
|
||||
expect(sortedArray).toEqual([
|
||||
{ name: 'Željko', age: 25 },
|
||||
{ name: 'Élodie', age: 28 },
|
||||
{ name: 'Álvaro', age: 30 },
|
||||
{ name: 'Bob', age: 35 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort an array of objects by a property in descending order if its number', () => {
|
||||
const sortedArray = sortByProperty('age', arrayOfObjects, 'desc');
|
||||
expect(sortedArray).toEqual([
|
||||
{ name: 'Bob', age: 35 },
|
||||
{ name: 'Álvaro', age: 30 },
|
||||
{ name: 'Élodie', age: 28 },
|
||||
{ name: 'Željko', age: 25 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export const sortByProperty = <T>(
|
||||
property: keyof T,
|
||||
arr: T[],
|
||||
order: 'asc' | 'desc' = 'asc',
|
||||
): T[] =>
|
||||
arr.sort((a, b) => {
|
||||
const result = String(a[property]).localeCompare(String(b[property]), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
return order === 'asc' ? result : -result;
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { truncateBeforeLast, truncate } from './truncate';
|
||||
|
||||
describe('truncate', () => {
|
||||
it('should truncate text to 30 chars by default', () => {
|
||||
expect(truncate('This is a very long text that should be truncated')).toBe(
|
||||
'This is a very long text that ...',
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate text to given length', () => {
|
||||
expect(truncate('This is a very long text that should be truncated', 25)).toBe(
|
||||
'This is a very long text ...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(truncateBeforeLast, () => {
|
||||
it('should return unmodified text if the length does not exceed max length', () => {
|
||||
expect(truncateBeforeLast('I love nodemation', 25)).toBe('I love nodemation');
|
||||
expect(truncateBeforeLast('I ❤️ nodemation', 25)).toBe('I ❤️ nodemation');
|
||||
expect(truncateBeforeLast('Nodemation is cool', 25)).toBe('Nodemation is cool');
|
||||
expect(truncateBeforeLast('Internationalization', 25)).toBe('Internationalization');
|
||||
expect(truncateBeforeLast('I love 👨👩👧👦', 8)).toBe('I love 👨👩👧👦');
|
||||
});
|
||||
|
||||
it('should remove chars just before the last word, as long as the last word is under 15 chars', () => {
|
||||
expect(truncateBeforeLast('I love nodemation', 15)).toBe('I lo…nodemation');
|
||||
expect(truncateBeforeLast('I love "nodemation"', 15)).toBe('I …"nodemation"');
|
||||
expect(truncateBeforeLast('I ❤️ nodemation', 13)).toBe('I …nodemation');
|
||||
expect(truncateBeforeLast('Nodemation is cool', 15)).toBe('Nodemation…cool');
|
||||
expect(truncateBeforeLast('"Nodemation" is cool', 15)).toBe('"Nodematio…cool');
|
||||
expect(truncateBeforeLast('Is it fun to automate boring stuff?', 15)).toBe('Is it fu…stuff?');
|
||||
expect(truncateBeforeLast('Is internationalization fun?', 15)).toBe('Is interna…fun?');
|
||||
expect(truncateBeforeLast('I love 👨👩👧👦', 7)).toBe('I lov…👨👩👧👦');
|
||||
});
|
||||
|
||||
it('should preserve last 5 characters if the last word is longer than 15 characters', () => {
|
||||
expect(truncateBeforeLast('I love internationalization', 25)).toBe('I love internationa…ation');
|
||||
expect(truncateBeforeLast('I love "internationalization"', 25)).toBe(
|
||||
'I love "internation…tion"',
|
||||
);
|
||||
expect(truncateBeforeLast('I "love" internationalization', 25)).toBe(
|
||||
'I "love" internatio…ation',
|
||||
);
|
||||
expect(truncateBeforeLast('I ❤️ internationalization', 9)).toBe('I ❤️…ation');
|
||||
expect(truncateBeforeLast('I ❤️ internationalization', 8)).toBe('I …ation');
|
||||
expect(truncateBeforeLast('Internationalization', 15)).toBe('Internati…ation');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export const truncate = (text: string, length = 30): string =>
|
||||
text.length > length ? text.slice(0, length) + '...' : text;
|
||||
|
||||
/**
|
||||
* Replace part of given text with ellipsis following the rules below:
|
||||
*
|
||||
* - Remove chars just before the last word, as long as the last word is under 15 chars
|
||||
* - Otherwise preserve the last 5 chars of the name and remove chars before that
|
||||
*/
|
||||
export function truncateBeforeLast(
|
||||
text: string,
|
||||
maxLength: number,
|
||||
lastCharsLength: number = 5,
|
||||
): string {
|
||||
const chars: string[] = [];
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
for (const { segment } of segmenter.segment(text)) {
|
||||
chars.push(segment);
|
||||
}
|
||||
|
||||
if (chars.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lastWhitespaceIndex = chars.findLastIndex((ch) => ch.match(/^\s+$/));
|
||||
const lastWordIndex = lastWhitespaceIndex + 1;
|
||||
const lastWord = chars.slice(lastWordIndex);
|
||||
const ellipsis = '…';
|
||||
const ellipsisLength = ellipsis.length;
|
||||
|
||||
if (lastWord.length < 15) {
|
||||
const charsToRemove = chars.length - maxLength + ellipsisLength;
|
||||
const indexBeforeLastWord = lastWordIndex;
|
||||
const keepLength = indexBeforeLastWord - charsToRemove;
|
||||
|
||||
if (keepLength > 0) {
|
||||
return (
|
||||
chars.slice(0, keepLength).join('') + ellipsis + chars.slice(indexBeforeLastWord).join('')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCharsLength < 1) {
|
||||
return chars.slice(0, maxLength).join('') + ellipsis;
|
||||
}
|
||||
|
||||
return (
|
||||
chars.slice(0, maxLength - lastCharsLength - ellipsisLength).join('') +
|
||||
ellipsis +
|
||||
chars.slice(-lastCharsLength).join('')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NANOID_ALPHABET } from '@n8n/constants';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
/**
|
||||
* Generates a unique 16-character nanoid.
|
||||
*
|
||||
* This is the canonical ID generator used across the entire n8n codebase for:
|
||||
* - Workflow IDs
|
||||
* - Project IDs
|
||||
* - Variable IDs
|
||||
* - API Key IDs
|
||||
* - And other entity IDs
|
||||
*
|
||||
* Both frontend and backend MUST use this function to ensure consistency.
|
||||
*
|
||||
* @returns A 16-character ID
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const id = generateNanoId();
|
||||
* // => 'aBcDeFgHiJkLmNoP' (16 characters)
|
||||
* ```
|
||||
*/
|
||||
export const generateNanoId = customAlphabet(NANOID_ALPHABET, 16);
|
||||
Reference in New Issue
Block a user