@noteplanco/noteplan-mcp 1.1.21 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ vi.mock('../noteplan/preferences.js', () => ({
5
5
  isAsteriskTodo: true,
6
6
  isDashTodo: false,
7
7
  defaultTodoCharacter: '*',
8
+ todoCharacter: '*',
8
9
  useCheckbox: true,
9
10
  taskPrefix: '* [ ] ',
10
11
  })),
@@ -2218,4 +2219,262 @@ describe('type filtering with code and table', () => {
2218
2219
  expect(result.filteredCount).toBe(5); // 4 code + 1 task
2219
2220
  });
2220
2221
  });
2222
+ // ---------------------------------------------------------------------------
2223
+ // Title resolution and search: frontmatter title vs body title vs filename
2224
+ // Verifies that search/filter pipelines use the frontmatter title when present,
2225
+ // matching the Swift-side title resolution (Globals.swift / TemplateHelper).
2226
+ // ---------------------------------------------------------------------------
2227
+ import { extractTitle } from '../noteplan/markdown-parser.js';
2228
+ describe('title resolution for search – frontmatter title takes priority', () => {
2229
+ // Simulate how unified-store builds Note objects and how listNotes filters them
2230
+ function buildNote(content, filename) {
2231
+ return {
2232
+ id: filename,
2233
+ title: extractTitle(content),
2234
+ filename,
2235
+ content,
2236
+ type: 'note',
2237
+ source: 'local',
2238
+ };
2239
+ }
2240
+ // Replicates the listNotes query filter from notes.ts
2241
+ // Normalizes underscores to spaces; pipe = OR, spaces = AND
2242
+ function matchesListQuery(note, query) {
2243
+ const haystack = `${note.title} ${note.filename} ${note.folder || ''}`
2244
+ .toLowerCase()
2245
+ .replace(/_/g, ' ');
2246
+ const alternatives = query.toLowerCase().split('|').map((alt) => alt.trim().replace(/_/g, ' ')).filter(Boolean);
2247
+ return alternatives.some((alt) => {
2248
+ const words = alt.split(/\s+/).filter(Boolean);
2249
+ return words.every((word) => haystack.includes(word));
2250
+ });
2251
+ }
2252
+ // Replicates metadataScoreTokenAware from unified-store.ts (title_or_filename search)
2253
+ // Normalizes underscores to spaces; multi-word queries use AND matching
2254
+ function normalizeForMatch(value) {
2255
+ return value.replace(/_/g, ' ');
2256
+ }
2257
+ function metadataScore(value, term) {
2258
+ if (!value || !term)
2259
+ return 0;
2260
+ if (value === term)
2261
+ return 120;
2262
+ if (value.startsWith(term))
2263
+ return 100;
2264
+ if (value.includes(term))
2265
+ return 80;
2266
+ return 0;
2267
+ }
2268
+ function metadataScoreTokenAware(value, term) {
2269
+ if (!value || !term)
2270
+ return 0;
2271
+ const normalizedValue = normalizeForMatch(value);
2272
+ const normalizedTerm = normalizeForMatch(term);
2273
+ const words = normalizedTerm.split(/\s+/).filter(Boolean);
2274
+ if (words.length <= 1) {
2275
+ return Math.max(metadataScore(value, term), metadataScore(normalizedValue, normalizedTerm));
2276
+ }
2277
+ const allMatch = words.every((word) => normalizedValue.includes(word));
2278
+ if (!allMatch)
2279
+ return 0;
2280
+ if (normalizedValue === normalizedTerm)
2281
+ return 120;
2282
+ if (normalizedValue.startsWith(normalizedTerm))
2283
+ return 100;
2284
+ return 70 + Math.min(words.length, 10);
2285
+ }
2286
+ // Replicates splitSearchTerms from unified-store.ts (OR on `|`)
2287
+ function splitSearchTerms(query) {
2288
+ const tokens = query.split('|').map((t) => t.trim()).filter(Boolean);
2289
+ return tokens.length > 0 ? tokens : [query.trim()];
2290
+ }
2291
+ // Replicates scoreMetadataMatch: OR across `|`-separated terms,
2292
+ // AND within space-separated words of each term
2293
+ function matchesTitleOrFilename(note, query) {
2294
+ const lowerTitle = note.title.toLowerCase();
2295
+ const lowerFilename = note.filename.toLowerCase();
2296
+ const terms = splitSearchTerms(query);
2297
+ for (const rawTerm of terms) {
2298
+ const term = rawTerm.toLowerCase();
2299
+ if (metadataScoreTokenAware(lowerTitle, term) > 0)
2300
+ return true;
2301
+ if (metadataScoreTokenAware(lowerFilename, term) > 0)
2302
+ return true;
2303
+ }
2304
+ return false;
2305
+ }
2306
+ // --- Local notes ---
2307
+ it('local note: listNotes query matches frontmatter title, not just filename', () => {
2308
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2309
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2310
+ expect(matchesListQuery(note, '0049')).toBe(true);
2311
+ expect(matchesListQuery(note, 'knuth')).toBe(true);
2312
+ });
2313
+ it('local note: listNotes query does NOT match filename-only content when frontmatter title is different', () => {
2314
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2315
+ // The filename contains "0012" but the frontmatter title does not — however
2316
+ // listNotes searches BOTH title and filename, so this should still match via filename
2317
+ expect(matchesListQuery(note, '0012')).toBe(true);
2318
+ });
2319
+ it('local note: title_or_filename search matches frontmatter title', () => {
2320
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2321
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2322
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2323
+ });
2324
+ it('local note: title_or_filename search matches filename too', () => {
2325
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2326
+ expect(matchesTitleOrFilename(note, '0012')).toBe(true);
2327
+ });
2328
+ it('local note: uses body heading as title when frontmatter has no title/name', () => {
2329
+ const note = buildNote('---\ntype: project-note\ntags: #test\n---\n# My Project Title', 'Notes/random-filename.txt');
2330
+ expect(note.title).toBe('My Project Title');
2331
+ expect(matchesTitleOrFilename(note, 'my project')).toBe(true);
2332
+ expect(matchesListQuery(note, 'my project')).toBe(true);
2333
+ });
2334
+ it('local note: uses name property as title fallback', () => {
2335
+ const note = buildNote('---\nname: My Named Note\ntags: #test\n---\n# Different Heading', 'Notes/something.txt');
2336
+ expect(note.title).toBe('My Named Note');
2337
+ expect(matchesTitleOrFilename(note, 'named note')).toBe(true);
2338
+ });
2339
+ // --- Space notes (simulating rowToNote behavior) ---
2340
+ // This simulates the fixed rowToNote: content present → extractTitle(content) is used
2341
+ function buildSpaceNote(content, dbTitle, filename) {
2342
+ return {
2343
+ id: 'space-id-123',
2344
+ title: content ? extractTitle(content) : dbTitle || 'Untitled',
2345
+ filename,
2346
+ content,
2347
+ type: 'note',
2348
+ source: 'space',
2349
+ };
2350
+ }
2351
+ it('space note: uses frontmatter title, not SQLite title column', () => {
2352
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', '🟥_0012_project 2 2 2 2', // SQLite title column (filename-derived)
2353
+ '🟥_0012_project 2 2 2 2.md');
2354
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2355
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2356
+ expect(matchesTitleOrFilename(note, 'knuth')).toBe(true);
2357
+ });
2358
+ it('space note: does NOT use the SQLite title when frontmatter title exists', () => {
2359
+ const note = buildSpaceNote('---\ntitle: Correct Title\n---\n# Body', 'Wrong DB Title', 'wrong-db-title.md');
2360
+ expect(note.title).toBe('Correct Title');
2361
+ expect(note.title).not.toBe('Wrong DB Title');
2362
+ });
2363
+ it('space note: uses body heading when frontmatter has no title', () => {
2364
+ const note = buildSpaceNote('---\ntags: #test\n---\n# Body Heading Title', 'db-filename-title', 'db-filename-title.md');
2365
+ expect(note.title).toBe('Body Heading Title');
2366
+ expect(matchesTitleOrFilename(note, 'body heading')).toBe(true);
2367
+ });
2368
+ it('space note: falls back to dbTitle when content is empty', () => {
2369
+ const note = buildSpaceNote('', 'DB Fallback Title', 'some-file.md');
2370
+ expect(note.title).toBe('DB Fallback Title');
2371
+ });
2372
+ it('space note: returns Untitled when both content and dbTitle are empty', () => {
2373
+ const note = buildSpaceNote('', '', 'empty.md');
2374
+ expect(note.title).toBe('Untitled');
2375
+ });
2376
+ it('space note: listNotes query matches frontmatter title from space note', () => {
2377
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', '🟥_0012_project 2 2 2 2', '🟥_0012_project 2 2 2 2.md');
2378
+ expect(matchesListQuery(note, '0049')).toBe(true);
2379
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2380
+ });
2381
+ // --- Token-aware AND matching with underscore normalization ---
2382
+ it('title_or_filename: "knuth reviewer" (space) matches "knuth_reviewer" (underscore)', () => {
2383
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2384
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2385
+ });
2386
+ it('listNotes: "knuth reviewer" (space) matches "knuth_reviewer" (underscore)', () => {
2387
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2388
+ expect(matchesListQuery(note, 'knuth reviewer')).toBe(true);
2389
+ });
2390
+ it('multi-word query matches tokens in any order', () => {
2391
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2392
+ expect(matchesTitleOrFilename(note, 'reviewer knuth')).toBe(true);
2393
+ expect(matchesListQuery(note, 'reviewer knuth')).toBe(true);
2394
+ });
2395
+ it('multi-word query requires ALL tokens (AND logic)', () => {
2396
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2397
+ // "knuth" matches but "einstein" does not → should fail
2398
+ expect(matchesTitleOrFilename(note, 'knuth einstein')).toBe(false);
2399
+ expect(matchesListQuery(note, 'knuth einstein')).toBe(false);
2400
+ });
2401
+ it('single word with underscore in query matches underscore in title', () => {
2402
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2403
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2404
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2405
+ });
2406
+ it('number substring still matches within underscore-separated title', () => {
2407
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2408
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2409
+ expect(matchesListQuery(note, '0049')).toBe(true);
2410
+ });
2411
+ // Multi-word frontmatter keys (e.g. "start date") must not break title resolution
2412
+ it('local note: extracts frontmatter title when multi-word keys are present', () => {
2413
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\nstart date: 2026-02\ntags: #project/scope\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2414
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2415
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2416
+ expect(matchesListQuery(note, 'knuth')).toBe(true);
2417
+ });
2418
+ it('space note: extracts frontmatter title when multi-word keys are present', () => {
2419
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\nstart date: 2026-02\ntags: #project/scope\ntype: project-note\n---\n# Body Heading', '🟥_0012_project 2 2 2 2', '🟥_0012_project 2 2 2 2.md');
2420
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2421
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2422
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2423
+ });
2424
+ // --- OR separator (pipe |) and AND (spaces) ---
2425
+ it('pipe | gives OR: matches if either term matches', () => {
2426
+ const note = buildNote('---\ntitle: Meeting Notes\n---\n# Body', 'Notes/meeting-notes.txt');
2427
+ // "meeting" matches, "nonexistent" doesn't — OR means it should match
2428
+ expect(matchesTitleOrFilename(note, 'nonexistent | meeting')).toBe(true);
2429
+ });
2430
+ it('pipe | gives OR: fails if neither term matches', () => {
2431
+ const note = buildNote('---\ntitle: Meeting Notes\n---\n# Body', 'Notes/meeting-notes.txt');
2432
+ expect(matchesTitleOrFilename(note, 'nonexistent | alsonot')).toBe(false);
2433
+ });
2434
+ it('pipe | gives OR: matches on second alternative', () => {
2435
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2436
+ // First term doesn't match, second does
2437
+ expect(matchesTitleOrFilename(note, 'einstein | knuth')).toBe(true);
2438
+ });
2439
+ it('spaces give AND: all words must match', () => {
2440
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2441
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2442
+ expect(matchesTitleOrFilename(note, 'knuth 0049')).toBe(true);
2443
+ expect(matchesTitleOrFilename(note, 'knuth einstein')).toBe(false);
2444
+ });
2445
+ it('OR with AND: each pipe alternative is AND-matched independently', () => {
2446
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2447
+ // "knuth einstein" fails AND, but "0049 reviewer" passes AND → OR succeeds
2448
+ expect(matchesTitleOrFilename(note, 'knuth einstein | 0049 reviewer')).toBe(true);
2449
+ });
2450
+ it('OR with AND: fails when no alternative matches all its tokens', () => {
2451
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2452
+ expect(matchesTitleOrFilename(note, 'knuth einstein | foo bar')).toBe(false);
2453
+ });
2454
+ // --- listNotes also supports OR (pipe) ---
2455
+ it('listNotes: pipe | gives OR', () => {
2456
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2457
+ expect(matchesListQuery(note, 'nonexistent | knuth')).toBe(true);
2458
+ expect(matchesListQuery(note, 'nonexistent | alsonot')).toBe(false);
2459
+ });
2460
+ it('listNotes: OR with AND combined', () => {
2461
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2462
+ expect(matchesListQuery(note, 'knuth einstein | 0049 reviewer')).toBe(true);
2463
+ expect(matchesListQuery(note, 'foo bar | baz qux')).toBe(false);
2464
+ });
2465
+ // --- Symmetric underscore normalization ---
2466
+ it('underscore in search term matches space in title', () => {
2467
+ const note = buildNote('---\ntitle: knuth reviewer notes\n---\n# Body', 'Notes/some-file.txt');
2468
+ // Title has spaces, query has underscore — should still match
2469
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2470
+ });
2471
+ it('underscore in search term matches underscore in title', () => {
2472
+ const note = buildNote('---\ntitle: knuth_reviewer_notes\n---\n# Body', 'Notes/some-file.txt');
2473
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2474
+ });
2475
+ it('space in search term matches underscore in title', () => {
2476
+ const note = buildNote('---\ntitle: knuth_reviewer_notes\n---\n# Body', 'Notes/some-file.txt');
2477
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2478
+ });
2479
+ });
2221
2480
  //# sourceMappingURL=notes.test.js.map