From 4ba0b99b1e389bfced221c6d25a62cdae9e89bd8 Mon Sep 17 00:00:00 2001 From: Ben Klein Date: Wed, 17 Sep 2025 18:20:48 +0000 Subject: [PATCH 1/3] feat: include the title of the file in the simple search --- docs/openapi.yaml | 6 + mocks/obsidian.ts | 26 ++-- src/requestHandler.test.ts | 251 +++++++++++++++++++++++++++++++++++++ src/requestHandler.ts | 62 ++++++--- src/types.ts | 1 + 5 files changed, 320 insertions(+), 26 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6d80d9..74b0899 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1583,11 +1583,17 @@ paths: properties: end: type: "number" + description: "End character position of the match within the file. Starts with 0." start: type: "number" + description: "Start character position of the match within the file. Starts with 0." + source: + type: "string" + description: "Where the search term matched: 'filename' or 'content'" required: - "start" - "end" + - "source" type: "object" required: - "match" diff --git a/mocks/obsidian.ts b/mocks/obsidian.ts index 21452da..e272f1d 100644 --- a/mocks/obsidian.ts +++ b/mocks/obsidian.ts @@ -22,7 +22,7 @@ class DataAdapter { _read = ""; _readBinary = new ArrayBuffer(0); _write: [string, string]; - _writeBinary : [string, ArrayBuffer]; + _writeBinary: [string, ArrayBuffer]; _remove: [string]; _stat = new Stat(); @@ -42,12 +42,12 @@ class DataAdapter { return this._readBinary; } - async write(path: string, content: string, option?:DataWriteOptions): Promise { + async write(path: string, content: string, option?: DataWriteOptions): Promise { this._write = [path, content]; } - async writeBinary(path: string, content: ArrayBuffer, option?:DataWriteOptions): Promise { - this._writeBinary = [path,content] + async writeBinary(path: string, content: ArrayBuffer, option?: DataWriteOptions): Promise { + this._writeBinary = [path, content]; } async remove(path: string): Promise { @@ -72,7 +72,7 @@ export class Vault { return this._cachedRead; } - async createFolder(path: string): Promise {} + async createFolder(path: string): Promise { } getFiles(): TFile[] { return this._files; @@ -158,6 +158,7 @@ export class FileStats { export class TFile { path = "somefile.md"; + basename = "somefile"; stat: FileStats = new FileStats(); } @@ -165,7 +166,7 @@ export class PluginManifest { version = ""; } -export class SettingTab {} +export class SettingTab { } export const apiVersion = "1.0.0"; @@ -174,8 +175,15 @@ export class SearchResult { matches: [number, number][] = []; } -export function prepareSimpleSearch( - query: string -): (value: string) => null | SearchResult { +// Mock configuration that tests can control +// Tests can set this to override the default behavior +export const _prepareSimpleSearchMock = { + behavior: null as ((query: string) => (text: string) => null | SearchResult) | null, +}; + +export function prepareSimpleSearch(query: string): (value: string) => null | SearchResult { + if (_prepareSimpleSearchMock.behavior) { + return _prepareSimpleSearchMock.behavior(query); + } return null; } diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index 9638726..cff5172 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -802,4 +802,255 @@ describe("requestHandler", () => { .expect(401); }); }); + + describe("searchSimplePost", () => { + test("match at beginning of filename", async () => { + const testFile = new TFile(); + testFile.basename = "Master Plan"; + testFile.path = "Master Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Some content here"; + + const result = await request(server) + .post("/search/simple/?query=Master") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("Master Plan.md"); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(0); + expect(result.body[0].matches[0].match.end).toBe(6); + expect(result.body[0].matches[0].context).toBe("Master Plan"); + }); + + test("match in middle of filename", async () => { + const testFile = new TFile(); + testFile.basename = "1 - Master Plan"; + testFile.path = "1 - Master Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Some content here"; + + const result = await request(server) + .post("/search/simple/?query=Master") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("1 - Master Plan.md"); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(4); + expect(result.body[0].matches[0].match.end).toBe(10); + expect(result.body[0].matches[0].context).toBe("1 - Master Plan"); + }); + + test("match at end of filename", async () => { + const testFile = new TFile(); + testFile.basename = "My Master Plan"; + testFile.path = "My Master Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Some content here"; + + const result = await request(server) + .post("/search/simple/?query=Plan") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("My Master Plan.md"); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(10); + expect(result.body[0].matches[0].match.end).toBe(14); + expect(result.body[0].matches[0].context).toBe("My Master Plan"); + }); + + test("match in content only", async () => { + const testFile = new TFile(); + testFile.basename = "Random Note"; + testFile.path = "Random Note.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "This is my master plan for the project."; + + const result = await request(server) + .post("/search/simple/?query=master") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("Random Note.md"); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("content"); + expect(result.body[0].matches[0].match.start).toBe(11); + expect(result.body[0].matches[0].match.end).toBe(17); + }); + + test("match in both filename and content", async () => { + const testFile = new TFile(); + testFile.basename = "Master Plan"; + testFile.path = "Master Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "The master plan is to complete this project."; + + const result = await request(server) + .post("/search/simple/?query=master") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("Master Plan.md"); + expect(result.body[0].matches).toHaveLength(2); + + // First match should be in filename (case-insensitive) + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(0); + expect(result.body[0].matches[0].match.end).toBe(6); + expect(result.body[0].matches[0].context).toBe("Master Plan"); + + // Second match should be in content + expect(result.body[0].matches[1].match.source).toBe("content"); + expect(result.body[0].matches[1].match.start).toBe(4); + expect(result.body[0].matches[1].match.end).toBe(10); + }); + + test("multiple matches in filename", async () => { + const testFile = new TFile(); + testFile.basename = "Test Test Test"; + testFile.path = "Test Test Test.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Content without the search term"; + + const result = await request(server) + .post("/search/simple/?query=Test") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("Test Test Test.md"); + expect(result.body[0].matches).toHaveLength(3); + + // All matches should be in filename + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(0); + expect(result.body[0].matches[0].match.end).toBe(4); + + expect(result.body[0].matches[1].match.source).toBe("filename"); + expect(result.body[0].matches[1].match.start).toBe(5); + expect(result.body[0].matches[1].match.end).toBe(9); + + expect(result.body[0].matches[2].match.source).toBe("filename"); + expect(result.body[0].matches[2].match.start).toBe(10); + expect(result.body[0].matches[2].match.end).toBe(14); + }); + + test("filename with special characters", async () => { + const testFile = new TFile(); + testFile.basename = "Project (2024) - Master Plan"; + testFile.path = "Project (2024) - Master Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Project details"; + + const result = await request(server) + .post("/search/simple/?query=2024") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].filename).toBe("Project (2024) - Master Plan.md"); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(9); + expect(result.body[0].matches[0].match.end).toBe(13); + expect(result.body[0].matches[0].context).toBe("Project (2024) - Master Plan"); + }); + + test("context length for content matches", async () => { + const testFile = new TFile(); + testFile.basename = "Note"; + testFile.path = "Note.md"; + + const longContent = "A".repeat(200) + "MATCH" + "B".repeat(200); + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = longContent; + + const result = await request(server) + .post("/search/simple/?query=MATCH&contextLength=50") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].matches).toHaveLength(1); + expect(result.body[0].matches[0].match.source).toBe("content"); + + // Context should be approximately 50 chars before + match + 50 chars after + const context = result.body[0].matches[0].context; + expect(context.length).toBeLessThanOrEqual(105); // 50 + 5 + 50 + expect(context).toContain("MATCH"); + }); + + test("no matches returns empty array", async () => { + const testFile = new TFile(); + testFile.basename = "Random Note"; + testFile.path = "Random Note.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "Some content"; + + const result = await request(server) + .post("/search/simple/?query=NonExistentTerm") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(0); + }); + + test("case insensitive search", async () => { + const testFile = new TFile(); + testFile.basename = "MASTER Plan"; + testFile.path = "MASTER Plan.md"; + + app.vault._markdownFiles = [testFile]; + app.vault._cachedRead = "master plan details"; + + const result = await request(server) + .post("/search/simple/?query=master") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(200); + + expect(result.body).toHaveLength(1); + expect(result.body[0].matches).toHaveLength(2); + + // Should match "MASTER" in filename (case-insensitive) + expect(result.body[0].matches[0].match.source).toBe("filename"); + expect(result.body[0].matches[0].match.start).toBe(0); + expect(result.body[0].matches[0].match.end).toBe(6); + + // Should match "master" in content + expect(result.body[0].matches[1].match.source).toBe("content"); + expect(result.body[0].matches[1].match.start).toBe(0); + expect(result.body[0].matches[1].match.end).toBe(6); + }); + + test("unauthorized", async () => { + await request(server) + .post("/search/simple/?query=test") + .expect(401); + }); + + test("missing query parameter", async () => { + await request(server) + .post("/search/simple/") + .set("Authorization", `Bearer ${API_KEY}`) + .expect(400); + }); + }); }); diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 106183b..ffe6e1e 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -262,10 +262,10 @@ export default class RequestHandler { certificateInfo: this.requestIsAuthenticated(req) && certificate ? { - validityDays: getCertificateValidityDays(certificate), - regenerateRecommended: - !getCertificateIsUptoStandards(certificate), - } + validityDays: getCertificateValidityDays(certificate), + regenerateRecommended: + !getCertificateIsUptoStandards(certificate), + } : undefined, apiExtensions: this.requestIsAuthenticated(req) ? this.apiExtensions.map(({ manifest }) => manifest) @@ -997,8 +997,8 @@ export default class RequestHandler { errorCode: ErrorCode.InvalidSearch, }); } - const contextLength: number = - parseInt(req.query.contextLength as string, 10) ?? 100; + const contextLengthRaw = parseInt(req.query.contextLength as string, 10); + const contextLength = Number.isNaN(contextLengthRaw) ? 100 : contextLengthRaw; let search: ReturnType; try { search = prepareSimpleSearch(query); @@ -1012,20 +1012,48 @@ export default class RequestHandler { for (const file of this.app.vault.getMarkdownFiles()) { const cachedContents = await this.app.vault.cachedRead(file); - const result = search(cachedContents); + + // Add the filename to the search text to include it in the search. + const filenamePrefix = file.basename + "\n\n"; + const result = search(filenamePrefix + cachedContents); + + // We added the filename to the search text with 2 newline characters. + // That causes the start and end position numbers to be wrong with an offset + // of the char length of the filename newline characters. + // This is fixed by subtracting the positionOffset from the start and end position. + const positionOffset = filenamePrefix.length; + if (result) { const contextMatches: SearchContext[] = []; for (const match of result.matches) { - contextMatches.push({ - match: { - start: match[0], - end: match[1], - }, - context: cachedContents.slice( - Math.max(match[0] - contextLength, 0), - match[1] + contextLength - ), - }); + // Check if the entire match is within the filename (including the newlines). + // We need to ensure both start and end positions are within the filename prefix. + if (match[0] < positionOffset && match[1] <= positionOffset) { + // When start position is between 0 and positionOffset and end position is <= positionOffset, + // that means the search term matched entirely within the filename prefix. + // Clamp the end position to the basename length to ensure it doesn't exceed the context. + contextMatches.push({ + match: { + start: match[0], + end: Math.min(match[1], file.basename.length), + source: "filename" + }, + context: file.basename, + }); + } else { + // Otherwise, the match was in the content + contextMatches.push({ + match: { + start: match[0] - positionOffset, + end: match[1] - positionOffset, + source: "content" + }, + context: cachedContents.slice( + Math.max(match[0] - contextLength, positionOffset), + match[1] + contextLength + ), + }); + } } results.push({ diff --git a/src/types.ts b/src/types.ts index f6e035d..863c44a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,7 @@ export interface SearchContext { match: { start: number; end: number; + source: string; }; context: string; } From 1e6aae15987b09ab99ff7e42a30a3551d6242f29 Mon Sep 17 00:00:00 2001 From: Ben Klein Date: Fri, 21 Nov 2025 16:38:51 +0100 Subject: [PATCH 2/3] fix: slice calculation in src/requestHandler.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/requestHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/requestHandler.ts b/src/requestHandler.ts index ffe6e1e..e156c77 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -1049,8 +1049,8 @@ export default class RequestHandler { source: "content" }, context: cachedContents.slice( - Math.max(match[0] - contextLength, positionOffset), - match[1] + contextLength + Math.max(match[0] - positionOffset - contextLength, 0), + match[1] - positionOffset + contextLength ), }); } From 7e6d10fa2dd0ff6fb60dce6a5fd83e753dc25fb2 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 21 Nov 2025 16:44:46 +0100 Subject: [PATCH 3/3] fix: tests --- src/requestHandler.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index cff5172..036e4ec 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -10,6 +10,8 @@ import { Command, HeadingCache, PluginManifest, + _prepareSimpleSearchMock, + SearchResult, } from "../mocks/obsidian"; describe("requestHandler", () => { @@ -804,6 +806,42 @@ describe("requestHandler", () => { }); describe("searchSimplePost", () => { + beforeEach(() => { + // Setup mock for prepareSimpleSearch + _prepareSimpleSearchMock.behavior = (query: string) => { + const queryLower = query.toLowerCase(); + const queryLength = query.length; + return (text: string) => { + const textLower = text.toLowerCase(); + const matches: [number, number][] = []; + let index = 0; + + // Find all matches (case-insensitive) + while ((index = textLower.indexOf(queryLower, index)) !== -1) { + matches.push([index, index + queryLength]); + index += 1; + } + + if (matches.length === 0) { + return null; + } + + // Calculate score based on number of matches + const score = matches.length; + + return { + score, + matches, + } as SearchResult; + }; + }; + }); + + afterEach(() => { + // Clean up mock + _prepareSimpleSearchMock.behavior = null; + }); + test("match at beginning of filename", async () => { const testFile = new TFile(); testFile.basename = "Master Plan";