@noteplanco/noteplan-mcp 1.1.7 → 1.1.9
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.
- package/dist/noteplan/embeddings.d.ts +8 -0
- package/dist/noteplan/embeddings.d.ts.map +1 -1
- package/dist/noteplan/embeddings.js +3 -3
- package/dist/noteplan/embeddings.js.map +1 -1
- package/dist/noteplan/file-reader.d.ts +6 -0
- package/dist/noteplan/file-reader.d.ts.map +1 -1
- package/dist/noteplan/file-reader.js +15 -0
- package/dist/noteplan/file-reader.js.map +1 -1
- package/dist/noteplan/file-writer.d.ts.map +1 -1
- package/dist/noteplan/file-writer.js +13 -1
- package/dist/noteplan/file-writer.js.map +1 -1
- package/dist/noteplan/file-writer.test.js +4 -0
- package/dist/noteplan/file-writer.test.js.map +1 -1
- package/dist/noteplan/frontmatter-parser.d.ts +4 -4
- package/dist/noteplan/frontmatter-parser.d.ts.map +1 -1
- package/dist/noteplan/frontmatter-parser.js +11 -14
- package/dist/noteplan/frontmatter-parser.js.map +1 -1
- package/dist/noteplan/frontmatter-parser.test.js +92 -0
- package/dist/noteplan/frontmatter-parser.test.js.map +1 -1
- package/dist/noteplan/markdown-parser.d.ts.map +1 -1
- package/dist/noteplan/markdown-parser.js +4 -2
- package/dist/noteplan/markdown-parser.js.map +1 -1
- package/dist/noteplan/markdown-parser.test.js +129 -0
- package/dist/noteplan/markdown-parser.test.js.map +1 -1
- package/dist/noteplan/sqlite-loader.d.ts +11 -2
- package/dist/noteplan/sqlite-loader.d.ts.map +1 -1
- package/dist/noteplan/sqlite-loader.js +143 -15
- package/dist/noteplan/sqlite-loader.js.map +1 -1
- package/dist/noteplan/sqlite-writer.d.ts.map +1 -1
- package/dist/noteplan/sqlite-writer.js +3 -0
- package/dist/noteplan/sqlite-writer.js.map +1 -1
- package/dist/noteplan/template-docs.d.ts +35 -0
- package/dist/noteplan/template-docs.d.ts.map +1 -0
- package/dist/noteplan/template-docs.js +184 -0
- package/dist/noteplan/template-docs.js.map +1 -0
- package/dist/noteplan/unified-store.d.ts +2 -0
- package/dist/noteplan/unified-store.d.ts.map +1 -1
- package/dist/noteplan/unified-store.js +30 -7
- package/dist/noteplan/unified-store.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +307 -65
- package/dist/server.js.map +1 -1
- package/dist/tools/calendar.d.ts +2 -2
- package/dist/tools/events.d.ts +16 -16
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +17 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/notes.d.ts +327 -45
- package/dist/tools/notes.d.ts.map +1 -1
- package/dist/tools/notes.js +391 -51
- package/dist/tools/notes.js.map +1 -1
- package/dist/tools/notes.test.js +959 -1
- package/dist/tools/notes.test.js.map +1 -1
- package/dist/tools/plugins.d.ts.map +1 -1
- package/dist/tools/plugins.js +1 -0
- package/dist/tools/plugins.js.map +1 -1
- package/dist/tools/search.d.ts +2 -2
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +32 -4
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/tasks.d.ts +86 -10
- package/dist/tools/tasks.d.ts.map +1 -1
- package/dist/tools/tasks.js +84 -25
- package/dist/tools/tasks.js.map +1 -1
- package/dist/tools/templates.d.ts +64 -3
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +73 -1
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/ui-automation.d.ts +74 -0
- package/dist/tools/ui-automation.d.ts.map +1 -0
- package/dist/tools/ui-automation.js +209 -0
- package/dist/tools/ui-automation.js.map +1 -0
- package/dist/utils/version.d.ts +1 -0
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js +89 -27
- package/dist/utils/version.js.map +1 -1
- package/docs/templates.db.gz +0 -0
- package/docs/x-callback-url.md +318 -0
- package/package.json +1 -1
package/dist/tools/notes.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Copies of private helper functions from notes.ts
|
|
4
4
|
// These are verbatim copies so we can unit-test them without exporting.
|
|
@@ -595,4 +595,962 @@ describe('findParagraphBounds', () => {
|
|
|
595
595
|
expect(findParagraphBounds(lines, 3)).toEqual({ startIndex: 2, endIndex: 3 });
|
|
596
596
|
});
|
|
597
597
|
});
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// matchesFrontmatterProperties — exported from unified-store
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
import { matchesFrontmatterProperties, normalizeFrontmatterScalar } from '../noteplan/unified-store.js';
|
|
602
|
+
describe('normalizeFrontmatterScalar', () => {
|
|
603
|
+
it('strips surrounding double quotes', () => {
|
|
604
|
+
expect(normalizeFrontmatterScalar('"book"', false)).toBe('book');
|
|
605
|
+
});
|
|
606
|
+
it('strips surrounding single quotes', () => {
|
|
607
|
+
expect(normalizeFrontmatterScalar("'book'", false)).toBe('book');
|
|
608
|
+
});
|
|
609
|
+
it('lowercases when caseSensitive is false', () => {
|
|
610
|
+
expect(normalizeFrontmatterScalar('Book', false)).toBe('book');
|
|
611
|
+
});
|
|
612
|
+
it('preserves case when caseSensitive is true', () => {
|
|
613
|
+
expect(normalizeFrontmatterScalar('Book', true)).toBe('Book');
|
|
614
|
+
});
|
|
615
|
+
it('trims whitespace', () => {
|
|
616
|
+
expect(normalizeFrontmatterScalar(' hello ', false)).toBe('hello');
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
describe('matchesFrontmatterProperties', () => {
|
|
620
|
+
const makeNote = (content) => ({
|
|
621
|
+
id: 'test-id',
|
|
622
|
+
title: 'Test Note',
|
|
623
|
+
filename: 'test.md',
|
|
624
|
+
type: 'note',
|
|
625
|
+
source: 'local',
|
|
626
|
+
folder: '',
|
|
627
|
+
content,
|
|
628
|
+
modifiedAt: new Date(),
|
|
629
|
+
createdAt: new Date(),
|
|
630
|
+
spaceId: undefined,
|
|
631
|
+
date: undefined,
|
|
632
|
+
});
|
|
633
|
+
it('matches a single property filter', () => {
|
|
634
|
+
const note = makeNote('---\ntype: book\n---\nSome content');
|
|
635
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(true);
|
|
636
|
+
});
|
|
637
|
+
it('rejects when property value does not match', () => {
|
|
638
|
+
const note = makeNote('---\ntype: article\n---\nSome content');
|
|
639
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
it('rejects when property key is missing', () => {
|
|
642
|
+
const note = makeNote('---\ntitle: My Note\n---\nSome content');
|
|
643
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(false);
|
|
644
|
+
});
|
|
645
|
+
it('returns false when there is no frontmatter', () => {
|
|
646
|
+
const note = makeNote('Just some text without frontmatter');
|
|
647
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(false);
|
|
648
|
+
});
|
|
649
|
+
it('matches case-insensitively by default', () => {
|
|
650
|
+
const note = makeNote('---\nType: Book\n---\nContent');
|
|
651
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(true);
|
|
652
|
+
});
|
|
653
|
+
it('matches comma-separated list values', () => {
|
|
654
|
+
const note = makeNote('---\ntags: fiction, book, novel\n---\nContent');
|
|
655
|
+
expect(matchesFrontmatterProperties(note, [['tags', 'book']], false)).toBe(true);
|
|
656
|
+
});
|
|
657
|
+
it('requires all filters to match', () => {
|
|
658
|
+
const note = makeNote('---\ntype: book\nstatus: reading\n---\nContent');
|
|
659
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book'], ['status', 'reading']], false)).toBe(true);
|
|
660
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book'], ['status', 'done']], false)).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
// ── Regression: read-write line number consistency with frontmatter ──
|
|
664
|
+
// These tests verify that line numbers from buildLineWindow (used by get_notes
|
|
665
|
+
// and getParagraphs) can be directly used in delete/edit/replace operations
|
|
666
|
+
// without any frontmatter offset mismatch. This was a real user-reported bug
|
|
667
|
+
// where delete_lines previewed wrong content because it applied a frontmatter
|
|
668
|
+
// offset while get_notes used absolute line numbers.
|
|
669
|
+
describe('frontmatter line number consistency (regression)', () => {
|
|
670
|
+
// A note with 3 lines of frontmatter (lines 1-3) and 5 content lines (4-8)
|
|
671
|
+
const noteWithFM = [
|
|
672
|
+
'---', // line 1
|
|
673
|
+
'title: Wishlist', // line 2
|
|
674
|
+
'---', // line 3
|
|
675
|
+
'# Wishlist', // line 4
|
|
676
|
+
'', // line 5
|
|
677
|
+
'## AI', // line 6
|
|
678
|
+
'- Feature A', // line 7
|
|
679
|
+
'- Choose AI provider', // line 8 ← user wants to delete this
|
|
680
|
+
].join('\n');
|
|
681
|
+
const allLines = noteWithFM.split('\n');
|
|
682
|
+
it('buildLineWindow reports absolute line numbers including frontmatter', () => {
|
|
683
|
+
const window = buildLineWindow(allLines, {
|
|
684
|
+
startLine: 1,
|
|
685
|
+
endLine: 8,
|
|
686
|
+
defaultLimit: 100,
|
|
687
|
+
maxLimit: 100,
|
|
688
|
+
});
|
|
689
|
+
expect(window.lines[0]).toEqual({ line: 1, lineIndex: 0, content: '---' });
|
|
690
|
+
expect(window.lines[3]).toEqual({ line: 4, lineIndex: 3, content: '# Wishlist' });
|
|
691
|
+
expect(window.lines[7]).toEqual({ line: 8, lineIndex: 7, content: '- Choose AI provider' });
|
|
692
|
+
});
|
|
693
|
+
it('line number from buildLineWindow can be used directly for array splice', () => {
|
|
694
|
+
// Simulate: user reads note, gets line 8 = "- Choose AI provider"
|
|
695
|
+
const window = buildLineWindow(allLines, {
|
|
696
|
+
startLine: 1,
|
|
697
|
+
endLine: 8,
|
|
698
|
+
defaultLimit: 100,
|
|
699
|
+
maxLimit: 100,
|
|
700
|
+
});
|
|
701
|
+
const targetLine = window.lines.find(l => l.content === '- Choose AI provider');
|
|
702
|
+
expect(targetLine).toBeDefined();
|
|
703
|
+
expect(targetLine.line).toBe(8);
|
|
704
|
+
// Now simulate delete: splice at (line - 1) with no frontmatter offset
|
|
705
|
+
const splicedLines = [...allLines];
|
|
706
|
+
splicedLines.splice(targetLine.line - 1, 1);
|
|
707
|
+
expect(splicedLines).toEqual([
|
|
708
|
+
'---', 'title: Wishlist', '---', '# Wishlist', '', '## AI', '- Feature A',
|
|
709
|
+
]);
|
|
710
|
+
// The deleted line should be "- Choose AI provider", not something else
|
|
711
|
+
expect(splicedLines).not.toContain('- Choose AI provider');
|
|
712
|
+
});
|
|
713
|
+
it('searching for content returns line numbers usable for deletion', () => {
|
|
714
|
+
// Simulate searchParagraphs: find "Choose AI provider", get line number
|
|
715
|
+
const window = buildLineWindow(allLines, {
|
|
716
|
+
defaultLimit: allLines.length,
|
|
717
|
+
maxLimit: allLines.length,
|
|
718
|
+
});
|
|
719
|
+
const match = window.lines.find(l => l.content.includes('Choose AI provider'));
|
|
720
|
+
expect(match).toBeDefined();
|
|
721
|
+
expect(match.line).toBe(8);
|
|
722
|
+
// Use that line number for deletion (absolute, no offset)
|
|
723
|
+
const startIndex = match.line - 1; // 0-indexed
|
|
724
|
+
expect(allLines[startIndex]).toBe('- Choose AI provider');
|
|
725
|
+
});
|
|
726
|
+
it('startLine/endLine window with frontmatter uses absolute numbering', () => {
|
|
727
|
+
// Read lines 6-8 (the AI section)
|
|
728
|
+
const window = buildLineWindow(allLines, {
|
|
729
|
+
startLine: 6,
|
|
730
|
+
endLine: 8,
|
|
731
|
+
defaultLimit: 100,
|
|
732
|
+
maxLimit: 100,
|
|
733
|
+
});
|
|
734
|
+
expect(window.lines).toEqual([
|
|
735
|
+
{ line: 6, lineIndex: 5, content: '## AI' },
|
|
736
|
+
{ line: 7, lineIndex: 6, content: '- Feature A' },
|
|
737
|
+
{ line: 8, lineIndex: 7, content: '- Choose AI provider' },
|
|
738
|
+
]);
|
|
739
|
+
});
|
|
740
|
+
it('note without frontmatter: line 1 is first line of content', () => {
|
|
741
|
+
const noFM = ['# Title', 'Body 1', 'Body 2'].join('\n');
|
|
742
|
+
const noFMLines = noFM.split('\n');
|
|
743
|
+
const window = buildLineWindow(noFMLines, {
|
|
744
|
+
defaultLimit: 100,
|
|
745
|
+
maxLimit: 100,
|
|
746
|
+
});
|
|
747
|
+
expect(window.lines[0]).toEqual({ line: 1, lineIndex: 0, content: '# Title' });
|
|
748
|
+
expect(window.lines[1]).toEqual({ line: 2, lineIndex: 1, content: 'Body 1' });
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
// ── Regression: editLine / replaceLines use absolute line numbers with frontmatter ──
|
|
752
|
+
// editLine and replaceLines (in notes.ts) depend on the note store, so we can't
|
|
753
|
+
// call them as pure functions here. Instead we replicate their core splice logic
|
|
754
|
+
// to prove the line-index arithmetic is consistent with buildLineWindow.
|
|
755
|
+
describe('editLine – frontmatter line number regression', () => {
|
|
756
|
+
const noteWithFM = [
|
|
757
|
+
'---', // line 1
|
|
758
|
+
'title: Wishlist', // line 2
|
|
759
|
+
'---', // line 3
|
|
760
|
+
'# Wishlist', // line 4
|
|
761
|
+
'', // line 5
|
|
762
|
+
'## AI', // line 6
|
|
763
|
+
'- Feature A', // line 7
|
|
764
|
+
'- Choose AI provider', // line 8
|
|
765
|
+
].join('\n');
|
|
766
|
+
const allLines = noteWithFM.split('\n');
|
|
767
|
+
it('editing line 8 replaces "- Choose AI provider", not an offset line', () => {
|
|
768
|
+
// Mirrors editLine logic: lineIndex = params.line - 1, then splice
|
|
769
|
+
const paramLine = 8;
|
|
770
|
+
const lineIndex = paramLine - 1; // 0-indexed, no frontmatter offset
|
|
771
|
+
expect(allLines[lineIndex]).toBe('- Choose AI provider');
|
|
772
|
+
const edited = [...allLines];
|
|
773
|
+
edited.splice(lineIndex, 1, '- Choose AI provider (edited)');
|
|
774
|
+
expect(edited[lineIndex]).toBe('- Choose AI provider (edited)');
|
|
775
|
+
expect(edited).toHaveLength(allLines.length);
|
|
776
|
+
});
|
|
777
|
+
it('editing line 4 replaces "# Wishlist" (first content line after frontmatter)', () => {
|
|
778
|
+
const paramLine = 4;
|
|
779
|
+
const lineIndex = paramLine - 1;
|
|
780
|
+
expect(allLines[lineIndex]).toBe('# Wishlist');
|
|
781
|
+
const edited = [...allLines];
|
|
782
|
+
edited.splice(lineIndex, 1, '# Wishlist (renamed)');
|
|
783
|
+
expect(edited[lineIndex]).toBe('# Wishlist (renamed)');
|
|
784
|
+
});
|
|
785
|
+
it('line number from buildLineWindow can be used directly in editLine splice', () => {
|
|
786
|
+
const window = buildLineWindow(allLines, {
|
|
787
|
+
startLine: 1,
|
|
788
|
+
endLine: allLines.length,
|
|
789
|
+
defaultLimit: 100,
|
|
790
|
+
maxLimit: 100,
|
|
791
|
+
});
|
|
792
|
+
const target = window.lines.find(l => l.content === '- Feature A');
|
|
793
|
+
expect(target).toBeDefined();
|
|
794
|
+
expect(target.line).toBe(7);
|
|
795
|
+
// Use that line number exactly as editLine would
|
|
796
|
+
const lineIndex = target.line - 1;
|
|
797
|
+
expect(allLines[lineIndex]).toBe('- Feature A');
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
describe('replaceLines – frontmatter line number regression', () => {
|
|
801
|
+
const noteWithFM = [
|
|
802
|
+
'---', // line 1
|
|
803
|
+
'title: Wishlist', // line 2
|
|
804
|
+
'---', // line 3
|
|
805
|
+
'# Wishlist', // line 4
|
|
806
|
+
'', // line 5
|
|
807
|
+
'## AI', // line 6
|
|
808
|
+
'- Feature A', // line 7
|
|
809
|
+
'- Feature B', // line 8
|
|
810
|
+
'- Feature C', // line 9
|
|
811
|
+
].join('\n');
|
|
812
|
+
const allLines = noteWithFM.split('\n');
|
|
813
|
+
it('replacing lines 7-9 targets the three feature lines, not offset lines', () => {
|
|
814
|
+
// Mirrors replaceLines logic: startIndex = boundedStartLine - 1
|
|
815
|
+
const startLine = 7;
|
|
816
|
+
const endLine = 9;
|
|
817
|
+
const startIndex = startLine - 1;
|
|
818
|
+
const count = endLine - startLine + 1;
|
|
819
|
+
expect(allLines.slice(startIndex, startIndex + count)).toEqual([
|
|
820
|
+
'- Feature A', '- Feature B', '- Feature C',
|
|
821
|
+
]);
|
|
822
|
+
const replaced = [...allLines];
|
|
823
|
+
replaced.splice(startIndex, count, '- Replaced A', '- Replaced B');
|
|
824
|
+
expect(replaced[startIndex]).toBe('- Replaced A');
|
|
825
|
+
expect(replaced[startIndex + 1]).toBe('- Replaced B');
|
|
826
|
+
expect(replaced).toHaveLength(allLines.length - 1); // 3 removed, 2 added
|
|
827
|
+
});
|
|
828
|
+
it('replacing line 4 targets "# Wishlist" (first content line)', () => {
|
|
829
|
+
const startLine = 4;
|
|
830
|
+
const endLine = 4;
|
|
831
|
+
const startIndex = startLine - 1;
|
|
832
|
+
expect(allLines[startIndex]).toBe('# Wishlist');
|
|
833
|
+
const replaced = [...allLines];
|
|
834
|
+
replaced.splice(startIndex, 1, '# New Title');
|
|
835
|
+
expect(replaced[startIndex]).toBe('# New Title');
|
|
836
|
+
expect(replaced).toHaveLength(allLines.length);
|
|
837
|
+
});
|
|
838
|
+
it('line numbers from buildLineWindow can be used directly in replaceLines splice', () => {
|
|
839
|
+
const window = buildLineWindow(allLines, {
|
|
840
|
+
startLine: 1,
|
|
841
|
+
endLine: allLines.length,
|
|
842
|
+
defaultLimit: 100,
|
|
843
|
+
maxLimit: 100,
|
|
844
|
+
});
|
|
845
|
+
const first = window.lines.find(l => l.content === '- Feature A');
|
|
846
|
+
const last = window.lines.find(l => l.content === '- Feature C');
|
|
847
|
+
expect(first).toBeDefined();
|
|
848
|
+
expect(last).toBeDefined();
|
|
849
|
+
const startIndex = first.line - 1;
|
|
850
|
+
const count = last.line - first.line + 1;
|
|
851
|
+
expect(allLines.slice(startIndex, startIndex + count)).toEqual([
|
|
852
|
+
'- Feature A', '- Feature B', '- Feature C',
|
|
853
|
+
]);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
// ── Regression: getTasks → completeTask/updateTask line number round-trip ──
|
|
857
|
+
// getTasks returns { lineIndex (0-based), line (1-based) } from parseTasks.
|
|
858
|
+
// completeTask/updateTask accept either field and resolve via resolveTaskLineIndex.
|
|
859
|
+
// These tests verify the full chain: parseTasks lineIndex includes frontmatter
|
|
860
|
+
// lines, and the resolved index targets the correct line in a splice.
|
|
861
|
+
// Inline copy of resolveTaskLineIndex from tasks.ts (not exported)
|
|
862
|
+
function resolveTaskLineIndex(input) {
|
|
863
|
+
const numLineIndex = input.lineIndex !== undefined && input.lineIndex !== null ? Number(input.lineIndex) : NaN;
|
|
864
|
+
const numLine = input.line !== undefined && input.line !== null ? Number(input.line) : NaN;
|
|
865
|
+
const hasLineIndex = Number.isFinite(numLineIndex);
|
|
866
|
+
const hasLine = Number.isFinite(numLine);
|
|
867
|
+
if (!hasLineIndex && !hasLine) {
|
|
868
|
+
return { ok: false, error: 'Provide lineIndex (0-based) or line (1-based)' };
|
|
869
|
+
}
|
|
870
|
+
const resolvedFromLine = hasLine ? Math.floor(numLine) - 1 : undefined;
|
|
871
|
+
const resolvedFromIndex = hasLineIndex ? Math.floor(numLineIndex) : undefined;
|
|
872
|
+
if (resolvedFromLine !== undefined && resolvedFromLine < 0) {
|
|
873
|
+
return { ok: false, error: 'line must be >= 1' };
|
|
874
|
+
}
|
|
875
|
+
if (resolvedFromIndex !== undefined && resolvedFromIndex < 0) {
|
|
876
|
+
return { ok: false, error: 'lineIndex must be >= 0' };
|
|
877
|
+
}
|
|
878
|
+
return { ok: true, lineIndex: resolvedFromIndex ?? resolvedFromLine };
|
|
879
|
+
}
|
|
880
|
+
// Minimal parseTasks that returns lineIndex (matching markdown-parser.ts logic)
|
|
881
|
+
function parseTasksMinimal(content) {
|
|
882
|
+
const lines = content.split('\n');
|
|
883
|
+
const tasks = [];
|
|
884
|
+
for (let i = 0; i < lines.length; i++) {
|
|
885
|
+
const match = lines[i].match(/^(\s*)[*+\-]\s*\[.\]\s*(.*)$/);
|
|
886
|
+
if (match) {
|
|
887
|
+
tasks.push({ lineIndex: i, content: match[2].trim() });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return tasks;
|
|
891
|
+
}
|
|
892
|
+
describe('getTasks → completeTask/updateTask – frontmatter line number regression', () => {
|
|
893
|
+
const noteWithFM = [
|
|
894
|
+
'---', // line 1 (index 0)
|
|
895
|
+
'title: Test', // line 2 (index 1)
|
|
896
|
+
'---', // line 3 (index 2)
|
|
897
|
+
'# Tasks', // line 4 (index 3)
|
|
898
|
+
'', // line 5 (index 4)
|
|
899
|
+
'* [ ] Task A', // line 6 (index 5)
|
|
900
|
+
'Some text', // line 7 (index 6)
|
|
901
|
+
'* [ ] Task B', // line 8 (index 7)
|
|
902
|
+
'* [x] Task C', // line 9 (index 8)
|
|
903
|
+
].join('\n');
|
|
904
|
+
const allLines = noteWithFM.split('\n');
|
|
905
|
+
it('parseTasks lineIndex is absolute (includes frontmatter)', () => {
|
|
906
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
907
|
+
expect(tasks).toHaveLength(3);
|
|
908
|
+
expect(tasks[0]).toEqual({ lineIndex: 5, content: 'Task A' });
|
|
909
|
+
expect(tasks[1]).toEqual({ lineIndex: 7, content: 'Task B' });
|
|
910
|
+
expect(tasks[2]).toEqual({ lineIndex: 8, content: 'Task C' });
|
|
911
|
+
});
|
|
912
|
+
it('getTasks line field (lineIndex + 1) is consistent with get_notes', () => {
|
|
913
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
914
|
+
const window = buildLineWindow(allLines, {
|
|
915
|
+
defaultLimit: allLines.length,
|
|
916
|
+
maxLimit: allLines.length,
|
|
917
|
+
});
|
|
918
|
+
for (const task of tasks) {
|
|
919
|
+
const line1Based = task.lineIndex + 1; // what getTasks returns as `line`
|
|
920
|
+
const windowLine = window.lines.find(l => l.line === line1Based);
|
|
921
|
+
expect(windowLine).toBeDefined();
|
|
922
|
+
expect(windowLine.content).toContain(task.content);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
it('resolveTaskLineIndex with lineIndex targets the correct line', () => {
|
|
926
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
927
|
+
const taskB = tasks.find(t => t.content === 'Task B');
|
|
928
|
+
const resolved = resolveTaskLineIndex({ lineIndex: taskB.lineIndex });
|
|
929
|
+
expect(resolved.ok).toBe(true);
|
|
930
|
+
if (resolved.ok) {
|
|
931
|
+
expect(allLines[resolved.lineIndex]).toBe('* [ ] Task B');
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
it('resolveTaskLineIndex with line (1-based) targets the correct line', () => {
|
|
935
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
936
|
+
const taskB = tasks.find(t => t.content === 'Task B');
|
|
937
|
+
const line1Based = taskB.lineIndex + 1; // what getTasks exposes
|
|
938
|
+
const resolved = resolveTaskLineIndex({ line: line1Based });
|
|
939
|
+
expect(resolved.ok).toBe(true);
|
|
940
|
+
if (resolved.ok) {
|
|
941
|
+
expect(allLines[resolved.lineIndex]).toBe('* [ ] Task B');
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
it('completeTask splice simulation: mark Task A done using lineIndex from parseTasks', () => {
|
|
945
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
946
|
+
const taskA = tasks.find(t => t.content === 'Task A');
|
|
947
|
+
const resolved = resolveTaskLineIndex({ lineIndex: taskA.lineIndex });
|
|
948
|
+
expect(resolved.ok).toBe(true);
|
|
949
|
+
if (resolved.ok) {
|
|
950
|
+
// Simulate updateTaskStatus: replace checkbox at resolved.lineIndex
|
|
951
|
+
const lines = [...allLines];
|
|
952
|
+
expect(lines[resolved.lineIndex]).toBe('* [ ] Task A');
|
|
953
|
+
lines[resolved.lineIndex] = lines[resolved.lineIndex].replace('[ ]', '[x]');
|
|
954
|
+
expect(lines[resolved.lineIndex]).toBe('* [x] Task A');
|
|
955
|
+
// Frontmatter and other lines untouched
|
|
956
|
+
expect(lines[0]).toBe('---');
|
|
957
|
+
expect(lines[2]).toBe('---');
|
|
958
|
+
expect(lines[7]).toBe('* [ ] Task B');
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
it('updateTask splice simulation: edit Task B content using line from getTasks', () => {
|
|
962
|
+
const tasks = parseTasksMinimal(noteWithFM);
|
|
963
|
+
const taskB = tasks.find(t => t.content === 'Task B');
|
|
964
|
+
const line1Based = taskB.lineIndex + 1;
|
|
965
|
+
const resolved = resolveTaskLineIndex({ line: line1Based });
|
|
966
|
+
expect(resolved.ok).toBe(true);
|
|
967
|
+
if (resolved.ok) {
|
|
968
|
+
const lines = [...allLines];
|
|
969
|
+
expect(lines[resolved.lineIndex]).toBe('* [ ] Task B');
|
|
970
|
+
lines[resolved.lineIndex] = '* [ ] Task B (updated)';
|
|
971
|
+
expect(lines[resolved.lineIndex]).toBe('* [ ] Task B (updated)');
|
|
972
|
+
expect(lines[5]).toBe('* [ ] Task A'); // unchanged
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
it('note without frontmatter: task lineIndex still correct', () => {
|
|
976
|
+
const noFM = [
|
|
977
|
+
'# Tasks', // index 0
|
|
978
|
+
'* [ ] First', // index 1
|
|
979
|
+
'* [ ] Second', // index 2
|
|
980
|
+
].join('\n');
|
|
981
|
+
const tasks = parseTasksMinimal(noFM);
|
|
982
|
+
expect(tasks[0].lineIndex).toBe(1);
|
|
983
|
+
expect(tasks[1].lineIndex).toBe(2);
|
|
984
|
+
const lines = noFM.split('\n');
|
|
985
|
+
expect(lines[tasks[0].lineIndex]).toBe('* [ ] First');
|
|
986
|
+
expect(lines[tasks[1].lineIndex]).toBe('* [ ] Second');
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
// ── Regression: endLine range handling in deleteLines / replaceLines ──
|
|
990
|
+
// A user reported that endLine was ignored — multi-line ranges collapsed to a
|
|
991
|
+
// single line. These tests verify that endLine is respected and that the
|
|
992
|
+
// bounded range covers the expected span.
|
|
993
|
+
describe('deleteLines – endLine range is respected', () => {
|
|
994
|
+
// Note with 3-line frontmatter and 7 content lines (lines 4-10)
|
|
995
|
+
const noteWithFM = [
|
|
996
|
+
'---', // line 1
|
|
997
|
+
'title: Test', // line 2
|
|
998
|
+
'---', // line 3
|
|
999
|
+
'# Heading', // line 4
|
|
1000
|
+
'', // line 5
|
|
1001
|
+
'Line A', // line 6
|
|
1002
|
+
'Line B', // line 7
|
|
1003
|
+
'Line C', // line 8
|
|
1004
|
+
'Line D', // line 9
|
|
1005
|
+
'Line E', // line 10
|
|
1006
|
+
].join('\n');
|
|
1007
|
+
const allLines = noteWithFM.split('\n');
|
|
1008
|
+
const totalLineCount = allLines.length;
|
|
1009
|
+
const fmLineCount = 3;
|
|
1010
|
+
const minLine = fmLineCount + 1; // 4
|
|
1011
|
+
it('startLine=6, endLine=8 deletes 3 lines (Line A, B, C)', () => {
|
|
1012
|
+
const boundedStartLine = toBoundedInt(6, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1013
|
+
const boundedEndLine = toBoundedInt(8, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1014
|
+
expect(boundedStartLine).toBe(6);
|
|
1015
|
+
expect(boundedEndLine).toBe(8);
|
|
1016
|
+
const lineCountToDelete = boundedEndLine - boundedStartLine + 1;
|
|
1017
|
+
expect(lineCountToDelete).toBe(3);
|
|
1018
|
+
const deletedLines = allLines.slice(boundedStartLine - 1, boundedEndLine);
|
|
1019
|
+
expect(deletedLines).toEqual(['Line A', 'Line B', 'Line C']);
|
|
1020
|
+
});
|
|
1021
|
+
it('startLine=6, endLine=6 deletes exactly 1 line', () => {
|
|
1022
|
+
const boundedStartLine = toBoundedInt(6, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1023
|
+
const boundedEndLine = toBoundedInt(6, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1024
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(1);
|
|
1025
|
+
expect(allLines[boundedStartLine - 1]).toBe('Line A');
|
|
1026
|
+
});
|
|
1027
|
+
it('startLine=9, endLine=10 deletes last 2 lines', () => {
|
|
1028
|
+
const boundedStartLine = toBoundedInt(9, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1029
|
+
const boundedEndLine = toBoundedInt(10, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1030
|
+
expect(boundedStartLine).toBe(9);
|
|
1031
|
+
expect(boundedEndLine).toBe(10);
|
|
1032
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(2);
|
|
1033
|
+
expect(allLines.slice(boundedStartLine - 1, boundedEndLine)).toEqual(['Line D', 'Line E']);
|
|
1034
|
+
});
|
|
1035
|
+
it('endLine beyond total lines is clamped to last line', () => {
|
|
1036
|
+
const boundedStartLine = toBoundedInt(9, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1037
|
+
const boundedEndLine = toBoundedInt(99, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1038
|
+
expect(boundedEndLine).toBe(10); // clamped to totalLineCount
|
|
1039
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(2);
|
|
1040
|
+
});
|
|
1041
|
+
it('endLine passed as string is correctly parsed', () => {
|
|
1042
|
+
const boundedStartLine = toBoundedInt(6, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1043
|
+
const boundedEndLine = toBoundedInt('8', boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1044
|
+
expect(boundedEndLine).toBe(8);
|
|
1045
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(3);
|
|
1046
|
+
});
|
|
1047
|
+
it('endLine passed as undefined collapses range to single line (default)', () => {
|
|
1048
|
+
const boundedStartLine = toBoundedInt(6, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1049
|
+
const boundedEndLine = toBoundedInt(undefined, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1050
|
+
// undefined → NaN → falls back to default (boundedStartLine)
|
|
1051
|
+
expect(boundedEndLine).toBe(boundedStartLine);
|
|
1052
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(1);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
describe('replaceLines – endLine range is respected', () => {
|
|
1056
|
+
const noteWithFM = [
|
|
1057
|
+
'---', // line 1
|
|
1058
|
+
'title: Test', // line 2
|
|
1059
|
+
'---', // line 3
|
|
1060
|
+
'# Heading', // line 4
|
|
1061
|
+
'Line A', // line 5
|
|
1062
|
+
'Line B', // line 6
|
|
1063
|
+
'Line C', // line 7
|
|
1064
|
+
'Line D', // line 8
|
|
1065
|
+
].join('\n');
|
|
1066
|
+
const allLines = noteWithFM.split('\n');
|
|
1067
|
+
const originalLineCount = allLines.length;
|
|
1068
|
+
const fmLineCount = 3;
|
|
1069
|
+
const minLine = fmLineCount + 1;
|
|
1070
|
+
it('startLine=5, endLine=7 replaces 3 lines (Line A, B, C)', () => {
|
|
1071
|
+
const boundedStartLine = toBoundedInt(5, minLine, minLine, Math.max(minLine, originalLineCount));
|
|
1072
|
+
const boundedEndLine = toBoundedInt(7, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, originalLineCount));
|
|
1073
|
+
expect(boundedStartLine).toBe(5);
|
|
1074
|
+
expect(boundedEndLine).toBe(7);
|
|
1075
|
+
const startIndex = boundedStartLine - 1;
|
|
1076
|
+
const lineCountToReplace = boundedEndLine - boundedStartLine + 1;
|
|
1077
|
+
expect(lineCountToReplace).toBe(3);
|
|
1078
|
+
const replacedText = allLines.slice(startIndex, boundedEndLine);
|
|
1079
|
+
expect(replacedText).toEqual(['Line A', 'Line B', 'Line C']);
|
|
1080
|
+
// Simulate splice with replacement content
|
|
1081
|
+
const result = [...allLines];
|
|
1082
|
+
result.splice(startIndex, lineCountToReplace, 'Replaced A', 'Replaced B');
|
|
1083
|
+
expect(result).toEqual([
|
|
1084
|
+
'---', 'title: Test', '---', '# Heading',
|
|
1085
|
+
'Replaced A', 'Replaced B', 'Line D',
|
|
1086
|
+
]);
|
|
1087
|
+
});
|
|
1088
|
+
it('startLine=5, endLine=5 replaces exactly 1 line', () => {
|
|
1089
|
+
const boundedStartLine = toBoundedInt(5, minLine, minLine, Math.max(minLine, originalLineCount));
|
|
1090
|
+
const boundedEndLine = toBoundedInt(5, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, originalLineCount));
|
|
1091
|
+
const lineCountToReplace = boundedEndLine - boundedStartLine + 1;
|
|
1092
|
+
expect(lineCountToReplace).toBe(1);
|
|
1093
|
+
expect(allLines[boundedStartLine - 1]).toBe('Line A');
|
|
1094
|
+
});
|
|
1095
|
+
it('endLine passed as string is correctly parsed', () => {
|
|
1096
|
+
const boundedStartLine = toBoundedInt(5, minLine, minLine, Math.max(minLine, originalLineCount));
|
|
1097
|
+
const boundedEndLine = toBoundedInt('7', boundedStartLine, boundedStartLine, Math.max(boundedStartLine, originalLineCount));
|
|
1098
|
+
expect(boundedEndLine).toBe(7);
|
|
1099
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(3);
|
|
1100
|
+
});
|
|
1101
|
+
it('endLine passed as undefined collapses range to single line (default)', () => {
|
|
1102
|
+
const boundedStartLine = toBoundedInt(5, minLine, minLine, Math.max(minLine, originalLineCount));
|
|
1103
|
+
const boundedEndLine = toBoundedInt(undefined, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, originalLineCount));
|
|
1104
|
+
expect(boundedEndLine).toBe(boundedStartLine);
|
|
1105
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(1);
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
// ── Regression: MCP dispatch — missing endLine must not silently collapse range ──
|
|
1109
|
+
// The MCP tool schema only requires "action", so startLine/endLine may arrive as
|
|
1110
|
+
// undefined when the client omits them. The functions should either reject the
|
|
1111
|
+
// call or preserve the intended range — NOT silently collapse to a single line.
|
|
1112
|
+
//
|
|
1113
|
+
// These tests simulate the exact parameter shapes that MCP delivers to
|
|
1114
|
+
// deleteLines/replaceLines (via `args as any`) and verify correct behavior.
|
|
1115
|
+
describe('deleteLines – MCP param validation: missing endLine', () => {
|
|
1116
|
+
const fmLineCount = 3;
|
|
1117
|
+
const totalLineCount = 10;
|
|
1118
|
+
const minLine = fmLineCount + 1;
|
|
1119
|
+
it('rejects missing endLine instead of silently collapsing range', () => {
|
|
1120
|
+
// Simulate: MCP delivers { startLine: 6 } with no endLine
|
|
1121
|
+
const params = { startLine: 6 };
|
|
1122
|
+
// Validate the same way deleteLines should: endLine must be a finite number
|
|
1123
|
+
const endLineNum = params.endLine !== undefined && params.endLine !== null ? Number(params.endLine) : NaN;
|
|
1124
|
+
expect(Number.isFinite(endLineNum)).toBe(false); // undefined → NaN → not finite
|
|
1125
|
+
// The function should detect this and return an error, not silently collapse
|
|
1126
|
+
const startLineNum = params.startLine !== undefined && params.startLine !== null ? Number(params.startLine) : NaN;
|
|
1127
|
+
expect(Number.isFinite(startLineNum)).toBe(true); // startLine is valid
|
|
1128
|
+
});
|
|
1129
|
+
it('valid endLine as number is preserved in range', () => {
|
|
1130
|
+
const params = { startLine: 6, endLine: 8 };
|
|
1131
|
+
const boundedStartLine = toBoundedInt(params.startLine, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1132
|
+
const boundedEndLine = toBoundedInt(params.endLine, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1133
|
+
expect(boundedStartLine).toBe(6);
|
|
1134
|
+
expect(boundedEndLine).toBe(8);
|
|
1135
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(3);
|
|
1136
|
+
});
|
|
1137
|
+
it('valid endLine as string (MCP coercion) is preserved in range', () => {
|
|
1138
|
+
const params = { startLine: 6, endLine: '8' };
|
|
1139
|
+
const boundedStartLine = toBoundedInt(params.startLine, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1140
|
+
const boundedEndLine = toBoundedInt(params.endLine, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1141
|
+
expect(boundedEndLine).toBe(8);
|
|
1142
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(3);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
describe('replaceLines – MCP param validation: missing endLine', () => {
|
|
1146
|
+
const fmLineCount = 3;
|
|
1147
|
+
const originalLineCount = 8;
|
|
1148
|
+
const minLine = fmLineCount + 1;
|
|
1149
|
+
it('rejects missing endLine instead of silently collapsing range', () => {
|
|
1150
|
+
const params = { startLine: 5 };
|
|
1151
|
+
const endLineNum = params.endLine !== undefined && params.endLine !== null ? Number(params.endLine) : NaN;
|
|
1152
|
+
expect(Number.isFinite(endLineNum)).toBe(false);
|
|
1153
|
+
const startLineNum = params.startLine !== undefined && params.startLine !== null ? Number(params.startLine) : NaN;
|
|
1154
|
+
expect(Number.isFinite(startLineNum)).toBe(true);
|
|
1155
|
+
});
|
|
1156
|
+
it('valid endLine as number is preserved in range', () => {
|
|
1157
|
+
const params = { startLine: 5, endLine: 7 };
|
|
1158
|
+
const boundedStartLine = toBoundedInt(params.startLine, minLine, minLine, Math.max(minLine, originalLineCount));
|
|
1159
|
+
const boundedEndLine = toBoundedInt(params.endLine, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, originalLineCount));
|
|
1160
|
+
expect(boundedStartLine).toBe(5);
|
|
1161
|
+
expect(boundedEndLine).toBe(7);
|
|
1162
|
+
expect(boundedEndLine - boundedStartLine + 1).toBe(3);
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
// ── Regression: frontmatter protection guards ──
|
|
1166
|
+
// editLine, deleteLines, and replaceLines must reject or clamp attempts to
|
|
1167
|
+
// modify frontmatter lines. These tests verify the guard logic directly.
|
|
1168
|
+
describe('editLine – frontmatter protection', () => {
|
|
1169
|
+
const noteWithFM = [
|
|
1170
|
+
'---', // line 1 (index 0)
|
|
1171
|
+
'title: Test', // line 2 (index 1)
|
|
1172
|
+
'---', // line 3 (index 2)
|
|
1173
|
+
'# Heading', // line 4 (index 3)
|
|
1174
|
+
'* [ ] Task A', // line 5 (index 4)
|
|
1175
|
+
].join('\n');
|
|
1176
|
+
const allLines = noteWithFM.split('\n');
|
|
1177
|
+
const fmLineCount = 3; // lines 1-3 are frontmatter
|
|
1178
|
+
it('rejects editing line 1 (opening ---)', () => {
|
|
1179
|
+
const lineIndex = 1 - 1; // line 1 → index 0
|
|
1180
|
+
expect(fmLineCount > 0 && lineIndex < fmLineCount).toBe(true);
|
|
1181
|
+
});
|
|
1182
|
+
it('rejects editing line 2 (frontmatter content)', () => {
|
|
1183
|
+
const lineIndex = 2 - 1;
|
|
1184
|
+
expect(fmLineCount > 0 && lineIndex < fmLineCount).toBe(true);
|
|
1185
|
+
});
|
|
1186
|
+
it('rejects editing line 3 (closing ---)', () => {
|
|
1187
|
+
const lineIndex = 3 - 1;
|
|
1188
|
+
expect(fmLineCount > 0 && lineIndex < fmLineCount).toBe(true);
|
|
1189
|
+
});
|
|
1190
|
+
it('allows editing line 4 (first content line after frontmatter)', () => {
|
|
1191
|
+
const lineIndex = 4 - 1;
|
|
1192
|
+
const rejected = fmLineCount > 0 && lineIndex < fmLineCount;
|
|
1193
|
+
expect(rejected).toBe(false);
|
|
1194
|
+
expect(allLines[lineIndex]).toBe('# Heading');
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
describe('deleteLines – frontmatter protection via clamping', () => {
|
|
1198
|
+
const noteWithFM = [
|
|
1199
|
+
'---', // line 1
|
|
1200
|
+
'title: Test', // line 2
|
|
1201
|
+
'---', // line 3
|
|
1202
|
+
'# Heading', // line 4
|
|
1203
|
+
'Body line 1', // line 5
|
|
1204
|
+
'Body line 2', // line 6
|
|
1205
|
+
].join('\n');
|
|
1206
|
+
const allLines = noteWithFM.split('\n');
|
|
1207
|
+
const totalLineCount = allLines.length;
|
|
1208
|
+
const fmLineCount = 3;
|
|
1209
|
+
const minLine = fmLineCount + 1; // 4
|
|
1210
|
+
it('clamps startLine=1 up to first content line', () => {
|
|
1211
|
+
// Simulate: user passes startLine=1, endLine=1 (trying to delete frontmatter)
|
|
1212
|
+
const boundedStartLine = toBoundedInt(1, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1213
|
+
expect(boundedStartLine).toBe(4); // clamped to first content line
|
|
1214
|
+
expect(allLines[boundedStartLine - 1]).toBe('# Heading');
|
|
1215
|
+
});
|
|
1216
|
+
it('clamps startLine=3 up to first content line', () => {
|
|
1217
|
+
const boundedStartLine = toBoundedInt(3, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1218
|
+
expect(boundedStartLine).toBe(4);
|
|
1219
|
+
});
|
|
1220
|
+
it('does not clamp startLine=4 (already at first content line)', () => {
|
|
1221
|
+
const boundedStartLine = toBoundedInt(4, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1222
|
+
expect(boundedStartLine).toBe(4);
|
|
1223
|
+
});
|
|
1224
|
+
it('does not clamp startLine=5 (beyond frontmatter)', () => {
|
|
1225
|
+
const boundedStartLine = toBoundedInt(5, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1226
|
+
expect(boundedStartLine).toBe(5);
|
|
1227
|
+
expect(allLines[boundedStartLine - 1]).toBe('Body line 1');
|
|
1228
|
+
});
|
|
1229
|
+
it('range startLine=1 endLine=6 gets clamped to 4-6 (frontmatter protected)', () => {
|
|
1230
|
+
const boundedStartLine = toBoundedInt(1, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1231
|
+
const boundedEndLine = toBoundedInt(6, boundedStartLine, boundedStartLine, Math.max(boundedStartLine, totalLineCount));
|
|
1232
|
+
expect(boundedStartLine).toBe(4);
|
|
1233
|
+
expect(boundedEndLine).toBe(6);
|
|
1234
|
+
const deletedLines = allLines.slice(boundedStartLine - 1, boundedEndLine);
|
|
1235
|
+
expect(deletedLines).toEqual(['# Heading', 'Body line 1', 'Body line 2']);
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
describe('replaceLines – frontmatter protection via clamping', () => {
|
|
1239
|
+
const noteWithFM = [
|
|
1240
|
+
'---', // line 1
|
|
1241
|
+
'title: Test', // line 2
|
|
1242
|
+
'---', // line 3
|
|
1243
|
+
'# Heading', // line 4
|
|
1244
|
+
'Body line 1', // line 5
|
|
1245
|
+
].join('\n');
|
|
1246
|
+
const allLines = noteWithFM.split('\n');
|
|
1247
|
+
const totalLineCount = allLines.length;
|
|
1248
|
+
const fmLineCount = 3;
|
|
1249
|
+
const minLine = fmLineCount + 1;
|
|
1250
|
+
it('clamps startLine=1 up to first content line', () => {
|
|
1251
|
+
const boundedStartLine = toBoundedInt(1, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1252
|
+
expect(boundedStartLine).toBe(4);
|
|
1253
|
+
});
|
|
1254
|
+
it('safety-net check rejects if startLine somehow lands inside frontmatter', () => {
|
|
1255
|
+
// This shouldn't happen due to clamping, but test the guard anyway
|
|
1256
|
+
const hypotheticalStartLine = 2; // inside frontmatter
|
|
1257
|
+
expect(hypotheticalStartLine <= fmLineCount).toBe(true);
|
|
1258
|
+
});
|
|
1259
|
+
it('allows replacing line 4 (first content line)', () => {
|
|
1260
|
+
const boundedStartLine = toBoundedInt(4, minLine, minLine, Math.max(minLine, totalLineCount));
|
|
1261
|
+
expect(boundedStartLine).toBe(4);
|
|
1262
|
+
expect(boundedStartLine <= fmLineCount).toBe(false);
|
|
1263
|
+
expect(allLines[boundedStartLine - 1]).toBe('# Heading');
|
|
1264
|
+
});
|
|
1265
|
+
it('note without frontmatter: minLine is 1, no clamping needed', () => {
|
|
1266
|
+
const noFM = ['# Title', 'Body'].join('\n');
|
|
1267
|
+
const noFMLines = noFM.split('\n');
|
|
1268
|
+
const noFMCount = 0; // no frontmatter
|
|
1269
|
+
const noFMMin = noFMCount > 0 ? noFMCount + 1 : 1;
|
|
1270
|
+
expect(noFMMin).toBe(1);
|
|
1271
|
+
const boundedStartLine = toBoundedInt(1, noFMMin, noFMMin, noFMLines.length);
|
|
1272
|
+
expect(boundedStartLine).toBe(1);
|
|
1273
|
+
expect(noFMLines[boundedStartLine - 1]).toBe('# Title');
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
// ---------------------------------------------------------------------------
|
|
1277
|
+
// matchesFrontmatterProperties — edge cases for developer report
|
|
1278
|
+
// ---------------------------------------------------------------------------
|
|
1279
|
+
describe('matchesFrontmatterProperties – property filter edge cases', () => {
|
|
1280
|
+
const makeNote = (content) => ({
|
|
1281
|
+
id: 'test-id',
|
|
1282
|
+
title: 'Test Note',
|
|
1283
|
+
filename: 'test.md',
|
|
1284
|
+
type: 'note',
|
|
1285
|
+
source: 'local',
|
|
1286
|
+
folder: '',
|
|
1287
|
+
content,
|
|
1288
|
+
modifiedAt: new Date(),
|
|
1289
|
+
createdAt: new Date(),
|
|
1290
|
+
spaceId: undefined,
|
|
1291
|
+
date: undefined,
|
|
1292
|
+
});
|
|
1293
|
+
it('matches quoted property value: type: "book" matches filter {type: "book"}', () => {
|
|
1294
|
+
const note = makeNote('---\ntype: "book"\n---\nContent');
|
|
1295
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(true);
|
|
1296
|
+
});
|
|
1297
|
+
it('matches quoted filter against unquoted value: filter "book" matches type: book', () => {
|
|
1298
|
+
const note = makeNote('---\ntype: book\n---\nContent');
|
|
1299
|
+
expect(matchesFrontmatterProperties(note, [['type', '"book"']], false)).toBe(true);
|
|
1300
|
+
});
|
|
1301
|
+
it('matches single-quoted property value', () => {
|
|
1302
|
+
const note = makeNote("---\ntype: 'book'\n---\nContent");
|
|
1303
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(true);
|
|
1304
|
+
});
|
|
1305
|
+
it('matches semicolon-separated list values', () => {
|
|
1306
|
+
const note = makeNote('---\ncategories: fiction;non-fiction;book\n---\nContent');
|
|
1307
|
+
expect(matchesFrontmatterProperties(note, [['categories', 'book']], false)).toBe(true);
|
|
1308
|
+
});
|
|
1309
|
+
it('matches semicolon-separated list with spaces', () => {
|
|
1310
|
+
const note = makeNote('---\ncategories: fiction; book; novel\n---\nContent');
|
|
1311
|
+
expect(matchesFrontmatterProperties(note, [['categories', 'book']], false)).toBe(true);
|
|
1312
|
+
});
|
|
1313
|
+
it('does not match empty property value against non-empty filter', () => {
|
|
1314
|
+
const note = makeNote('---\ntype: \n---\nContent');
|
|
1315
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(false);
|
|
1316
|
+
});
|
|
1317
|
+
it('does not match when property value is whitespace-only', () => {
|
|
1318
|
+
const note = makeNote('---\ntype: \n---\nContent');
|
|
1319
|
+
expect(matchesFrontmatterProperties(note, [['type', 'book']], false)).toBe(false);
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
// ---------------------------------------------------------------------------
|
|
1323
|
+
// getNote id/filename resolution — regression test
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
// We test the getNote logic by importing the function and mocking the readers.
|
|
1326
|
+
// Since getNote depends on sqliteReader and fileReader, we use vi.mock.
|
|
1327
|
+
import { getNote } from '../noteplan/unified-store.js';
|
|
1328
|
+
import * as sqliteReader from '../noteplan/sqlite-reader.js';
|
|
1329
|
+
import * as fileReader from '../noteplan/file-reader.js';
|
|
1330
|
+
vi.mock('../noteplan/sqlite-reader.js', () => ({
|
|
1331
|
+
getSpaceNote: vi.fn(),
|
|
1332
|
+
listSpaces: vi.fn(() => []),
|
|
1333
|
+
listSpaceNotes: vi.fn(() => []),
|
|
1334
|
+
searchSpaceNotesFTS: vi.fn(() => []),
|
|
1335
|
+
getSpaceNoteByTitle: vi.fn(),
|
|
1336
|
+
getSpaceCalendarNote: vi.fn(),
|
|
1337
|
+
listSpaceFolders: vi.fn(() => []),
|
|
1338
|
+
extractSpaceTags: vi.fn(() => []),
|
|
1339
|
+
resolveSpaceFolder: vi.fn(),
|
|
1340
|
+
isSpaceNoteInTrash: vi.fn(),
|
|
1341
|
+
countSpaceFolderContents: vi.fn(() => ({ noteCount: 0, folderCount: 0 })),
|
|
1342
|
+
}));
|
|
1343
|
+
vi.mock('../noteplan/file-reader.js', () => ({
|
|
1344
|
+
readNoteFile: vi.fn(),
|
|
1345
|
+
getCalendarNote: vi.fn(),
|
|
1346
|
+
getNoteByTitle: vi.fn(),
|
|
1347
|
+
listProjectNotes: vi.fn(() => []),
|
|
1348
|
+
listCalendarNotes: vi.fn(() => []),
|
|
1349
|
+
listFolders: vi.fn(() => []),
|
|
1350
|
+
searchLocalNotes: vi.fn(() => []),
|
|
1351
|
+
getNotesPath: vi.fn(() => '/tmp/notes'),
|
|
1352
|
+
extractAllTags: vi.fn(() => []),
|
|
1353
|
+
countNotesInDirectory: vi.fn(() => ({ noteCount: 0, folderCount: 0 })),
|
|
1354
|
+
}));
|
|
1355
|
+
vi.mock('../noteplan/file-writer.js', () => ({}));
|
|
1356
|
+
vi.mock('../noteplan/sqlite-writer.js', () => ({}));
|
|
1357
|
+
vi.mock('./ripgrep-search.js', () => ({
|
|
1358
|
+
isRipgrepAvailable: vi.fn(() => Promise.resolve(false)),
|
|
1359
|
+
searchWithRipgrep: vi.fn(),
|
|
1360
|
+
}));
|
|
1361
|
+
vi.mock('../noteplan/fuzzy-search.js', () => ({
|
|
1362
|
+
fuzzySearch: vi.fn(() => []),
|
|
1363
|
+
}));
|
|
1364
|
+
describe('getNote id/filename resolution', () => {
|
|
1365
|
+
const localNote = {
|
|
1366
|
+
id: 'Notes/Books/my-book.md',
|
|
1367
|
+
title: 'My Book',
|
|
1368
|
+
filename: 'Notes/Books/my-book.md',
|
|
1369
|
+
type: 'note',
|
|
1370
|
+
source: 'local',
|
|
1371
|
+
folder: 'Books',
|
|
1372
|
+
content: '---\ntype: book\n---\n# My Book\nContent here',
|
|
1373
|
+
modifiedAt: new Date(),
|
|
1374
|
+
createdAt: new Date(),
|
|
1375
|
+
spaceId: undefined,
|
|
1376
|
+
date: undefined,
|
|
1377
|
+
};
|
|
1378
|
+
beforeEach(() => {
|
|
1379
|
+
vi.clearAllMocks();
|
|
1380
|
+
});
|
|
1381
|
+
it('retrieves a local note via id when SQLite lookup returns null', () => {
|
|
1382
|
+
vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
|
|
1383
|
+
vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
|
|
1384
|
+
const result = getNote({ id: 'Notes/Books/my-book.md' });
|
|
1385
|
+
expect(result).toEqual(localNote);
|
|
1386
|
+
expect(sqliteReader.getSpaceNote).toHaveBeenCalledWith('Notes/Books/my-book.md');
|
|
1387
|
+
expect(fileReader.readNoteFile).toHaveBeenCalledWith('Notes/Books/my-book.md');
|
|
1388
|
+
});
|
|
1389
|
+
it('retrieves a local note via filename', () => {
|
|
1390
|
+
vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
|
|
1391
|
+
const result = getNote({ filename: 'Notes/Books/my-book.md' });
|
|
1392
|
+
expect(result).toEqual(localNote);
|
|
1393
|
+
});
|
|
1394
|
+
it('id and filename return the same note for local notes', () => {
|
|
1395
|
+
vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
|
|
1396
|
+
vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
|
|
1397
|
+
const byId = getNote({ id: localNote.filename });
|
|
1398
|
+
const byFilename = getNote({ filename: localNote.filename });
|
|
1399
|
+
expect(byId).toEqual(byFilename);
|
|
1400
|
+
});
|
|
1401
|
+
it('returns null for non-existent id', () => {
|
|
1402
|
+
vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
|
|
1403
|
+
vi.mocked(fileReader.readNoteFile).mockReturnValue(null);
|
|
1404
|
+
const result = getNote({ id: 'Notes/nonexistent.md' });
|
|
1405
|
+
expect(result).toBeNull();
|
|
1406
|
+
});
|
|
1407
|
+
it('prefers space note when SQLite lookup succeeds', () => {
|
|
1408
|
+
const spaceNote = { ...localNote, source: 'space', id: 'space-uuid-123' };
|
|
1409
|
+
vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(spaceNote);
|
|
1410
|
+
const result = getNote({ id: 'space-uuid-123' });
|
|
1411
|
+
expect(result).toEqual(spaceNote);
|
|
1412
|
+
expect(fileReader.readNoteFile).not.toHaveBeenCalled();
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
// ---------------------------------------------------------------------------
|
|
1416
|
+
// Issue #2 verification: searchLocalNotes with colons in query
|
|
1417
|
+
// searchLocalNotes uses a simple content.toLowerCase().includes(query) check.
|
|
1418
|
+
// This verifies colons in the query don't cause matching failures.
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
describe('searchLocalNotes – colon in query (issue #2 verification)', () => {
|
|
1421
|
+
// Replicate the matching logic from file-reader.ts searchLocalNotes
|
|
1422
|
+
function matchesSearchQuery(note, query) {
|
|
1423
|
+
const lowerQuery = query.toLowerCase();
|
|
1424
|
+
return (note.content.toLowerCase().includes(lowerQuery) ||
|
|
1425
|
+
note.title.toLowerCase().includes(lowerQuery));
|
|
1426
|
+
}
|
|
1427
|
+
it('finds notes containing "type: book" when query includes a colon', () => {
|
|
1428
|
+
const note = {
|
|
1429
|
+
content: '---\ntype: book\n---\n# My Book\nSome content',
|
|
1430
|
+
title: 'My Book',
|
|
1431
|
+
};
|
|
1432
|
+
expect(matchesSearchQuery(note, 'type: book')).toBe(true);
|
|
1433
|
+
});
|
|
1434
|
+
it('finds notes containing "articles:" when query ends with a colon', () => {
|
|
1435
|
+
const note = {
|
|
1436
|
+
content: '---\ntitle: Reading List\narticles: yes\n---\n# Reading List',
|
|
1437
|
+
title: 'Reading List',
|
|
1438
|
+
};
|
|
1439
|
+
expect(matchesSearchQuery(note, 'articles:')).toBe(true);
|
|
1440
|
+
});
|
|
1441
|
+
it('colon in query does not cause false positives', () => {
|
|
1442
|
+
const note = {
|
|
1443
|
+
content: '---\ntype: article\n---\n# Article\nSome content',
|
|
1444
|
+
title: 'Article',
|
|
1445
|
+
};
|
|
1446
|
+
expect(matchesSearchQuery(note, 'type: book')).toBe(false);
|
|
1447
|
+
});
|
|
1448
|
+
it('matches colon query case-insensitively', () => {
|
|
1449
|
+
const note = {
|
|
1450
|
+
content: '---\nType: Book\n---\n# My Book',
|
|
1451
|
+
title: 'My Book',
|
|
1452
|
+
};
|
|
1453
|
+
expect(matchesSearchQuery(note, 'type: book')).toBe(true);
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
// ---------------------------------------------------------------------------
|
|
1457
|
+
// Issue #3 verification: searchParagraphsGlobal finds frontmatter text
|
|
1458
|
+
// searchParagraphsGlobal iterates ALL lines of a note (including frontmatter).
|
|
1459
|
+
// This verifies frontmatter lines are not skipped during paragraph search.
|
|
1460
|
+
// ---------------------------------------------------------------------------
|
|
1461
|
+
describe('searchParagraphsGlobal – frontmatter text matching (issue #3 verification)', () => {
|
|
1462
|
+
// Replicate the core line-matching logic from searchParagraphsGlobal in notes.ts
|
|
1463
|
+
function findMatchingLines(content, query, caseSensitive = false) {
|
|
1464
|
+
const normalizedQuery = caseSensitive ? query : query.toLowerCase();
|
|
1465
|
+
const lines = content.split('\n');
|
|
1466
|
+
const matches = [];
|
|
1467
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1468
|
+
const lineText = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
1469
|
+
if (lineText.includes(normalizedQuery)) {
|
|
1470
|
+
matches.push({ lineIndex: i, line: i + 1, content: lines[i] });
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return matches;
|
|
1474
|
+
}
|
|
1475
|
+
it('finds "type: book" in frontmatter lines', () => {
|
|
1476
|
+
const content = '---\ntype: book\nauthor: Someone\n---\n# My Book\nContent';
|
|
1477
|
+
const matches = findMatchingLines(content, 'type: book');
|
|
1478
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
1479
|
+
expect(matches[0].content).toBe('type: book');
|
|
1480
|
+
expect(matches[0].line).toBe(2); // line 2 in the frontmatter
|
|
1481
|
+
});
|
|
1482
|
+
it('finds frontmatter property among all note lines', () => {
|
|
1483
|
+
const content = '---\ntags: fiction, novel\nstatus: reading\n---\n# Novel\nGreat book';
|
|
1484
|
+
const matches = findMatchingLines(content, 'status: reading');
|
|
1485
|
+
expect(matches).toHaveLength(1);
|
|
1486
|
+
expect(matches[0].lineIndex).toBe(2); // 0-indexed: ---(0), tags(1), status(2)
|
|
1487
|
+
});
|
|
1488
|
+
it('does not skip the opening --- delimiter', () => {
|
|
1489
|
+
const content = '---\ntype: book\n---\n# Title';
|
|
1490
|
+
const matches = findMatchingLines(content, '---');
|
|
1491
|
+
// Should find both the opening and closing ---
|
|
1492
|
+
expect(matches.length).toBe(2);
|
|
1493
|
+
expect(matches[0].line).toBe(1);
|
|
1494
|
+
expect(matches[1].line).toBe(3);
|
|
1495
|
+
});
|
|
1496
|
+
it('finds matches in both frontmatter and body', () => {
|
|
1497
|
+
const content = '---\nnote: book review\n---\n# Book Review\nThis is a book review.';
|
|
1498
|
+
const matches = findMatchingLines(content, 'book review');
|
|
1499
|
+
expect(matches.length).toBe(3); // frontmatter line, heading, body line
|
|
1500
|
+
});
|
|
1501
|
+
it('returns empty when query does not match any line', () => {
|
|
1502
|
+
const content = '---\ntype: article\n---\n# Article\nSome content';
|
|
1503
|
+
const matches = findMatchingLines(content, 'type: book');
|
|
1504
|
+
expect(matches).toHaveLength(0);
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
// ── Manual test procedure for frontmatter line consistency ──
|
|
1508
|
+
// Run this against a live NotePlan MCP server to verify read/write consistency.
|
|
1509
|
+
//
|
|
1510
|
+
// SETUP: Create a test note with frontmatter:
|
|
1511
|
+
// noteplan_manage_note(action="create", title="FM Line Test", content=`
|
|
1512
|
+
// ---
|
|
1513
|
+
// test: true
|
|
1514
|
+
// ---
|
|
1515
|
+
// # FM Line Test
|
|
1516
|
+
//
|
|
1517
|
+
// Line A
|
|
1518
|
+
// Line B
|
|
1519
|
+
// Line C
|
|
1520
|
+
// `)
|
|
1521
|
+
//
|
|
1522
|
+
// TEST 1: Read and verify line numbers
|
|
1523
|
+
// noteplan_get_notes(filename="...", includeContent=true)
|
|
1524
|
+
// → Verify: "---" is line 1, "# FM Line Test" is line 4, "Line A" is line 6
|
|
1525
|
+
//
|
|
1526
|
+
// TEST 2: Search and verify line numbers match
|
|
1527
|
+
// noteplan_paragraphs(action="search", filename="...", query="Line B")
|
|
1528
|
+
// → Verify: result.line matches get_notes line for "Line B" (should be 7)
|
|
1529
|
+
//
|
|
1530
|
+
// TEST 3: Delete using the line number from search
|
|
1531
|
+
// noteplan_edit_content(action="delete_lines", filename="...", startLine=7, endLine=7, dryRun=true)
|
|
1532
|
+
// → Verify: dry run preview shows "Line B", NOT a different line
|
|
1533
|
+
// → If preview shows wrong content, the frontmatter offset bug has regressed!
|
|
1534
|
+
//
|
|
1535
|
+
// TEST 4: Edit a line using absolute number
|
|
1536
|
+
// noteplan_edit_content(action="edit_line", filename="...", line=6, content="Line A (edited)")
|
|
1537
|
+
// → Verify: "Line A" was changed, not a different line
|
|
1538
|
+
//
|
|
1539
|
+
// TEST 5: Replace lines using absolute numbers
|
|
1540
|
+
// noteplan_edit_content(action="replace_lines", filename="...", startLine=6, endLine=8,
|
|
1541
|
+
// content="Replaced A\nReplaced B\nReplaced C", dryRun=true)
|
|
1542
|
+
// → Verify: preview shows "Line A", "Line B", "Line C" being replaced
|
|
1543
|
+
//
|
|
1544
|
+
// TEST 6: Insert at absolute line
|
|
1545
|
+
// noteplan_edit_content(action="insert", filename="...", position="at-line", line=6,
|
|
1546
|
+
// content="Inserted before Line A")
|
|
1547
|
+
// → Verify: new line appears at line 6, "Line A" moves to line 7
|
|
1548
|
+
//
|
|
1549
|
+
// TEST 7: Frontmatter protection
|
|
1550
|
+
// noteplan_edit_content(action="delete_lines", filename="...", startLine=1, endLine=3)
|
|
1551
|
+
// → Verify: returns error about frontmatter protection, does NOT delete
|
|
1552
|
+
//
|
|
1553
|
+
// CLEANUP:
|
|
1554
|
+
// noteplan_manage_note(action="delete", filename="...")
|
|
1555
|
+
//
|
|
598
1556
|
//# sourceMappingURL=notes.test.js.map
|