mirror of
https://github.com/longwind1984/galaxy-view.git
synced 2026-07-22 07:47:52 +00:00
推翻上一版的产品判断。上一版把 #11 提出者的**机制**(`-file:"Index"` 语法)当成了 **目的**,做了个文本框——在一个主张「看见你的库」的 3D 星图里,让用户背 `-file:` 语法是最反视觉的答案。而 PR #8 的 Tag Lens 早就把对的范式递过来了:点 chip、不打字。 改为: - **主:可点的文件夹图例**。列出顶层文件夹(真实节点色+笔记数),点击切显隐、 hover 出「只看」、标题右侧「全部显示」还原。节点一直按顶层文件夹上色,但面板 从来没暴露过图例——用户看见一团团彩色,既不知道什么意思也没法对它做任何事。 图例做成可点的,一个东西同时回答「这个颜色什么意思」和「只给我看这块」,零语法。 - **次:文本查询降级为折叠的逃生口**(+按名字过滤)。只留给图例表达不了的横切 模式——散落在所有文件夹里的 Index,正是提出者的原始场景。解析器与测试原样保留。 - 三个开关(未解析/孤儿/标签)仍与图例同住:它们回答的是同一个问题。 顺带修掉一个既有缺陷(Rick 拍板随图例一起修): 回退色相原本是 `HUES[hash32(folder) % 9]`——与文件夹大小无关且乱序。实测 Rick 的库 (14 个顶层文件夹 / 9 个导入配色组):99Archive(545) / 90故纸堆(86) / Readwise(68) 撞成同一个蓝,合计 1184 篇=全库 37% 落在读不出区别的颜色上。图例会把这个缺陷直接 摆到用户眼前,故一起修:① 色相按笔记数排名发;② 只发给没被 colorGroups 吃掉的文件夹 (Rick 的 9 个导入组不占槽位 → 剩 5 个正好各拿一个独立色相)。>9 个待发时仍回收色轮, 但撞的是最小的几个而非最大的。 实现要点: - `noteFilter` 扩成 NoteFilter{hiddenFolders, query},图例与文本框是 AND - `folderStats` 由**未过滤**的全量文件算——否则点灭一个文件夹会让其他 chip 数字跳 - 图例顺序=笔记数降序,同数按名字定序(不稳定的话每次重建 chip 会跳) - `assignFolderHues` 必须在 colorFn 生效前跑完;6 处 setColorFn 收口成 applyColorFn - 图例取色走 colorFn 探针而非自己算——图例跟图对不上就不是图例是装饰 验证:77 测试通过(新增 palette 9 个含真实库回归、noteFilter 图例 6 个);tsc 通过; lint 0 error;已部署 dev-vault。 ⚠️ 面板渲染仍未眼验(computer-use 授权被拒),需 Rick 手动确认。 ⚠️ 修正:demo 里先前标的 hash 回退色值是我用 python 朴素 HSL→RGB 算的,与 three 实际 不符(setHSL 在线性空间算再转 sRGB)。撞色结论不变,色值已按 three 实测更正。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { applyFilter, folderStats, matchesFilter, parseFilterQuery } from '../src/data/noteFilter';
|
||
|
||
const rec = (path: string) => ({ path, basename: path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '') });
|
||
|
||
const VAULT = [
|
||
rec('Daily/2026-07-15.md'),
|
||
rec('Daily/Index.md'),
|
||
rec('Projects/Galaxy View.md'),
|
||
rec('Projects/Index.md'),
|
||
rec('Archive/Old Index Notes.md'),
|
||
rec('star wars notes.md'),
|
||
];
|
||
|
||
const run = (q: string) => applyFilter(VAULT, { hiddenFolders: new Set<string>(), query: parseFilterQuery(q) }).map((f) => f.path);
|
||
const runHidden = (folders: string[]) => applyFilter(VAULT, { hiddenFolders: new Set(folders), query: [] }).map((f) => f.path);
|
||
|
||
describe('parseFilterQuery', () => {
|
||
it('treats a bare word as a path match', () => {
|
||
expect(parseFilterQuery('Index')).toEqual([{ field: 'path', value: 'index', negate: false }]);
|
||
});
|
||
|
||
it('parses field prefixes and negation', () => {
|
||
expect(parseFilterQuery('-file:Index')).toEqual([{ field: 'file', value: 'index', negate: true }]);
|
||
expect(parseFilterQuery('path:Daily')).toEqual([{ field: 'path', value: 'daily', negate: false }]);
|
||
});
|
||
|
||
it('keeps spaces inside quotes as one term', () => {
|
||
expect(parseFilterQuery('"star wars"')).toEqual([{ field: 'path', value: 'star wars', negate: false }]);
|
||
expect(parseFilterQuery('-file:"Old Index"')).toEqual([{ field: 'file', value: 'old index', negate: true }]);
|
||
});
|
||
|
||
it('splits multiple terms on whitespace', () => {
|
||
expect(parseFilterQuery(' file:a -path:b ')).toEqual([
|
||
{ field: 'file', value: 'a', negate: false },
|
||
{ field: 'path', value: 'b', negate: true },
|
||
]);
|
||
});
|
||
|
||
it('degrades unknown prefixes to a literal bare term rather than erroring', () => {
|
||
// 过滤框是边打边用的,中间态必然不合法——不能抛错
|
||
expect(parseFilterQuery('content:foo')).toEqual([{ field: 'path', value: 'content:foo', negate: false }]);
|
||
});
|
||
|
||
it('drops terms that carry no value', () => {
|
||
expect(parseFilterQuery('-')).toEqual([]);
|
||
expect(parseFilterQuery('file:')).toEqual([]);
|
||
expect(parseFilterQuery(' ')).toEqual([]);
|
||
});
|
||
|
||
it('reads an unclosed quote to the end instead of dropping the term', () => {
|
||
expect(parseFilterQuery('"star wars')).toEqual([{ field: 'path', value: 'star wars', negate: false }]);
|
||
});
|
||
});
|
||
|
||
describe('applyFilter', () => {
|
||
it('returns the array untouched when nothing is filtered', () => {
|
||
expect(applyFilter(VAULT, { hiddenFolders: new Set<string>(), query: [] })).toBe(VAULT); // 同一引用 = 没有无谓复制
|
||
});
|
||
|
||
it('includes only matches for a positive filename term (issue #11 example)', () => {
|
||
expect(run('file:"Index"')).toEqual(['Daily/Index.md', 'Projects/Index.md', 'Archive/Old Index Notes.md']);
|
||
});
|
||
|
||
it('excludes matches for a negative filename term (issue #11 example)', () => {
|
||
expect(run('-file:"Index"')).toEqual(['Daily/2026-07-15.md', 'Projects/Galaxy View.md', 'star wars notes.md']);
|
||
});
|
||
|
||
it('matches folder names via a bare term, since path includes them', () => {
|
||
expect(run('Daily')).toEqual(['Daily/2026-07-15.md', 'Daily/Index.md']);
|
||
});
|
||
|
||
it('is case-insensitive', () => {
|
||
expect(run('file:index')).toEqual(run('file:INDEX'));
|
||
});
|
||
|
||
it('ANDs multiple terms', () => {
|
||
expect(run('Index -path:Daily')).toEqual(['Projects/Index.md', 'Archive/Old Index Notes.md']);
|
||
});
|
||
|
||
it('distinguishes file: from path: (folder name must not satisfy file:)', () => {
|
||
expect(run('path:Projects')).toEqual(['Projects/Galaxy View.md', 'Projects/Index.md']);
|
||
expect(run('file:Projects')).toEqual([]);
|
||
});
|
||
|
||
it('matches a quoted phrase containing spaces', () => {
|
||
expect(run('"star wars"')).toEqual(['star wars notes.md']);
|
||
});
|
||
|
||
it('can exclude everything without throwing', () => {
|
||
expect(run('file:Index -file:Index')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('matchesFilter', () => {
|
||
it('passes any record when the query is empty', () => {
|
||
expect(matchesFilter(rec('anything.md'), [])).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('folderStats(图例数据)', () => {
|
||
it('按笔记数降序,根目录笔记归入 ""(同数时按名字,空串在前)', () => {
|
||
expect(folderStats(VAULT)).toEqual([
|
||
{ folder: 'Daily', count: 2 },
|
||
{ folder: 'Projects', count: 2 },
|
||
{ folder: '', count: 1 },
|
||
{ folder: 'Archive', count: 1 },
|
||
]);
|
||
});
|
||
|
||
it('数量相同时按名字定序——图例顺序必须稳定,否则每次重建 chip 会跳', () => {
|
||
const a = folderStats(VAULT).map((f) => f.folder);
|
||
const b = folderStats([...VAULT].reverse()).map((f) => f.folder);
|
||
expect(a).toEqual(b);
|
||
});
|
||
});
|
||
|
||
describe('文件夹显隐(图例点击)', () => {
|
||
it('点灭一个文件夹 → 它的笔记全部消失', () => {
|
||
expect(runHidden(['Daily'])).toEqual([
|
||
'Projects/Galaxy View.md',
|
||
'Projects/Index.md',
|
||
'Archive/Old Index Notes.md',
|
||
'star wars notes.md',
|
||
]);
|
||
});
|
||
|
||
it('「只看 Projects」= 点灭其余全部', () => {
|
||
expect(runHidden(['Daily', 'Archive', ''])).toEqual(['Projects/Galaxy View.md', 'Projects/Index.md']);
|
||
});
|
||
|
||
it('根目录笔记可单独点灭(键是空串,不能被当成 falsy 忽略)', () => {
|
||
expect(runHidden([''])).not.toContain('star wars notes.md');
|
||
expect(runHidden([''])).toHaveLength(5);
|
||
});
|
||
|
||
it('图例与文本框是 AND:Projects 里排除 Index', () => {
|
||
const out = applyFilter(VAULT, { hiddenFolders: new Set(['Daily', 'Archive', '']), query: parseFilterQuery('-file:Index') });
|
||
expect(out.map((f) => f.path)).toEqual(['Projects/Galaxy View.md']);
|
||
});
|
||
});
|