diff --git a/plugins/text-case-converter/CHANGELOG.md b/plugins/text-case-converter/CHANGELOG.md index 4433734e..71eaa0e7 100644 --- a/plugins/text-case-converter/CHANGELOG.md +++ b/plugins/text-case-converter/CHANGELOG.md @@ -1,4 +1,11 @@ # 更新日志 +## 1.1.0 + +- 新增首字母大写、小驼峰、大驼峰、蛇形命名、尖叫蛇形命名 +- 支持符号分隔与中文参与蛇形命名,五种命名风格可互转 +- 补充 node --test 用例与异常通知 + ## 1.0.0 -- 剪贴板智能大小写转换 \ No newline at end of file + +- 剪贴板智能大小写转换 diff --git a/plugins/text-case-converter/README.md b/plugins/text-case-converter/README.md index d708fdc7..255f683c 100644 --- a/plugins/text-case-converter/README.md +++ b/plugins/text-case-converter/README.md @@ -1,36 +1,61 @@ # text-case-converter -ZTools 插件:选中文本或剪贴板中的英文内容,一键智能/手动转换大小写。 +智能大小写转换。支持智能转大小写、转大写、转小写、大小写反转、首字母大写、小驼峰命名、大驼峰命名、蛇形命名、尖叫蛇形命名。 ## 功能 +| 功能 | 触发关键字 | 选中有效文本时 | +| ----- | -------------------------- | ------- | +| 智能大小写 | `大小写` / `智能大小写` / `case` | 「智能大小写」 | +| 转大写 | `转大写` / `uppercase` | 「转大写」 | +| 转小写 | `转小写` / `lowercase` | 「转小写」 | +| 大小写反转 | `大小写反转` / `invert case` | 「大小写反转」 | +| 首字母大写 | `首字母大写` / `title case` | 「首字母大写」 | +| 小驼峰 | `小驼峰` / `camelCase` | 「小驼峰」 | +| 大驼峰 | `大驼峰` / `PascalCase` | 「大驼峰」 | +| 蛇形命名 | `蛇形命名` / `snake_case` | 「蛇形命名」 | +| 尖叫蛇形 | `尖叫蛇形` / `SCREAMING_SNAKE` | 「尖叫蛇形」 | -| 功能 | 触发关键字 | 选中有效文本时 | -| ----- | ------------------------ | ------------- | -| 智能大小写 | `大小写` / `智能大小写` / `case` | 超级面板显示「智能大小写」 | -| 转大写 | `转大写` / `uppercase` | 「转大写」 | -| 转小写 | `转小写` / `lowercase` | 「转小写」 | -| 大小写反转 | `大小写反转` / `invert case` | 「大小写反转」 | +**智能大小写**(只统计英文字母):大写多于小写则整段转小写,否则转大写。 - -**智能规则**(只统计英文字母 `[A-Za-z]`):大写字母数多于小写则整段转小写,否则(含相等)转大写。 +**命名风格**:先按符号切开(保留符号),再智能分词后套用目标风格。蛇形/尖叫蛇形允许中文作为命名一段(`xxx_中文_xxx`);其余命名风格将中文视为分隔符。 **输出**: -- 选中文本触发 → 转换后粘贴回原选区,系统通知后退出 -- 关键字触发 → 读取剪贴板,写回剪贴板并通知后退出 -- 无英文字母 → 提示「未检测到有效英文字母」,直接退出 +- 选中文本触发 → 粘贴回选区后退出 +- 关键字触发 → 写回剪贴板后退出 +- 无效或异常 → 系统通知提示后退出 + +## 示例 + +- 以插件名称为例: + +| 指令 | 输入示例 | 输出示例 | +| ----- | ------------------- | ------------------- | +| 智能大小写 | `TextCaseConverter` | 按大小写计数转全大或全小 | +| 转大写 | `textCaseConverter` | `TEXTCASECONVERTER` | +| 转小写 | `TextCaseConverter` | `textcaseconverter` | +| 大小写反转 | `TextCase` | `tEXTcASE` | +| 首字母大写 | `textCaseConverter` | `Text Case Converter` | +| 小驼峰 | `Text Case Converter` | `textCaseConverter` | +| 大驼峰 | `text_case_converter` | `TextCaseConverter` | +| 蛇形命名 | `textCaseConverter` | `text_case_converter` | +| 尖叫蛇形 | `textCaseConverter` | `TEXT_CASE_CONVERTER` | +| 蛇形+中文 | `text转换Converter` | `text_转换_converter` | + +> 可在 **插件详情-指令列表** 设置 **全局快捷键** 或 **禁用无用指令** ## 开发 ```bash pnpm install pnpm run build # tsc → 生成 public/*.png → 组装 dist/ -pnpm run icons # 仅生成 public/*.png(不提交,已 gitignore) -pnpm run dev # tsc -w(改完后需再 pnpm run build) +pnpm run test +pnpm run icons +pnpm run dev ``` -本地调试请加载 `dist/`。图标由 `scripts/svg-to-png.cjs` 在构建时生成,仓库不提交 PNG;示例字母见脚本顶部 `SAMPLE_LETTER`。 +本地调试请加载 `dist/`。图标由构建生成,仓库不提交 `public/` ## 构建产物(CI zip 根) @@ -39,20 +64,23 @@ dist/ ├── plugin.json ├── preload.js ├── case-convert.js +├── naming.js └── public/ ├── logo.png ├── smart.png ├── upper.png ├── lower.png - └── invert.png + ├── invert.png + ├── title.png + ├── camel.png + ├── pascal.png + ├── snake.png + └── screaming.png ``` - - ## 相关文档 - [第一个插件](https://ztoolscenter.github.io/ZTools-doc/first-plugin.html) - [preload-js](https://ztoolscenter.github.io/ZTools-doc/preload-js.html) - [plugin.json 配置](https://ztoolscenter.github.io/ZTools-doc/plugin-json.html) - [插件 API](https://ztoolscenter.github.io/ZTools-doc/plugin-api.html) - diff --git a/plugins/text-case-converter/package.json b/plugins/text-case-converter/package.json index e0c9c249..bb5ba0e8 100644 --- a/plugins/text-case-converter/package.json +++ b/plugins/text-case-converter/package.json @@ -4,13 +4,16 @@ "name": "UCPr", "url": "https://github.com/UCPr251" }, - "version": "1.0.0", + "version": "1.1.0", "type": "commonjs", - "description": "剪贴板智能大小写转换", + "description": "智能大小写转换。支持智能转大小写、转大写、转小写、大小写反转、首字母大写、小驼峰命名、大驼峰命名、蛇形命名、尖叫蛇形命名\n可在 插件详情-指令列表 设置全局快捷键或禁用无用指令", "scripts": { - "build": "tsc && node scripts/svg-to-png.cjs && node scripts/assemble-dist.cjs", + "prebuild": "pnpm icons", + "build": "tsc", + "postbuild": "node scripts/assemble-dist.cjs", "icons": "node scripts/svg-to-png.cjs", - "dev": "tsc -w --preserveWatchOutput" + "dev": "tsc -w --preserveWatchOutput", + "test": "pnpm run build && node --test \"test/*.test.cjs\"" }, "devDependencies": { "@resvg/resvg-js": "^2.6.2", diff --git a/plugins/text-case-converter/plugin.json b/plugins/text-case-converter/plugin.json index 9e8800d6..3eac376a 100644 --- a/plugins/text-case-converter/plugin.json +++ b/plugins/text-case-converter/plugin.json @@ -2,9 +2,9 @@ "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", "name": "text-case-converter", "title": "大小写转换", - "description": "剪贴板智能大小写转换", + "description": "智能大小写转换。支持智能转大小写、转大写、转小写、大小写反转、首字母大写、小驼峰命名、大驼峰命名、蛇形命名、尖叫蛇形命名。可在 插件详情-指令列表 设置全局快捷键或禁用无用指令", "author": "UCPr", - "version": "1.0.0", + "version": "1.1.0", "preload": "preload.js", "logo": "public/logo.png", "features": [ @@ -72,6 +72,86 @@ "minLength": 1 } ] + }, + { + "code": "title", + "explain": "转为首字母大写(按词分割并空格连接)", + "icon": "public/title.png", + "mainHide": true, + "cmds": [ + "首字母大写", + "title case", + { + "type": "regex", + "label": "首字母大写", + "match": "/[A-Za-z]/", + "minLength": 1 + } + ] + }, + { + "code": "camel", + "explain": "转为小驼峰命名", + "icon": "public/camel.png", + "mainHide": true, + "cmds": [ + "小驼峰", + "camelCase", + { + "type": "regex", + "label": "小驼峰", + "match": "/[A-Za-z]/", + "minLength": 1 + } + ] + }, + { + "code": "pascal", + "explain": "转为大驼峰命名", + "icon": "public/pascal.png", + "mainHide": true, + "cmds": [ + "大驼峰", + "PascalCase", + { + "type": "regex", + "label": "大驼峰", + "match": "/[A-Za-z]/", + "minLength": 1 + } + ] + }, + { + "code": "snake", + "explain": "转为蛇形命名(下划线连接,可含中文)", + "icon": "public/snake.png", + "mainHide": true, + "cmds": [ + "蛇形命名", + "snake_case", + { + "type": "regex", + "label": "蛇形命名", + "match": "/[A-Za-z]/", + "minLength": 1 + } + ] + }, + { + "code": "screaming", + "explain": "转为尖叫蛇形命名(大写下划线)", + "icon": "public/screaming.png", + "mainHide": true, + "cmds": [ + "尖叫蛇形", + "SCREAMING_SNAKE", + { + "type": "regex", + "label": "尖叫蛇形", + "match": "/[A-Za-z]/", + "minLength": 1 + } + ] } ] } \ No newline at end of file diff --git a/plugins/text-case-converter/scripts/assemble-dist.cjs b/plugins/text-case-converter/scripts/assemble-dist.cjs index 9ab349ab..d1d3265f 100644 --- a/plugins/text-case-converter/scripts/assemble-dist.cjs +++ b/plugins/text-case-converter/scripts/assemble-dist.cjs @@ -14,7 +14,7 @@ if (!fs.existsSync(distDir)) { process.exit(1) } -const requiredJs = ['preload.js', 'case-convert.js'] +const requiredJs = ['preload.js', 'case-convert.js', 'naming.js'] for (const file of requiredJs) { if (!fs.existsSync(path.join(distDir, file))) { console.error(`Missing dist/${file} — run tsc first`) diff --git a/plugins/text-case-converter/scripts/svg-to-png.cjs b/plugins/text-case-converter/scripts/svg-to-png.cjs index 9098ee0f..42ffa8a2 100644 --- a/plugins/text-case-converter/scripts/svg-to-png.cjs +++ b/plugins/text-case-converter/scripts/svg-to-png.cjs @@ -1,127 +1,154 @@ /** * Dynamically generate plugin icons (SVG → PNG). * Change SAMPLE_LETTER to customize the demo glyph used in icons. + * Renders each icon in a worker thread for parallel CPU work. */ const fs = require('fs') const path = require('path') -const { Resvg } = require('@resvg/resvg-js') - -/** Example letter shown in icons */ -const SAMPLE_LETTER = 't' +const { Worker, isMainThread, parentPort, workerData } = require('worker_threads') const PNG_SIZE = 128 -const publicDir = path.join(__dirname, '..', 'public') -const FONT = 'Segoe UI, Arial, sans-serif' +if (!isMainThread) { + const { Resvg } = require('@resvg/resvg-js') + const { svg, out, size } = workerData + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: size }, + font: { loadSystemFonts: true }, + }) + const png = resvg.render().asPng() + fs.writeFileSync(out, png) + parentPort.postMessage({ out, bytes: png.length }) +} else { + /** Example letter shown in icons */ + const SAMPLE_LETTER = 't' + + const publicDir = path.join(__dirname, '..', 'public') + const FONT = 'Segoe UI, Arial, sans-serif' -function normalizeLetter(raw) { - const ch = String(raw ?? '').trim() - if (!ch) { - throw new Error('SAMPLE_LETTER must be a non-empty character') - } - const letter = [...ch][0] - const lower = letter.toLowerCase() - const upper = letter.toUpperCase() - if (lower === upper) { - console.warn(`Warning: "${letter}" has no case distinction; icons may look identical.`) + function normalizeLetter(raw) { + const ch = String(raw ?? '').trim() + if (!ch) { + throw new Error('SAMPLE_LETTER must be a non-empty character') + } + const letter = [...ch][0] + const lower = letter.toLowerCase() + const upper = letter.toUpperCase() + if (lower === upper) { + console.warn(`Warning: "${letter}" has no case distinction; icons may look identical.`) + } + return { lower, upper } } - return { lower, upper } -} -function esc(text) { - return String(text) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} + function esc(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } -function svgRoot(bg, body) { - return ` + function svgRoot(bg, body) { + return ` ${body} ` -} + } -function letterText(x, y, text, fontSize) { - return ` ${esc(text)}` -} + function letterText(x, y, text, fontSize) { + return ` ${esc(text)}` + } -function arrow(y, stroke, strokeWidth = 3, headSize = 4) { - const mid = y - const tip = mid - return [ - ` `, - ` `, - ].join('\n') -} + function arrow(y, stroke, strokeWidth = 3, headSize = 4) { + const mid = y + const tip = mid + return [ + ` `, + ` `, + ].join('\n') + } -function buildSvgs({ lower, upper }) { - return { - logo: svgRoot( - '#2563EB', - letterText(32, 42, `${upper}${lower}`, 28), - ), - smart: svgRoot( - '#2563EB', - [ - ` `, - ` `, - ` `, - ].join('\n'), - ), - // a → A - upper: svgRoot( - '#1D4ED8', - [ - letterText(18, 40, lower, 24), - arrow(32, '#BFDBFE'), - letterText(48, 40, upper, 24), - ].join('\n'), - ), - // A → a - lower: svgRoot( - '#0EA5E9', - [ - letterText(18, 40, upper, 24), - arrow(32, '#BAE6FD'), - letterText(48, 40, lower, 24), - ].join('\n'), - ), - // A → a - // a → A - invert: svgRoot( - '#7C3AED', - [ - letterText(18, 26, upper, 18), - arrow(20, '#E9D5FF', 2.5, 3), - letterText(48, 26, lower, 18), - letterText(18, 50, lower, 18), - arrow(44, '#E9D5FF', 2.5, 3), - letterText(48, 50, upper, 18), - ].join('\n'), - ), + function buildSvgs({ lower, upper }) { + return { + logo: svgRoot('#2563EB', letterText(32, 42, `${upper}${lower}`, 28)), + smart: svgRoot( + '#2563EB', + [ + ` `, + ` `, + ` `, + ].join('\n'), + ), + upper: svgRoot( + '#1D4ED8', + [ + letterText(18, 40, lower, 24), + arrow(32, '#BFDBFE'), + letterText(48, 40, upper, 24), + ].join('\n'), + ), + lower: svgRoot( + '#0EA5E9', + [ + letterText(18, 40, upper, 24), + arrow(32, '#BAE6FD'), + letterText(48, 40, lower, 24), + ].join('\n'), + ), + invert: svgRoot( + '#7C3AED', + [ + letterText(18, 26, upper, 18), + arrow(20, '#E9D5FF', 2.5, 3), + letterText(48, 26, lower, 18), + letterText(18, 50, lower, 18), + arrow(44, '#E9D5FF', 2.5, 3), + letterText(48, 50, upper, 18), + ].join('\n'), + ), + title: svgRoot('#059669', letterText(32, 40, `${upper}${lower} ${upper}${lower}`, 22)), + camel: svgRoot('#D97706', letterText(32, 42, `a${upper}a${upper}`, 24)), + pascal: svgRoot('#EA580C', letterText(32, 42, `${upper}a${upper}a`, 24)), + snake: svgRoot('#64748B', letterText(32, 40, `${lower}_${lower}_${lower}`, 26)), + screaming: svgRoot('#BE123C', letterText(32, 40, `${upper}_${upper}_${upper}`, 20)), + } } -} -function writePng(name, svg) { - const resvg = new Resvg(svg, { - fitTo: { mode: 'width', value: PNG_SIZE }, - font: { loadSystemFonts: true }, - }) - const png = resvg.render().asPng() - const out = path.join(publicDir, `${name}.png`) - fs.writeFileSync(out, png) - console.log(`Wrote ${path.relative(process.cwd(), out)} (${png.length} bytes)`) -} + /** 在独立线程中渲染并写入一张 PNG */ + function writePngConcurrent(name, svg) { + const out = path.join(publicDir, `${name}.png`) + return new Promise((resolve, reject) => { + const worker = new Worker(__filename, { + workerData: { svg, out, size: PNG_SIZE }, + }) + worker.on('message', (msg) => { + console.log(`Wrote ${path.relative(process.cwd(), msg.out)} (${msg.bytes} bytes)`) + resolve(msg) + }) + worker.on('error', reject) + worker.on('exit', (code) => { + if (code !== 0) reject(new Error(`Icon worker exited with code ${code} (${name})`)) + }) + }) + } -fs.mkdirSync(publicDir, { recursive: true }) + async function main() { + fs.mkdirSync(publicDir, { recursive: true }) + const sample = normalizeLetter(SAMPLE_LETTER) + const svgs = buildSvgs(sample) + const started = Date.now() -const sample = normalizeLetter(SAMPLE_LETTER) -const svgs = buildSvgs(sample) + await Promise.all( + Object.entries(svgs).map(([name, svg]) => writePngConcurrent(name, svg)), + ) -for (const [name, svg] of Object.entries(svgs)) { - writePng(name, svg) -} + console.log( + `Done in ${Date.now() - started}ms. SAMPLE_LETTER = "${SAMPLE_LETTER}" → upper "${sample.upper}" / lower "${sample.lower}"`, + ) + } -console.log(`Done. SAMPLE_LETTER = "${SAMPLE_LETTER}" → upper "${sample.upper}" / lower "${sample.lower}"`) + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/plugins/text-case-converter/src/case-convert.ts b/plugins/text-case-converter/src/case-convert.ts index 8075a333..5852ab14 100644 --- a/plugins/text-case-converter/src/case-convert.ts +++ b/plugins/text-case-converter/src/case-convert.ts @@ -1,5 +1,21 @@ -/** Feature codes matching plugin.json */ -export type CaseFeatureCode = 'smart' | 'upper' | 'lower' | 'invert' +import { + convertNamingByCode, + hasConvertibleNaming, + isNamingCode, + withEdgeWhitespace, +} from './naming.js' + +/** 全部功能码 */ +export type CaseFeatureCode = + | 'smart' + | 'upper' + | 'lower' + | 'invert' + | 'title' + | 'camel' + | 'pascal' + | 'snake' + | 'screaming' const ENGLISH_LETTER = /[A-Za-z]/ @@ -8,10 +24,11 @@ export function hasEnglishLetter(text: string): boolean { return ENGLISH_LETTER.test(text) } +export { hasConvertibleNaming, isNamingCode } + /** - * 智能转换:仅统计英文字母。 - * - 大写字母多于小写字母 → 转为小写 - * - 否则(包括相等) → 转为大写 + * 智能转换:仅统计英文字母 + * 大写多于小写则转小写,否则转大写 */ export function smartConvert(text: string): string { let upper = 0 @@ -42,21 +59,26 @@ export function invertCase(text: string): string { return result } +/** 按功能码转换,未知码返回 null */ export function convertByCode(code: string, text: string): string | null { + if (isNamingCode(code)) { + return convertNamingByCode(code, text) + } switch (code as CaseFeatureCode) { case 'smart': - return smartConvert(text) + return withEdgeWhitespace(text, smartConvert) case 'upper': - return toUpper(text) + return withEdgeWhitespace(text, toUpper) case 'lower': - return toLower(text) + return withEdgeWhitespace(text, toLower) case 'invert': - return invertCase(text) + return withEdgeWhitespace(text, invertCase) default: return null } } +/** 成功提示文案 */ export function successMessage(code: string): string { switch (code as CaseFeatureCode) { case 'smart': @@ -67,6 +89,16 @@ export function successMessage(code: string): string { return '已转为小写' case 'invert': return '已反转大小写' + case 'title': + return '已转为首字母大写' + case 'camel': + return '已转为小驼峰' + case 'pascal': + return '已转为大驼峰' + case 'snake': + return '已转为蛇形命名' + case 'screaming': + return '已转为尖叫蛇形' default: return '转换完成' } diff --git a/plugins/text-case-converter/src/naming.ts b/plugins/text-case-converter/src/naming.ts new file mode 100644 index 00000000..ce75b355 --- /dev/null +++ b/plugins/text-case-converter/src/naming.ts @@ -0,0 +1,299 @@ +/** 命名风格代码 */ +export type NamingStyle = 'title' | 'camel' | 'pascal' | 'snake' | 'screaming' + +const HAN_CHAR = /\p{Script=Han}/u +const ENGLISH_CAMEL = + /[A-Z]+(?=[A-Z][a-z]|[0-9]|$)|[A-Z]?[a-z]+|[0-9]+/g + +/** 是否为汉字 */ +function isHan(ch: string): boolean { + return HAN_CHAR.test(ch) +} + +/** 是否可作为命名内容保留在段内(字母数字下划线汉字) */ +function isInnerContentChar(ch: string): boolean { + return /[A-Za-z0-9_]/.test(ch) || isHan(ch) || /\s/.test(ch) +} + +/** 外部分隔符:符号等,不含字母数字下划线汉字与空白 */ +function isOuterSeparator(ch: string): boolean { + return !isInnerContentChar(ch) +} + +type Piece = { type: 'text' | 'sep'; value: string } + +/** 按外部分隔符切开并保留分隔符 */ +export function splitByOuterSeparators(input: string): Piece[] { + const pieces: Piece[] = [] + let buf = '' + let sepMode: boolean | null = null + + for (const ch of input) { + const sep = isOuterSeparator(ch) + if (sepMode === null) { + sepMode = sep + buf = ch + continue + } + if (sep === sepMode) { + buf += ch + } else { + pieces.push({ type: sepMode ? 'sep' : 'text', value: buf }) + buf = ch + sepMode = sep + } + } + if (buf) { + pieces.push({ type: sepMode ? 'sep' : 'text', value: buf }) + } + return pieces +} + +/** 拆英文驼峰与连续大写缩写 */ +export function tokenizeEnglish(chunk: string): string[] { + if (!chunk) return [] + const words: string[] = [] + for (const part of chunk.split(/[_\s]+/)) { + if (!part) continue + const matches = part.match(ENGLISH_CAMEL) + if (matches) { + for (const m of matches) words.push(m) + } + } + return words +} + +type Token = + | { kind: 'word'; value: string; han: boolean } + | { kind: 'sep'; value: string } + +/** 蛇形类:汉字作为词参与连接 */ +function tokenizeForSnake(segment: string): Token[] { + const tokens: Token[] = [] + let i = 0 + const chars = [...segment] + + while (i < chars.length) { + const ch = chars[i] + if (/[\s_]/.test(ch)) { + i++ + continue + } + if (isHan(ch)) { + let han = ch + i++ + while (i < chars.length && isHan(chars[i])) { + han += chars[i] + i++ + } + tokens.push({ kind: 'word', value: han, han: true }) + continue + } + if (/[A-Za-z0-9]/.test(ch)) { + let eng = ch + i++ + while (i < chars.length && /[A-Za-z0-9]/.test(chars[i])) { + eng += chars[i] + i++ + } + for (const w of tokenizeEnglish(eng)) { + tokens.push({ kind: 'word', value: w, han: false }) + } + continue + } + i++ + } + return tokens +} + +/** 驼峰/标题类:汉字作为段内分隔符 */ +function tokenizeForCamelFamily(segment: string): Token[] { + const tokens: Token[] = [] + let i = 0 + const chars = [...segment] + + while (i < chars.length) { + const ch = chars[i] + if (isHan(ch)) { + let han = ch + i++ + while (i < chars.length && isHan(chars[i])) { + han += chars[i] + i++ + } + tokens.push({ kind: 'sep', value: han }) + continue + } + if (/[\s_]/.test(ch)) { + i++ + continue + } + if (/[A-Za-z0-9]/.test(ch)) { + let eng = ch + i++ + while (i < chars.length && /[A-Za-z0-9]/.test(chars[i])) { + eng += chars[i] + i++ + } + for (const w of tokenizeEnglish(eng)) { + tokens.push({ kind: 'word', value: w, han: false }) + } + continue + } + i++ + } + return tokens +} + +/** 单词首字母大写其余小写 */ +function capWord(word: string): string { + if (!word) return word + if (/^[0-9]+$/.test(word)) return word + const lower = word.toLowerCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) +} + +/** 将词列表格式化为标题样式 */ +function formatTitleWords(words: string[]): string { + return words.map(capWord).join(' ') +} + +/** 将词列表格式化为小驼峰 */ +function formatCamelWords(words: string[]): string { + if (words.length === 0) return '' + return words + .map((w, i) => { + if (/^[0-9]+$/.test(w)) return w + const lower = w.toLowerCase() + if (i === 0) return lower + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join('') +} + +/** 将词列表格式化为大驼峰 */ +function formatPascalWords(words: string[]): string { + return words + .map((w) => { + if (/^[0-9]+$/.test(w)) return w + return capWord(w) + }) + .join('') +} + +/** 将词列表格式化为蛇形 */ +function formatSnakeWords(words: { value: string; han: boolean }[]): string { + return words + .map((w) => (w.han ? w.value : w.value.toLowerCase())) + .join('_') +} + +/** 将词列表格式化为尖叫蛇形 */ +function formatScreamingWords(words: { value: string; han: boolean }[]): string { + return words + .map((w) => (w.han ? w.value : w.value.toUpperCase())) + .join('_') +} + +/** 格式化蛇形类 token 流 */ +function formatSnakeTokens(tokens: Token[], screaming: boolean): string { + const words = tokens.filter((t): t is Extract => t.kind === 'word') + if (words.length === 0) return '' + return screaming ? formatScreamingWords(words) : formatSnakeWords(words) +} + +/** 格式化驼峰族 token 流(汉字分隔保留) */ +function formatCamelFamilyTokens(tokens: Token[], style: 'title' | 'camel' | 'pascal'): string { + let result = '' + let pendingWords: string[] = [] + + const flush = () => { + if (pendingWords.length === 0) return + if (style === 'title') result += formatTitleWords(pendingWords) + else if (style === 'camel') result += formatCamelWords(pendingWords) + else result += formatPascalWords(pendingWords) + pendingWords = [] + } + + for (const t of tokens) { + if (t.kind === 'word') { + pendingWords.push(t.value) + } else { + flush() + result += t.value + } + } + flush() + return result +} + +/** 转换单个文本段 */ +function convertSegment(segment: string, style: NamingStyle): string { + if (!segment) return segment + + if (style === 'snake' || style === 'screaming') { + return formatSnakeTokens(tokenizeForSnake(segment), style === 'screaming') + } + + return formatCamelFamilyTokens(tokenizeForCamelFamily(segment), style) +} + +/** 是否存在可转换的命名片段 */ +export function hasConvertibleNaming(text: string): boolean { + if (!text) return false + return /[A-Za-z\u4e00-\u9fff]/.test(text) +} + +/** 剥离并保留首尾空白,只对中间正文做转换 */ +export function withEdgeWhitespace(text: string, transform: (core: string) => string): string { + const lead = text.match(/^\s*/)?.[0] ?? '' + const rest = text.slice(lead.length) + const trail = rest.match(/\s*$/)?.[0] ?? '' + const core = rest.slice(0, rest.length - trail.length) + if (!core) return text + return lead + transform(core) + trail +} + +/** 按目标命名风格转换整段文本 */ +export function convertNaming(text: string, style: NamingStyle): string { + return withEdgeWhitespace(text, (core) => { + const pieces = splitByOuterSeparators(core) + if (pieces.length === 0) return core + return pieces + .map((p) => (p.type === 'sep' ? p.value : convertSegment(p.value, style))) + .join('') + }) +} + +export function toTitleCase(text: string): string { + return convertNaming(text, 'title') +} + +export function toCamelCase(text: string): string { + return convertNaming(text, 'camel') +} + +export function toPascalCase(text: string): string { + return convertNaming(text, 'pascal') +} + +export function toSnakeCase(text: string): string { + return convertNaming(text, 'snake') +} + +export function toScreamingSnakeCase(text: string): string { + return convertNaming(text, 'screaming') +} + +const NAMING_CODES = new Set(['title', 'camel', 'pascal', 'snake', 'screaming']) + +/** 是否为命名风格功能码 */ +export function isNamingCode(code: string): boolean { + return NAMING_CODES.has(code) +} + +/** 按功能码做命名转换,未知码返回 null */ +export function convertNamingByCode(code: string, text: string): string | null { + if (!isNamingCode(code)) return null + return convertNaming(text, code as NamingStyle) +} diff --git a/plugins/text-case-converter/src/preload.ts b/plugins/text-case-converter/src/preload.ts index 1f296bea..eabd2fba 100644 --- a/plugins/text-case-converter/src/preload.ts +++ b/plugins/text-case-converter/src/preload.ts @@ -1,8 +1,9 @@ import { clipboard } from 'electron' import { convertByCode, + hasConvertibleNaming, hasEnglishLetter, - successMessage, + isNamingCode, } from './case-convert.js' /** 计划退出插件的时长 */ @@ -17,7 +18,7 @@ function clearIdleKillTimer(): void { } } -/** 隐藏窗口,如果3分钟内没有使用则退出插件 */ +/** 隐藏窗口,如果三分钟内没有使用则退出插件 */ function scheduleIdleKill(): void { clearIdleKillTimer() idleKillTimer = setTimeout(() => { @@ -42,42 +43,65 @@ function resolveInputText(type: string, payload: unknown): string { return clipboard.readText() } -/** 隐藏窗口,计划退出 */ +/** 隐藏窗口并计划空闲退出 */ function exitPlugin(): void { window.ztools.outPlugin(false) scheduleIdleKill() } +/** 提示后退出 */ +function failAndExit(message: string): void { + window.ztools.showNotification(message) + window.ztools.hideMainWindow() + exitPlugin() +} + window.ztools.onPluginEnter(({ code, type, payload }: { code: string; type: string; payload: unknown }) => { clearIdleKillTimer() - const text = resolveInputText(type, payload) + try { + const text = resolveInputText(type, payload) - if (!hasEnglishLetter(text)) { - window.ztools.showNotification('未检测到有效英文字母') - window.ztools.hideMainWindow() - exitPlugin() - return - } + if (!text.trim()) { + failAndExit('文本为空,无法转换') + return + } - const result = convertByCode(code, text) - if (result === null) { - window.ztools.showNotification('未知功能') - window.ztools.hideMainWindow() - exitPlugin() - return - } + if (isNamingCode(code)) { + if (!hasConvertibleNaming(text)) { + failAndExit('无法识别可转换的命名片段') + return + } + } else if (!hasEnglishLetter(text)) { + failAndExit('未检测到有效英文字母') + return + } - const fromSelection = type === 'regex' || type === 'over' - if (fromSelection) { - window.ztools.hideMainWindowPasteText(result) - } else { - window.ztools.copyText(result) - window.ztools.hideMainWindow() - } + const result = convertByCode(code, text) + if (result === null) { + failAndExit('未知功能') + return + } - // window.ztools.showNotification(successMessage(code)) - exitPlugin() + if (isNamingCode(code) && result === '') { + failAndExit('无法识别可转换的命名片段') + return + } + + const fromSelection = type === 'regex' || type === 'over' + if (fromSelection) { + window.ztools.hideMainWindowPasteText(result) + } else { + window.ztools.copyText(result) + window.ztools.hideMainWindow() + } + + // window.ztools.showNotification(successMessage(code)) + exitPlugin() + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + failAndExit(detail ? `转换失败:${detail}` : '转换失败') + } }) window.ztools.onPluginOut((processExit) => { diff --git a/plugins/text-case-converter/test/case-convert.test.cjs b/plugins/text-case-converter/test/case-convert.test.cjs new file mode 100644 index 00000000..34d0ca5d --- /dev/null +++ b/plugins/text-case-converter/test/case-convert.test.cjs @@ -0,0 +1,42 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const { + smartConvert, + toUpper, + toLower, + invertCase, + hasEnglishLetter, + convertByCode, +} = require('../dist/case-convert.js') + +describe('case-convert', () => { + it('smartConvert', () => { + assert.equal(smartConvert('Hello'), 'HELLO') + assert.equal(smartConvert('HELLO'), 'hello') + assert.equal(smartConvert('HeLl'), 'HELL') + }) + + it('upper lower invert', () => { + assert.equal(toUpper('textCase'), 'TEXTCASE') + assert.equal(toLower('TextCase'), 'textcase') + assert.equal(invertCase('TextCase'), 'tEXTcASE') + }) + + it('hasEnglishLetter', () => { + assert.equal(hasEnglishLetter('你好Hi'), true) + assert.equal(hasEnglishLetter('你好'), false) + }) + + it('convertByCode dispatches naming', () => { + assert.equal(convertByCode('camel', 'Text Case Converter'), 'textCaseConverter') + assert.equal(convertByCode('snake', 'textCaseConverter'), 'text_case_converter') + assert.equal(convertByCode('unknown', 'x'), null) + }) + + it('preserves edge whitespace via convertByCode', () => { + assert.equal(convertByCode('upper', ' ab\n'), ' AB\n') + assert.equal(convertByCode('lower', '\tAB '), '\tab ') + assert.equal(convertByCode('invert', ' Ab '), ' aB ') + assert.equal(convertByCode('smart', '\nHello\n'), '\nHELLO\n') + }) +}) diff --git a/plugins/text-case-converter/test/naming.test.cjs b/plugins/text-case-converter/test/naming.test.cjs new file mode 100644 index 00000000..8258aa1e --- /dev/null +++ b/plugins/text-case-converter/test/naming.test.cjs @@ -0,0 +1,81 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const { + toTitleCase, + toCamelCase, + toPascalCase, + toSnakeCase, + toScreamingSnakeCase, + hasConvertibleNaming, +} = require('../dist/naming.js') + +describe('naming pure english', () => { + it('clearIdleKillTimer round-trip styles', () => { + const src = 'clearIdleKillTimer' + assert.equal(toTitleCase(src), 'Clear Idle Kill Timer') + assert.equal(toCamelCase(src), 'clearIdleKillTimer') + assert.equal(toPascalCase(src), 'ClearIdleKillTimer') + assert.equal(toSnakeCase(src), 'clear_idle_kill_timer') + assert.equal(toScreamingSnakeCase(src), 'CLEAR_IDLE_KILL_TIMER') + }) + + it('IDLE_KILL_MS to title and camel', () => { + assert.equal(toTitleCase('IDLE_KILL_MS'), 'Idle Kill Ms') + assert.equal(toCamelCase('IDLE_KILL_MS'), 'idleKillMs') + assert.equal(toPascalCase('IDLE_KILL_MS'), 'IdleKillMs') + assert.equal(toSnakeCase('IDLE_KILL_MS'), 'idle_kill_ms') + }) + + it('title to camel', () => { + assert.equal(toCamelCase('Clear Idle Kill Timer'), 'clearIdleKillTimer') + assert.equal(toCamelCase('Text Case Converter'), 'textCaseConverter') + }) + + it('snake to pascal', () => { + assert.equal(toPascalCase('text_case_converter'), 'TextCaseConverter') + }) + + it('XMLHttpRequest split', () => { + assert.equal(toTitleCase('XMLHttpRequest'), 'Xml Http Request') + assert.equal(toCamelCase('XMLHttpRequest'), 'xmlHttpRequest') + }) + + it('idempotent camel', () => { + assert.equal(toCamelCase('textCaseConverter'), 'textCaseConverter') + }) + + it('preserves leading and trailing whitespace', () => { + assert.equal(toCamelCase(' text_case_converter\n'), ' textCaseConverter\n') + assert.equal(toSnakeCase('\tTextCaseConverter '), '\ttext_case_converter ') + assert.equal(toTitleCase('\nclearIdleKillTimer\r\n'), '\nClear Idle Kill Timer\r\n') + }) +}) + +describe('naming separators and chinese', () => { + it('outer symbol separators', () => { + assert.equal(toCamelCase('user-name_info'), 'user-nameInfo') + assert.equal(toTitleCase('user-name_info'), 'User-Name Info') + assert.equal(toSnakeCase('foo.barBaz'), 'foo.bar_baz') + }) + + it('chinese as snake word', () => { + assert.equal(toSnakeCase('user名称Name'), 'user_名称_name') + assert.equal(toScreamingSnakeCase('user名称Name'), 'USER_名称_NAME') + assert.equal(toSnakeCase('text转换Converter'), 'text_转换_converter') + }) + + it('chinese as camel-family separator', () => { + assert.equal(toCamelCase('hello世界world'), 'hello世界world') + assert.equal(toPascalCase('user名称Name'), 'User名称Name') + assert.equal(toTitleCase('获取UserID'), '获取User Id') + }) +}) + +describe('naming guards', () => { + it('hasConvertibleNaming', () => { + assert.equal(hasConvertibleNaming('abc'), true) + assert.equal(hasConvertibleNaming('中文'), true) + assert.equal(hasConvertibleNaming('---'), false) + assert.equal(hasConvertibleNaming(''), false) + }) +})