Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ easy-copy/
# env
.env
/.sisyphus
/.omo
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


---

## [1.7.2] - 2026-07-22

### ✨ New Features

- **Custom regex copy matchers**: add named copy rules with configurable regular expressions, flags, capture groups, descriptions, and enable toggles to copy project-specific text formats.
- **Reorderable copy targets**: drag built-in and custom matchers into the desired priority order, including Markdown links.
- **AI regex prompt helper**: copy a ready-to-use prompt from the matcher editor to help generate a suitable regular expression.

### 🐛 Bug Fixes

- **Unicode block display text** (fixes [#41](https://github.com/Moyf/easy-copy/issues/41)): block link aliases now apply word limits to space-separated text containing Latin Extended letters, Cyrillic, or Unicode punctuation instead of incorrectly truncating them by character count. CJK text continues to use the character limit, and character truncation no longer splits Unicode surrogate pairs.

### ♻️ Changed

- **Block display text settings**: renamed the English/CJK-oriented labels to word and character limits, with descriptions that reflect the actual truncation strategy.

<details>
<summary>中文说明(点击展开)</summary>

### ✨ 新功能

- **自定义正则复制规则**:支持添加带名称、说明、正则表达式、标志、捕获组和启用开关的复制规则,用于复制项目特有的文本格式。
- **复制目标排序**:支持拖动内置与自定义匹配器来调整优先级,并将 Markdown 链接纳入可排序目标。
- **AI 正则提示词助手**:可从匹配器编辑界面复制即用型提示词,辅助生成合适的正则表达式。

### 🐛 修复

- **Unicode 块显示文本**(修复 [#41](https://github.com/Moyf/easy-copy/issues/41)):包含拉丁扩展字母、西里尔字母或 Unicode 标点的空格分词文本,现在会正确采用单词数上限,不再被错误地按字符截短。CJK 文本仍使用字符数上限,按字符截取时也不会再拆断 Unicode 代理对。

### ♻️ 变更

- **块显示文本设置**:将原先面向英语/CJK 的标签调整为按词和按字符截取上限,并更新说明以准确反映实际截取策略。

</details>

---

## [1.7.1] - 2026-05-28
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "easy-copy",
"name": "Easy Copy",
"version": "1.7.1",
"version": "1.7.2",
"minAppVersion": "1.8.7",
"description": "Easily copy the text within inline code, bold text (and many other formats), or quickly generate an elegant link to a heading.",
"author": "Moy",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "easy-copy",
"version": "1.7.1",
"version": "1.7.2",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
Expand Down
108 changes: 108 additions & 0 deletions src/copyMatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
buildCopyMatchers,
DEFAULT_BUILTIN_COPY_MATCHER_IDS,
getCustomMatcherOrderId,
normalizeMatcherOrder,
} from './copyMatcher';
import { ContextType, DEFAULT_SETTINGS } from './type';

describe('normalizeMatcherOrder', () => {
it('keeps saved order and appends missing built-in and custom matchers', () => {
const customMatcher = {
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
};

expect(normalizeMatcherOrder(['inline-code', 'bold'], [customMatcher])).toEqual([
'inline-code',
'bold',
'italic',
'highlight',
'strikethrough',
'inline-latex',
'link',
'wiki-link',
getCustomMatcherOrderId(customMatcher),
]);
});

it('drops unknown and duplicate matcher ids', () => {
expect(normalizeMatcherOrder(['missing', 'bold', 'bold'], [])).toEqual(DEFAULT_BUILTIN_COPY_MATCHER_IDS);
});
});

describe('buildCopyMatchers', () => {
it('orders custom regex matchers with built-in matchers', () => {
const customMatcher = {
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
};
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: true,
customMatchers: [customMatcher],
matcherOrder: [getCustomMatcherOrderId(customMatcher), ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};

const matchers = buildCopyMatchers(settings, false);

expect(matchers[0]).toMatchObject({
id: getCustomMatcherOrderId(customMatcher),
type: ContextType.CUSTOM,
enabled: true,
captureGroup: 1,
name: 'Quotes',
});
expect(matchers[0].regex.exec('copy "inside"')?.[1]).toBe('inside');
});

it('skips invalid custom regex without removing valid matchers', () => {
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: true,
customMatchers: [{
id: 'broken',
name: 'Broken',
pattern: '(',
flags: 'g',
captureGroup: 1,
enabled: true,
}],
matcherOrder: ['custom:broken', ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};

const matchers = buildCopyMatchers(settings, false);

expect(matchers.some(matcher => matcher.id === 'custom:broken')).toBe(false);
expect(matchers[0].id).toBe('bold');
});

it('disables custom regex matchers when target customization is off', () => {
const settings = {
...DEFAULT_SETTINGS,
customizeTargets: false,
customMatchers: [{
id: 'quotes',
name: 'Quotes',
pattern: '"([^"]+)"',
flags: 'g',
captureGroup: 1,
enabled: true,
}],
matcherOrder: ['custom:quotes', ...DEFAULT_BUILTIN_COPY_MATCHER_IDS],
};

const customMatcher = buildCopyMatchers(settings, false).find(matcher => matcher.id === 'custom:quotes');

expect(customMatcher?.enabled).toBe(false);
});
});
152 changes: 152 additions & 0 deletions src/copyMatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { BuiltinCopyMatcherId, ContextType, CustomCopyMatcherSetting, EasyCopySettings } from './type';

export const DEFAULT_BUILTIN_COPY_MATCHER_IDS: BuiltinCopyMatcherId[] = [
'bold',
'italic',
'highlight',
'strikethrough',
'inline-code',
'inline-latex',
'link',
'wiki-link',
];

export interface CopyMatcher {
id: string;
type: ContextType;
enabled: boolean;
regex: RegExp;
captureGroup?: number;
name?: string;
}

export interface CopyMatchInfo {
content: string;
range: [number, number];
}

export function getCustomMatcherOrderId(matcher: CustomCopyMatcherSetting): string {
return `custom:${matcher.id}`;
}

export function normalizeBuiltinMatcherOrder(order: readonly string[] | undefined): BuiltinCopyMatcherId[] {
const knownIds = new Set<string>(DEFAULT_BUILTIN_COPY_MATCHER_IDS);
const orderedIds: BuiltinCopyMatcherId[] = [];

for (const id of order ?? []) {
if (knownIds.has(id) && !orderedIds.includes(id as BuiltinCopyMatcherId)) {
orderedIds.push(id as BuiltinCopyMatcherId);
}
}

for (const id of DEFAULT_BUILTIN_COPY_MATCHER_IDS) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}

return orderedIds;
}

export function normalizeMatcherOrder(order: readonly string[] | undefined, customMatchers: readonly CustomCopyMatcherSetting[]): string[] {
const customIds = new Set(customMatchers.map(getCustomMatcherOrderId));
const knownIds = new Set<string>([...DEFAULT_BUILTIN_COPY_MATCHER_IDS, ...customIds]);
const orderedIds: string[] = [];

for (const id of order ?? []) {
if (knownIds.has(id) && !orderedIds.includes(id)) orderedIds.push(id);
}

for (const id of DEFAULT_BUILTIN_COPY_MATCHER_IDS) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}

for (const id of customIds) {
if (!orderedIds.includes(id)) orderedIds.push(id);
}

return orderedIds;
}

function buildCustomCopyMatcher(setting: CustomCopyMatcherSetting, enabled: boolean): CopyMatcher | null {
if (!setting.pattern.trim()) return null;

try {
const flags = Array.from(new Set(`${setting.flags || ''}g`)).join('');
return {
id: getCustomMatcherOrderId(setting),
type: ContextType.CUSTOM,
regex: new RegExp(setting.pattern, flags),
enabled: enabled && setting.enabled,
captureGroup: setting.captureGroup,
name: setting.name.trim() || 'Custom matcher',
};
} catch {
return null;
}
}

export function getMatcherContent(match: RegExpExecArray, captureGroup?: number): string {
if (captureGroup !== undefined) return match[captureGroup] ?? match[0];

for (let i = 1; i < match.length; i++) {
if (match[i] !== undefined) return match[i];
}

return match[0];
}

export function getMatchInfo(fullText: string, cursorPosition: number, regex: RegExp, captureGroup?: number): CopyMatchInfo | null {
let match: RegExpExecArray | null;
regex.lastIndex = 0;
while ((match = regex.exec(fullText)) !== null) {
const matchStart = match.index;
const matchEnd = match.index + match[0].length;

if (cursorPosition >= matchStart && cursorPosition <= matchEnd) {
return {
content: getMatcherContent(match, captureGroup),
range: [matchStart, matchEnd],
};
}

if (match[0].length === 0) regex.lastIndex++;
}

return null;
}

export function buildBuiltinCopyMatchers(settings: EasyCopySettings, isIosApp: boolean): CopyMatcher[] {
// iOS 16.4 之前不支持后视(Lookbehinds),但支持前视(Lookaheads)
// 所以针对 iOS 平台使用只带前视的正则表达式,其他平台使用完整版本
const italicRegex = isIosApp ?
/(?:\*([^*]+)\*(?!\*)|_([^_]+)_(?!_))/g :
/(?:(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g;

const boldRegex = /(?:\*\*([^*]+)\*\*|__([^_]+)__)/g;

const matchers: CopyMatcher[] = [
{ id: 'bold', type: ContextType.BOLD, regex: boldRegex, enabled: !settings.customizeTargets || settings.enableBold },
{ id: 'italic', type: ContextType.ITALIC, regex: italicRegex, enabled: !settings.customizeTargets || settings.enableItalic },
{ id: 'highlight', type: ContextType.HIGHLIGHT, regex: /==([^=]+)==/g, enabled: !settings.customizeTargets || settings.enableHighlight },
{ id: 'strikethrough', type: ContextType.STRIKETHROUGH, regex: /~~([^~]+)~~/g, enabled: !settings.customizeTargets || settings.enableStrikethrough },
{ id: 'inline-code', type: ContextType.INLINECODE, regex: /`([^`]+)`/g, enabled: !settings.customizeTargets || settings.enableInlineCode },
{ id: 'inline-latex', type: ContextType.INLINELATEX, regex: /\$([^$]+)\$/g, enabled: !settings.customizeTargets || settings.enableInlineLatex },
{ id: 'wiki-link', type: ContextType.WIKILINK, regex: /\[\[([^\]]+)\]\]/g, enabled: !settings.customizeTargets || settings.enableWikiLink },
];

const matcherById = new Map(matchers.map(matcher => [matcher.id, matcher]));
return normalizeBuiltinMatcherOrder(settings.matcherOrder)
.map(id => matcherById.get(id))
.filter((matcher): matcher is CopyMatcher => Boolean(matcher));
}

export function buildCopyMatchers(settings: EasyCopySettings, isIosApp: boolean): CopyMatcher[] {
const matchers = [
...buildBuiltinCopyMatchers(settings, isIosApp),
...settings.customMatchers.map(matcher => buildCustomCopyMatcher(matcher, settings.customizeTargets)).filter(matcher => matcher !== null),
];
const matcherById = new Map(matchers.map(matcher => [matcher.id, matcher]));

return normalizeMatcherOrder(settings.matcherOrder, settings.customMatchers)
.map(id => matcherById.get(id))
.filter(matcher => matcher !== undefined);
}
Loading
Loading