import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import time from '@joplin/lib/time'; export interface MentionNoteItem { id: string; title: string; parentTitle?: string; section?: string; } /** * 纯前端 GB2312 汉字首字母转换,支持拼音首字母模糊检索中文笔记(例如 gzzb -> 工作周报) */ export const getChinesePinyinInitials = (str: string): string => { const boundaries = [ { p: 'a', c: '啊' }, { p: 'b', c: '芭' }, { p: 'c', c: '擦' }, { p: 'd', c: '搭' }, { p: 'e', c: '蛾' }, { p: 'f', c: '发' }, { p: 'g', c: '噶' }, { p: 'h', c: '哈' }, { p: 'j', c: '击' }, { p: 'k', c: '喀' }, { p: 'l', c: '垃' }, { p: 'm', c: '妈' }, { p: 'n', c: '拿' }, { p: 'o', c: '哦' }, { p: 'p', c: '啪' }, { p: 'q', c: '期' }, { p: 'r', c: '然' }, { p: 's', c: '撒' }, { p: 't', c: '塌' }, { p: 'w', c: '挖' }, { p: 'x', c: '昔' }, { p: 'y', c: '压' }, { p: 'z', c: '匝' }, ]; return str.split('').map(ch => { if (/[a-zA-Z0-9]/.test(ch)) return ch.toLowerCase(); if (!/[\u4e00-\u9fa5]/.test(ch)) return ''; for (let i = boundaries.length - 1; i >= 0; i--) { if (ch.localeCompare(boundaries[i].c, 'zh-Hans-CN') >= 0) { return boundaries[i].p; } } return ''; }).join(''); }; /** * 中国地区标准日期星期格式化(统一 星期一 ~ 星期日) */ export const formatChineseDateTitle = (ms: number): string => { const weekdaysZh = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const datePrefix = time.formatMsToLocal(ms, 'YYYY-MM-DD'); const d = new Date(ms); return `${datePrefix} ${weekdaysZh[d.getDay()]}`; }; /** * 双链与 @ 快速提及的笔记查询与推荐算法 * 同时服务于 Markdown 编辑器(CodeMirror 6)与富文本编辑器(TinyMCE) */ export const searchNotesForMention = async (query: string): Promise => { const trimmed = query.trim(); const lowerQuery = trimmed.toLowerCase(); const isDateSearch = (keywords: string[]) => { return keywords.some(k => k === lowerQuery || k.includes(lowerQuery) || lowerQuery.includes(k)); }; const matchesToday = !trimmed || isDateSearch(['今天', '今日', 'jt', 'jr', 'today', '日志', '日记', 'rz', 'rj']); const matchesYesterday = !trimmed || isDateSearch(['昨天', '昨日', 'zt', 'zr', 'yesterday']); const matchesTomorrow = !trimmed || isDateSearch(['明天', '明日', 'mt', 'mr', 'tomorrow']); const matchesDayBefore = isDateSearch(['前天', '前日', 'qt']); const matchesDayAfter = isDateSearch(['后天', '后日', 'ht']); const now = Date.now(); const oneDayMs = 24 * 60 * 60 * 1000; const targetDates: Array<{ offsetDays: number; label: string; createLabel: string }> = []; if (matchesToday) targetDates.push({ offsetDays: 0, label: '📅 今日日志', createLabel: '✨ 新建今日日志' }); if (matchesYesterday) targetDates.push({ offsetDays: -1, label: '📅 昨日日志', createLabel: '✨ 新建昨日日志' }); if (matchesTomorrow) targetDates.push({ offsetDays: 1, label: '📅 明日日志', createLabel: '✨ 新建明日日志' }); if (matchesDayBefore) targetDates.push({ offsetDays: -2, label: '📅 前天日志', createLabel: '✨ 新建前天日志' }); if (matchesDayAfter) targetDates.push({ offsetDays: 2, label: '📅 后天日志', createLabel: '✨ 新建后天日志' }); const specialItems: Array = []; for (const item of targetDates) { const targetMs = now + item.offsetDays * oneDayMs; const prefix = time.formatMsToLocal(targetMs, 'YYYY-MM-DD'); const dateTitle = formatChineseDateTitle(targetMs); const row = await Note.db().selectOne( 'SELECT id, title FROM notes WHERE is_conflict = 0 AND (deleted_time = 0 OR deleted_time IS NULL) AND title LIKE ? ORDER BY updated_time DESC LIMIT 1', [`${prefix}%`], ); if (row && typeof row.id === 'string' && typeof row.title === 'string') { specialItems.push({ id: row.id, title: row.title, parentTitle: item.label, section: '📅 日志推荐', }); } else { specialItems.push({ id: `__CREATE_${prefix}__`, title: dateTitle, parentTitle: item.createLabel, section: '📅 日志推荐', }); } } // 识别直接输入的指定日期数字(如 2026-11-05、2026/11/5、20261105) const dateRegex = /^(\d{4})[-/.]?(\d{1,2})[-/.]?(\d{1,2})$/; const directDateMatch = trimmed.match(dateRegex); if (directDateMatch) { const year = parseInt(directDateMatch[1], 10); const month = parseInt(directDateMatch[2], 10); const day = parseInt(directDateMatch[3], 10); if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { const d = new Date(year, month - 1, day); if (!isNaN(d.getTime())) { const prefix = time.formatMsToLocal(d.getTime(), 'YYYY-MM-DD'); const dateTitle = formatChineseDateTitle(d.getTime()); if (!specialItems.some(s => s.id.includes(prefix))) { const row = await Note.db().selectOne( 'SELECT id, title FROM notes WHERE is_conflict = 0 AND (deleted_time = 0 OR deleted_time IS NULL) AND title LIKE ? ORDER BY updated_time DESC LIMIT 1', [`${prefix}%`], ); if (row && typeof row.id === 'string' && typeof row.title === 'string') { specialItems.push({ id: row.id, title: row.title, parentTitle: `📅 ${prefix} 日志`, section: '📅 日志推荐', }); } else { specialItems.push({ id: `__CREATE_${prefix}__`, title: dateTitle, parentTitle: `✨ 新建 ${prefix} 日志`, section: '📅 日志推荐', }); } } } } } // 查询笔记标题匹配 const likeQuery = `%${trimmed}%`; const rows = await Note.db().selectAll<{ id: string; title: string; parent_id: string }>( `SELECT id, title, parent_id FROM notes WHERE is_conflict = 0 AND (deleted_time = 0 OR deleted_time IS NULL) AND title LIKE ? ORDER BY updated_time DESC LIMIT 15`, [likeQuery], ); const folders = await Folder.all(); const folderMap = new Map(folders.map(f => [f.id, f.title])); const existingSpecialIds = new Set(specialItems.map(s => s.id)); const noteResults: Array = []; for (const r of rows) { if (!existingSpecialIds.has(r.id)) { noteResults.push({ id: r.id, title: r.title, parentTitle: folderMap.get(r.parent_id), section: '📝 关联笔记', }); } } // 若输入为英文字母且普通模糊搜索无果或结果较少,尝试拼音首字母反查 if (trimmed && /^[a-zA-Z]+$/.test(trimmed) && noteResults.length < 5) { const recentRows = await Note.db().selectAll<{ id: string; title: string; parent_id: string }>( `SELECT id, title, parent_id FROM notes WHERE is_conflict = 0 AND (deleted_time = 0 OR deleted_time IS NULL) ORDER BY updated_time DESC LIMIT 60`, ); for (const r of recentRows) { if (!existingSpecialIds.has(r.id) && !noteResults.some(n => n.id === r.id)) { const initials = getChinesePinyinInitials(r.title); if (initials.includes(lowerQuery)) { noteResults.push({ id: r.id, title: r.title, parentTitle: folderMap.get(r.parent_id), section: '📝 关联笔记', }); } } } } return [...specialItems, ...noteResults]; }; export default searchNotesForMention;