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..251770b 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: string = "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"; @@ -177,5 +178,24 @@ export class SearchResult { export function prepareSimpleSearch( query: string ): (value: string) => null | SearchResult { - return null; + return (text: string) => { + const matches: [number, number][] = []; + const lowerQuery = query.toLowerCase(); + const lowerText = text.toLowerCase(); + + let index = 0; + while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) { + matches.push([index, index + query.length]); + index += query.length; + } + + if (matches.length === 0) { + return null; + } + + const result = new SearchResult(); + result.matches = matches; + result.score = matches.length * -10; + return result; + }; } 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..65f5381 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,44 @@ 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 headline to the search text to include it in the search. + const filenamePrefix = file.basename + "\n\n"; + const result = search(filenamePrefix + cachedContents); + + // We added the headline to the search text with 2 line breaks. + // That causes the start and end position numbers to be wrong with an offset + // of the char length of the headline line breaks. + // 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 - ), - }); + if (match[0] < positionOffset) { + // When start position is between 0 and positionOffset, that means the search term matched within the filename. + contextMatches.push({ + match: { + start: match[0], + end: match[1], + 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; }