-
Notifications
You must be signed in to change notification settings - Fork 180
Include the title of the file in the simple search #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Hi @coddingtonbear, what do you think? Could we merge this by time? Do you need anything from my side? :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR enhances the search functionality to include file names in the search scope and distinguish between filename and content matches. The implementation prepends file basenames to the search text and handles match position offsets accordingly.
- Added a
sourcefield toSearchContextto distinguish between filename and content matches - Modified search logic to prepend file basename to searchable text and adjust match positions
- Fixed several formatting issues (whitespace, spacing) in the mock files
Reviewed Changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/types.ts | Added source field to SearchContext interface to distinguish match origin |
| src/requestHandler.ts | Enhanced search to include filenames and differentiate match sources; fixed contextLength parsing bug |
| mocks/obsidian.ts | Fixed spacing/formatting inconsistencies; added basename property to TFile mock |
| docs/openapi.yaml | Updated API documentation with descriptions and new source field |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
coddingtonbear
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple more comments -- once you've updated the mocks and (I assume) your tests, let me know!
mocks/obsidian.ts
Outdated
| query: string | ||
| ): (value: string) => null | SearchResult { | ||
| return null; | ||
| return (text: string) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a quick note about this file, @phortx: since it lives under mocks/, it’s meant purely as a mock—basically something lightweight so the type checker and tests have a stand-in. We shouldn’t put any real implementation logic here.
Instead, the idea is that your tests can control what this mock returns in different scenarios. If you take a peek at some of the other mock functions in mocks/obsidian.ts, you’ll see how they’re set up to let tests override behavior as needed.
So to make this consistent: this function shouldn’t implement any real behavior. It should either do nothing or return whatever value your test explicitly configures, so that you can verify how requestHandler.ts responds to those different return values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I resetted the mock implementation and added a way for the tests to override the behavior.
Co-authored-by: Copilot <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review for a chance to win a $100 gift card. Take the survey.
| 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"; | ||
| 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); | ||
| }); | ||
| }); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for the edge case where a search term spans the boundary between the filename and content. For example, if the filename is "Master" and the content starts with "Plan", searching for "ter\n\nPl" would create a match that spans both. This edge case should be tested to ensure the implementation handles it correctly (or explicitly rejects it).
| 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; |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The contextLength parameter is not validated for negative or unreasonably large values. A negative value would cause Math.max(match[0] - positionOffset - contextLength, 0) to potentially return a larger start position than expected, and the slice end position match[1] - positionOffset + contextLength could be negative. Consider adding validation to ensure contextLength >= 0 or clamping it to a reasonable range.
| const contextLength = Number.isNaN(contextLengthRaw) ? 100 : contextLengthRaw; | |
| // Clamp contextLength to [0, 1000], default to 100 if invalid | |
| const contextLength = | |
| Number.isNaN(contextLengthRaw) || contextLengthRaw < 0 | |
| ? 100 | |
| : Math.min(contextLengthRaw, 1000); |
| 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." |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The description "Start character position of the match within the file. Starts with 0." is now ambiguous. When source is "filename", the position is within the filename (basename), not the file content. When source is "content", it's within the file content. Consider updating the description to clarify this: "Start character position of the match. When source is 'filename', this is the position within the filename. When source is 'content', this is the position within the file content. Starts with 0."
| description: "Start character position of the match within the file. Starts with 0." | |
| description: "Start character position of the match. When source is 'filename', this is the position within the filename. When source is 'content', this is the position within the file content. Starts with 0." |
| }); | ||
| // 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) { |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition doesn't handle matches that span across the boundary between filename and content. If a search term starts in the filename but ends in the content (e.g., searching for "Master\n\nThe" when filename is "Master"), the match would have match[0] < positionOffset but match[1] > positionOffset. This case falls into the else branch and would produce incorrect results because:
- It would be marked as
source: "content"when it actually spans both - The position offset subtraction would make
startnegative
Consider adding a condition to handle boundary-spanning matches, or explicitly filter them out if they shouldn't be supported.
| match: { | ||
| start: number; | ||
| end: number; | ||
| source: string; |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The source field is typed as string, but based on the implementation it should only ever be "filename" or "content". Consider using a union type "filename" | "content" for better type safety and to make the API contract clearer.
| source: string; | |
| source: "filename" | "content"; |
| type: "number" | ||
| description: "Start character position of the match within the file. Starts with 0." | ||
| source: | ||
| type: "string" |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The source field description says "'filename' or 'content'" but the type is just string. Consider using an enum to be more precise in the OpenAPI spec. Example:
source:
type: "string"
enum: ["filename", "content"]
description: "Where the search term matched"| type: "string" | |
| type: "string" | |
| enum: ["filename", "content"] |
| properties: | ||
| end: | ||
| type: "number" | ||
| description: "End character position of the match within the file. Starts with 0." |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The description "End character position of the match within the file. Starts with 0." is now ambiguous. When source is "filename", the position is within the filename (basename), not the file content. When source is "content", it's within the file content. Consider updating the description to clarify this: "End character position of the match. When source is 'filename', this is the position within the filename. When source is 'content', this is the position within the file content. Starts with 0."
| description: "End character position of the match within the file. Starts with 0." | |
| description: "End character position of the match. When source is 'filename', this is the position within the filename. When source is 'content', this is the position within the file content. Starts with 0." |
| if (_prepareSimpleSearchMock.behavior) { | ||
| return _prepareSimpleSearchMock.behavior(query); | ||
| } | ||
| return null; |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The return type is (value: string) => null | SearchResult, which means the function should return another function. However, return null; returns null directly, not a function. This should be return () => null; to match the return type and maintain consistency with the mock behavior pattern.
| return null; | |
| return () => null; |
| contextMatches.push({ | ||
| match: { | ||
| start: match[0], | ||
| end: Math.min(match[1], file.basename.length), | ||
| source: "filename" | ||
| }, | ||
| context: file.basename, | ||
| }); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If file.basename is empty, filenamePrefix would be "\n\n". A match could occur in these newline characters and be classified as a "filename" match with context: "". While this is technically correct (searching for newlines), it might lead to confusing results. Consider filtering out matches that occur entirely within the newlines but not in the actual basename text (i.e., when match[0] >= file.basename.length).
| contextMatches.push({ | |
| match: { | |
| start: match[0], | |
| end: Math.min(match[1], file.basename.length), | |
| source: "filename" | |
| }, | |
| context: file.basename, | |
| }); | |
| // If the basename is empty and the match occurs only in the newlines, skip this match. | |
| if (!(file.basename.length === 0 && match[0] >= file.basename.length)) { | |
| contextMatches.push({ | |
| match: { | |
| start: match[0], | |
| end: Math.min(match[1], file.basename.length), | |
| source: "filename" | |
| }, | |
| context: file.basename, | |
| }); | |
| } |
... also some smaller formatting fixes that the IDE did.
See #172