update
This commit is contained in:
8
lib/adaptive-poll.js
Normal file
8
lib/adaptive-poll.js
Normal file
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
function computePollMs(base, visible, { cap = 4000 } = {}) {
|
||||
if (visible) return base;
|
||||
return Math.min(base * 2, cap);
|
||||
}
|
||||
|
||||
module.exports = { computePollMs };
|
||||
31
lib/clip-payload.js
Normal file
31
lib/clip-payload.js
Normal file
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把一行 clips 记录转成"要写进系统剪贴板的东西"。
|
||||
*
|
||||
* 单独抽出来是因为这里踩过坑:copyClipToClipboard 里读的是
|
||||
* rowToClip() 的返回值上的 .image,而 rowToClip() 只产出 imageBase64,
|
||||
* 于是 Buffer.from(undefined) 抛错 —— 图片条目的"复制"整个静默失败,
|
||||
* 而且错误被 catch 吞掉,用户只会觉得"点了没反应"。
|
||||
*
|
||||
* 这一层不碰 electron,可以直接单测。
|
||||
*
|
||||
* @param {object} row 来自 `SELECT * FROM clips`(必须含 image 列)
|
||||
* @returns {{kind:'text', text:string} | {kind:'image', buffer:Buffer}}
|
||||
* @throws {Error} row 缺失或图片数据为空时
|
||||
*/
|
||||
function clipPayload(row) {
|
||||
if (!row) throw new Error('记录不存在');
|
||||
if (row.type === 'text') {
|
||||
return { kind: 'text', text: row.text || '' };
|
||||
}
|
||||
if (row.type === 'image') {
|
||||
if (row.image == null) throw new Error('图片数据为空');
|
||||
const buffer = Buffer.isBuffer(row.image) ? row.image : Buffer.from(row.image);
|
||||
if (buffer.length === 0) throw new Error('图片数据为空');
|
||||
return { kind: 'image', buffer };
|
||||
}
|
||||
throw new Error(`未知的记录类型: ${row.type}`);
|
||||
}
|
||||
|
||||
module.exports = { clipPayload };
|
||||
87
lib/echo-suppress.js
Normal file
87
lib/echo-suppress.js
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 剪贴板"回声抑制"状态机 —— 区分"应用自己写进剪贴板的内容"与"外部新复制"。
|
||||
*
|
||||
* 纯逻辑,不依赖 Electron:main.js 的 pollClipboard / writePayloadToClipboard /
|
||||
* initializeClipboardState 都用它。抽出来之前这份逻辑内联在 main.js 里,
|
||||
* 测试只能克隆一份状态机自己测自己(main.js 改坏了测试照样绿)—— 而这是
|
||||
* 丢数据级的逻辑,必须有真保护。规则都从真实踩坑里固化,改动前先看
|
||||
* test/echo-suppress.test.js。
|
||||
*
|
||||
* 规则:
|
||||
* - 记**内容**而不是记 flag:记下我们写进去的内容,下一次轮询真正读到同样
|
||||
* 内容时才吞。占位 flag 会被"两次 tick 之间夹的外部复制"错误消费,
|
||||
* 把那次外部复制静默吞掉(丢数据)。
|
||||
* - 读到不匹配的新内容时顺手清掉 pending,避免它误伤之后内容相同的外部
|
||||
* 复制(点复制 A → 外部复制 B → 外部又复制回 A,第二个 A 必须入库)。
|
||||
* - 文本被清空('')*不* 消化 pending:外部中途清空剪贴板不该让回声标记
|
||||
* 失效,否则用户接着复制同内容会重复入库。
|
||||
* - 基线(last)随每次"变化"更新;自己写入时同时预置 pending 和 last,
|
||||
* 让下一个 tick 走"无变化"分支,连读图/编码的代价都不付。
|
||||
* 为此 markOwnWrite 返回一个 undo 闭包:**写入抛错时必须调用它**——
|
||||
* 失败时剪贴板上还是旧内容,基线却已预置成新值,不回滚的话下个 tick
|
||||
* 会把真实的旧内容当成"新变化"再入一次库(旧内容早已在库里 → 重复条目)。
|
||||
*/
|
||||
function createEchoSuppress() {
|
||||
let last = null; // 上次见到的内容(变化检测基线);null = 还没见过
|
||||
let pending = null; // 我们自己写进去、等着"被读到"后吞掉的内容;null = 无
|
||||
return {
|
||||
/** 启动采样:记住当前剪贴板内容当基线,不当新内容、不标记回声。 */
|
||||
seed(value) { last = value; },
|
||||
|
||||
/**
|
||||
* 应用自己写剪贴板:预置基线(下一 tick 判"无变化")+ 记下回声内容。
|
||||
* 返回 undo 闭包:实际写入(clipboard.writeText / writeImage)抛错时
|
||||
* 调用它以回滚基线、清掉回声标记(见文件头最后一条规则)。
|
||||
*/
|
||||
markOwnWrite(value) {
|
||||
const prevLast = last;
|
||||
pending = value;
|
||||
last = value;
|
||||
return () => { pending = null; last = prevLast; };
|
||||
},
|
||||
|
||||
/** 写入失败后调用:清回声标记,别吞掉用户接下来的真实复制。 */
|
||||
clearPending() { pending = null; },
|
||||
|
||||
/** 1×1/空图出现时重置基线(poll 侧按"没东西"处理,下一次任何图都算新)。 */
|
||||
resetBaseline() { last = null; },
|
||||
|
||||
/**
|
||||
* pollClipboard 入库失败时调用:撤销上一次 observe 对基线/pending 的修改,
|
||||
* 让下一次 tick 重新探测同一条内容。
|
||||
*
|
||||
* 背景:observe 一旦判定 'new',就已经把 last 设成新值;如果跟着的
|
||||
* stmt.insertText / insertImage 抛错(SQLite 临时锁、磁盘满、字段非法……),
|
||||
* 不回滚的话下一次 tick 读到同一条内容会判 'unchanged',**那条内容被
|
||||
* 永久吞掉** —— 直到用户重启(initializeClipboardState 重新 seed 基线)
|
||||
* 才能恢复。回滚后下次 tick 会再判 'new' 再试一次入库,符合预期。
|
||||
*
|
||||
* 一次 observe 只对应一次入库尝试:last 在入库失败之前已被 observe 改过,
|
||||
* 但 pending 通常仍是 null(自己写入是 markOwnWrite 走的另一条路)——
|
||||
* 不区分地统一置空足够,逻辑简单也避免回退到 observe 之前的精确快照。
|
||||
*/
|
||||
abortLastObserve() { last = null; pending = null; },
|
||||
|
||||
/**
|
||||
* 喂入一次轮询读到的内容。
|
||||
* @returns {'unchanged'|'empty'|'suppressed'|'new'}
|
||||
* unchanged —— 与基线相同(文本:调用方继续图片分支;图片:直接 return)
|
||||
* empty —— 空串,剪贴板被清空(仅文本;不消化 pending)
|
||||
* suppressed —— 是我们自己写进去的回声,已吞
|
||||
* new —— 外部新内容,调用方应入库
|
||||
*/
|
||||
observe(value) {
|
||||
if (value === last) return 'unchanged';
|
||||
last = value;
|
||||
if (value === '') return 'empty';
|
||||
if (pending !== null && value === pending) { pending = null; return 'suppressed'; }
|
||||
// 不匹配的新内容顺手消化 pending(见文件头规则 2)
|
||||
if (pending !== null) pending = null;
|
||||
return 'new';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEchoSuppress };
|
||||
15
lib/favorites-cap.js
Normal file
15
lib/favorites-cap.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_MAX = 1000;
|
||||
|
||||
/**
|
||||
* Returns { ok: true } if current row count < max, else { ok: false, message }.
|
||||
* Pure: takes a db-like object so tests can mock.
|
||||
*/
|
||||
function checkAgainstCap(db, max = DEFAULT_MAX) {
|
||||
const n = db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n;
|
||||
if (n < max) return { ok: true };
|
||||
return { ok: false, message: `已达收藏上限 ${max} 条` };
|
||||
}
|
||||
|
||||
module.exports = { checkAgainstCap, DEFAULT_MAX };
|
||||
39
lib/image-cap.js
Normal file
39
lib/image-cap.js
Normal file
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
const { nativeImage } = require('electron');
|
||||
|
||||
const PREVIEW_BYTES = 12 * 1024; // >12 KB → needs resizing for inline preview
|
||||
const SOFT_CAP_BYTES = 2 * 1024 * 1024; // 2 MB
|
||||
const RESIZE_WIDTH = 800; // 大图压到这个宽度,保证 SOFT_CAP_BYTES 不爆
|
||||
|
||||
/**
|
||||
* Returns { buf, preview }.
|
||||
* - buf: input buffer unchanged.
|
||||
* - preview: null if the buffer is < PREVIEW_BYTES (12 KB), otherwise the
|
||||
* literal string '[图片]'. The caller (main.js) is responsible for
|
||||
* actually resizing when buf.length > SOFT_CAP_BYTES (2 MB); the
|
||||
* preview marker is purely a signal that the buffer is non-trivial.
|
||||
*/
|
||||
function capImage(buf) {
|
||||
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
|
||||
if (b.length < PREVIEW_BYTES) return { buf: b, preview: null };
|
||||
return { buf: b, preview: '[图片]' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。
|
||||
* 三处共用:pollClipboard / bringFavoriteIntoHistory。
|
||||
*
|
||||
* 返回 { buf, preview },preview 由 capImage 给出(无图/小图 = null),
|
||||
* 给数据库的 preview 列用。
|
||||
*/
|
||||
function normalizeImageForStorage(buf) {
|
||||
const cap = capImage(buf);
|
||||
// 小图/空图:直接原样入库
|
||||
if (!cap.preview) return cap;
|
||||
if (cap.buf.length <= SOFT_CAP_BYTES) return cap;
|
||||
// 大图:nativeImage 重新编码到 RESIZE_WIDTH 宽,PNG 重编码比原图小一个数量级
|
||||
const img = nativeImage.createFromBuffer(cap.buf);
|
||||
return { buf: img.resize({ width: RESIZE_WIDTH }).toPNG(), preview: cap.preview };
|
||||
}
|
||||
|
||||
module.exports = { capImage, normalizeImageForStorage, PREVIEW_BYTES, SOFT_CAP_BYTES, RESIZE_WIDTH };
|
||||
8
lib/log.js
Normal file
8
lib/log.js
Normal file
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
const on = process.env.CB_LOG === '1';
|
||||
|
||||
function devLog(...args) { if (on) console.log(...args); }
|
||||
function devWarn(...args) { if (on) console.warn(...args); }
|
||||
function devError(...args) { console.error(...args); } // errors always log
|
||||
|
||||
module.exports = { devLog, devWarn, devError, isDev: on };
|
||||
98
lib/migrate.js
Normal file
98
lib/migrate.js
Normal file
@@ -0,0 +1,98 @@
|
||||
'use strict';
|
||||
|
||||
const { devWarn } = require('./log');
|
||||
|
||||
// clips -> clips_fts 的同步触发器。v2 / v3 共用。
|
||||
const FTS_TRIGGERS = `
|
||||
CREATE TRIGGER IF NOT EXISTS clips_ai AFTER INSERT ON clips BEGIN
|
||||
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clips_ad AFTER DELETE ON clips BEGIN
|
||||
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
|
||||
END;
|
||||
-- 只在 text 真的变了才重建索引。没有这个 WHEN 的话,每次
|
||||
-- "UPDATE clips SET top_at = ?"(也就是每次复制/置顶)都会白白
|
||||
-- 把该行从 FTS 删掉再插回去。
|
||||
CREATE TRIGGER IF NOT EXISTS clips_au AFTER UPDATE ON clips
|
||||
WHEN old.text IS NOT new.text BEGIN
|
||||
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
|
||||
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
|
||||
END;
|
||||
`;
|
||||
|
||||
// STEPS[i] upgrades the schema from version (i + 1) to (i + 2).
|
||||
// STEPS[0] therefore is the v1 -> v2 step.
|
||||
const STEPS = [
|
||||
// v1 -> v2: add FTS5 table + triggers (idempotent).
|
||||
function v1_to_v2(db) {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS clips_fts USING fts5(text, content='clips', content_rowid='id');
|
||||
${FTS_TRIGGERS}
|
||||
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
|
||||
`);
|
||||
},
|
||||
|
||||
// v2 -> v3: 换成 trigram 分词器。
|
||||
//
|
||||
// v2 用的是默认的 unicode61 分词器,它把一整串连续的中文当成**一个** token,
|
||||
// 而 FTS5 的 MATCH 是整 token 匹配、不做子串匹配。结果:搜"公园"在
|
||||
// "今天去公园散步"里一条都搜不到 —— 对一个中文剪贴板工具来说等于搜索全废。
|
||||
// (v2 这一步之前因为索引下标算错从没真正执行过,所以线上一直悄悄退回 LIKE,
|
||||
// 反而"正好"是对的;把 v2 修好之后这个坑才会暴露出来。)
|
||||
//
|
||||
// trigram 分词器按 3 字符滑窗切分,支持子串匹配,中文可用。
|
||||
// 代价:≤2 字符的查询它匹配不到,这部分由调用方退回 LIKE 处理。
|
||||
function v2_to_v3(db) {
|
||||
db.exec(`
|
||||
DROP TRIGGER IF EXISTS clips_ai;
|
||||
DROP TRIGGER IF EXISTS clips_ad;
|
||||
DROP TRIGGER IF EXISTS clips_au;
|
||||
DROP TABLE IF EXISTS clips_fts;
|
||||
CREATE VIRTUAL TABLE clips_fts USING fts5(
|
||||
text, content='clips', content_rowid='id', tokenize='trigram'
|
||||
);
|
||||
${FTS_TRIGGERS}
|
||||
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Applies each pending schema step in order.
|
||||
*
|
||||
* The DB's own `PRAGMA user_version` is the source of truth for what has already
|
||||
* been applied — not the config file, which only records the version the app
|
||||
* *wants*. Passing a config-derived `fromVersion` would re-run every step on
|
||||
* every launch (re-indexing the whole FTS table each time the app starts).
|
||||
*
|
||||
* Returns the version actually reached, so callers can tell whether a step was
|
||||
* skipped because of an error.
|
||||
*/
|
||||
function migrate(db, fromVersion, toVersion) {
|
||||
let current = fromVersion;
|
||||
try {
|
||||
const row = db.pragma('user_version', { simple: true });
|
||||
if (typeof row === 'number' && row > 0) current = row;
|
||||
} catch (e) {
|
||||
// Non-sqlite stub (tests) — fall back to the caller-supplied version.
|
||||
}
|
||||
|
||||
for (let v = current + 1; v <= toVersion; v++) {
|
||||
const step = STEPS[v - 2];
|
||||
if (!step) continue;
|
||||
try {
|
||||
step(db);
|
||||
current = v;
|
||||
try { db.pragma(`user_version = ${v}`); } catch (e) {}
|
||||
} catch (e) {
|
||||
// Most likely FTS5 is not compiled into this SQLite build. Leave the
|
||||
// version marker where it is so a later launch can retry, and let the
|
||||
// caller fall back to LIKE-based search.
|
||||
devWarn('[migrate] step', v, 'failed, leaving schema at v' + current + ':', e.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
module.exports = { migrate, STEPS };
|
||||
21
lib/notify-fanout.js
Normal file
21
lib/notify-fanout.js
Normal file
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
function createSubscribers() {
|
||||
const clip = new Set();
|
||||
const fav = new Set();
|
||||
const theme = new Set();
|
||||
return {
|
||||
addClip: (wc) => clip.add(wc),
|
||||
addFav: (wc) => fav.add(wc),
|
||||
addTheme: (wc) => theme.add(wc),
|
||||
drop: (wc) => { clip.delete(wc); fav.delete(wc); theme.delete(wc); },
|
||||
notifyClip: (ch, payload) => { for (const wc of clip) try { wc.send(ch, payload); } catch {} },
|
||||
notifyFav: (ch, payload) => { for (const wc of fav) try { wc.send(ch, payload); } catch {} },
|
||||
notifyTheme: (ch, payload) => { for (const wc of theme) try { wc.send(ch, payload); } catch {} },
|
||||
get clip() { return clip; },
|
||||
get fav() { return fav; },
|
||||
get theme() { return theme; },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSubscribers };
|
||||
25
lib/poll-guard.js
Normal file
25
lib/poll-guard.js
Normal file
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 轮询守卫:决定本 tick 是否进入图片分支。
|
||||
*
|
||||
* 背景:默认 pollClipboard 每 tick 都跑 `clipboard.readImage().getBitmap()`
|
||||
* + sha1,剪贴板上有 4K 截图时约 33 MB memcpy + ~40ms。先用 availableFormats()
|
||||
* 探一下,文本剪贴板直接跳过图片分支,零开销。
|
||||
*
|
||||
* 已知残留:当剪贴板确实有大图时仍然每 tick 重哈希。彻底解决需要启发式
|
||||
* 风险(尺寸相同可能漏抓变更)或订阅 clipboard-sequence-number(Electron
|
||||
* 不暴露)。本次只做最稳妥的格式守卫。
|
||||
*
|
||||
* @param {unknown} formats 来自 clipboard.availableFormats() 的列表
|
||||
* @returns {boolean} 是否需要读图片
|
||||
*/
|
||||
function shouldReadImage(formats) {
|
||||
if (!Array.isArray(formats)) return false;
|
||||
for (const f of formats) {
|
||||
if (typeof f === 'string' && f.startsWith('image/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { shouldReadImage };
|
||||
44
lib/same-dir.js
Normal file
44
lib/same-dir.js
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
/**
|
||||
* 把路径规整到 OS 真实指向(symlink / 8.3 短名 / 盘符大小写都消除)。
|
||||
* 失败时退回到 path.resolve —— 比如路径根本不存在,realpath 抛 ENOENT,
|
||||
* 用 resolve 至少能让 path.resolve(a) === path.resolve(b) 这条兜底成立。
|
||||
*
|
||||
* @param {string} p
|
||||
* @returns {string}
|
||||
*/
|
||||
function real(p) {
|
||||
try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个路径是否指向同一个目录。
|
||||
*
|
||||
* Windows 文件系统大小写不敏感:`D:\Clipboard Data` 与 `d:\clipboard data`
|
||||
* 是同一个目录,必须按小写比较。原始 `path.resolve(a) === path.resolve(b)`
|
||||
* 在大小写不一致时会误判为不同目录 —— settings:choose-dir 里这会让用户
|
||||
* 重选当前目录时绕过「已经是当前数据位置」提示,弹出「目标已有数据」
|
||||
* 迁移对话框自己迁自己。
|
||||
*
|
||||
* 此外 NTFS 还做 Unicode-normalization 不敏感:`café` 的 NFC / NFD 两种写法
|
||||
* 指向同一文件。光是 toLowerCase 比较会把两种写法判成不同,触发自我迁移。
|
||||
* 走 realpathSync.native 让 OS 自己解析到内部规范形式再比较。
|
||||
*
|
||||
* POSIX 大小写敏感、symlink 敏感:严格比较。
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSameDir(a, b) {
|
||||
const ra = real(a);
|
||||
const rb = real(b);
|
||||
return process.platform === 'win32'
|
||||
? ra.toLowerCase() === rb.toLowerCase()
|
||||
: ra === rb;
|
||||
}
|
||||
|
||||
module.exports = { isSameDir };
|
||||
58
lib/search-plan.js
Normal file
58
lib/search-plan.js
Normal file
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
// trigram 分词器按 3 字符滑窗建索引,因此**任何**短于 3 字符的词都匹配不到。
|
||||
const TRIGRAM_MIN = 3;
|
||||
|
||||
/**
|
||||
* 转义 LIKE 元字符(% _ \),供 `LIKE ? ESCAPE '\'` 使用。
|
||||
* 用户输入里的 % / _ 不转义会被当通配符:搜 "100%" 变成匹配一切以 100 开头的串。
|
||||
*/
|
||||
function escapeLike(s) {
|
||||
return String(s).replace(/[\\%_]/g, (c) => '\\' + c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把查询拆成「每个 token 一个 LIKE 模式」的数组(已转义)。
|
||||
* 多 token 是 AND 关系 —— 与 FTS MATCH 多短语的语义对齐,
|
||||
* 且各 token 独立匹配,不要求原文里连空格一起逐字出现。
|
||||
*
|
||||
* 空查询返回空数组(调用方按"列出全部"处理)。
|
||||
*/
|
||||
function likePatterns(query) {
|
||||
const trimmed = String(query == null ? '' : query).trim();
|
||||
if (!trimmed) return [];
|
||||
return trimmed.split(/\s+/).filter(Boolean).map((tok) => `%${escapeLike(tok)}%`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定一个搜索词该走 FTS 还是退回 LIKE,并给出安全转义后的 FTS 查询串。
|
||||
*
|
||||
* 背景:clips_fts 用的是 trigram 分词器(见 lib/migrate.js 的 v2->v3)。
|
||||
* 选它是因为默认的 unicode61 会把一整串中文当成一个 token,导致
|
||||
* "公园" 搜不到 "今天去公园散步" —— 中文场景下搜索基本全废。
|
||||
* trigram 解决了子串匹配,但代价是 ≤2 字符的词一条也匹配不到,
|
||||
* 所以这类查询必须退回 LIKE("公园""会议""复制" 这种两字词非常常见)。
|
||||
*
|
||||
* 另外,用户输入直接塞进 MATCH 会因 FTS5 保留字符(- " ( ) * ^ :)报语法错误,
|
||||
* 所以每个 token 都要包成 FTS5 字符串字面量。
|
||||
*
|
||||
* @param {string} query 用户原始输入
|
||||
* @returns {{mode:'all'} | {mode:'like', patterns:string[]} | {mode:'fts', match:string}}
|
||||
*/
|
||||
function planSearch(query) {
|
||||
const patterns = likePatterns(query);
|
||||
if (patterns.length === 0) return { mode: 'all' };
|
||||
|
||||
// 任何一个 token 太短 → 整个 MATCH 都会返回空(多 token 是 AND 关系),
|
||||
// 所以只要有一个短 token 就整体退回 LIKE。
|
||||
// 注意用 [...tok] 按码点算长度,别用 .length(代理对会算成 2)。
|
||||
const trimmed = String(query == null ? '' : query).trim();
|
||||
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
||||
const tooShort = tokens.some((tok) => [...tok].length < TRIGRAM_MIN);
|
||||
if (tooShort) return { mode: 'like', patterns };
|
||||
|
||||
const match = tokens.map((tok) => `"${tok.replace(/"/g, '""')}"`).join(' ');
|
||||
return { mode: 'fts', match };
|
||||
}
|
||||
|
||||
module.exports = { planSearch, likePatterns, escapeLike, TRIGRAM_MIN };
|
||||
37
lib/settings-handlers.js
Normal file
37
lib/settings-handlers.js
Normal file
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
const cfg = require('../config.js');
|
||||
|
||||
const SETTINGS_HANDLERS = {
|
||||
autoLaunch: {
|
||||
// 宽松强制转换:布尔、真值字符串('true' / 'yes')、1 都视为开启
|
||||
apply: (cur, v) => cfg.sanitize({ ...cur, autoLaunch: Boolean(v) }),
|
||||
},
|
||||
maxItems: {
|
||||
apply: (cur, v) => cfg.sanitize({ ...cur, maxItems: v }),
|
||||
},
|
||||
displayLimit: {
|
||||
// 界面显示条数 —— 只影响渲染层,不动数据库。运行时改完要通知所有窗口
|
||||
// 重拉,preload 的默认值才会跟着变(见 main.js settings:set 分支)。
|
||||
apply: (cur, v) => cfg.sanitize({ ...cur, displayLimit: v }),
|
||||
},
|
||||
pollMs: {
|
||||
apply: (cur, v) => cfg.sanitize({ ...cur, pollMs: v }),
|
||||
},
|
||||
hotkey: {
|
||||
// 预设白名单校验在 cfg.sanitize 里(非法值回落默认 Ctrl+Alt+V)。
|
||||
// 运行时的注册/解绑在 main.js settings:set 的 hotkey 分支,
|
||||
// 那里才拿得到 globalShortcut。
|
||||
apply: (cur, v) => cfg.sanitize({ ...cur, hotkey: v }),
|
||||
},
|
||||
// dataDir 刻意没有 handler:更换目录必须走 settings:choose-dir
|
||||
// (关库 → 搬文件 → 校验 → 改配置 → 重启)。只在这里改 config.dataDir,
|
||||
// 运行中的数据库连接仍指向旧目录 —— 内存与落盘分叉,重启后数据"消失"。
|
||||
};
|
||||
|
||||
function applyPatch(current, { key, value }) {
|
||||
const h = SETTINGS_HANDLERS[key];
|
||||
if (!h) return { ...current };
|
||||
return h.apply(current, value);
|
||||
}
|
||||
|
||||
module.exports = { SETTINGS_HANDLERS, applyPatch };
|
||||
Reference in New Issue
Block a user