mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			tree-activ
			...
			fix/fix-eq
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8d88411fda | ||
|  | 8e227a6146 | ||
|  | b03cb1ce1b | ||
|  | fb0d971e48 | ||
|  | 4fa4112840 | ||
|  | 50f0b88eff | 
							
								
								
									
										4
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -116,10 +116,10 @@ jobs: | ||||
|           - dockerfile: Dockerfile | ||||
|             platform: linux/arm64 | ||||
|             image: ubuntu-24.04-arm | ||||
|           - dockerfile: Dockerfile.legacy | ||||
|           - dockerfile: Dockerfile | ||||
|             platform: linux/arm/v7 | ||||
|             image: ubuntu-24.04-arm | ||||
|           - dockerfile: Dockerfile.legacy | ||||
|           - dockerfile: Dockerfile | ||||
|             platform: linux/arm/v8 | ||||
|             image: ubuntu-24.04-arm | ||||
|     runs-on: ${{ matrix.image }} | ||||
|   | ||||
| @@ -417,7 +417,7 @@ export default class FNote { | ||||
|         return notePaths; | ||||
|     } | ||||
|  | ||||
|     getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] { | ||||
|     getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] { | ||||
|         const isHoistedRoot = hoistedNoteId === "root"; | ||||
|  | ||||
|         const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({ | ||||
| @@ -428,23 +428,7 @@ export default class FNote { | ||||
|             isHidden: path.includes("_hidden") | ||||
|         })); | ||||
|  | ||||
|         // Calculate the length of the prefix match between two arrays | ||||
|         const prefixMatchLength = (path: string[], target: string[]) => { | ||||
|             const diffIndex = path.findIndex((seg, i) => seg !== target[i]); | ||||
|             return diffIndex === -1 ? Math.min(path.length, target.length) : diffIndex; | ||||
|         }; | ||||
|  | ||||
|         notePaths.sort((a, b) => { | ||||
|             if (activeNotePath) { | ||||
|                 const activeSegments = activeNotePath.split('/'); | ||||
|                 const aOverlap = prefixMatchLength(a.notePath, activeSegments); | ||||
|                 const bOverlap = prefixMatchLength(b.notePath, activeSegments); | ||||
|                 // Paths with more matching prefix segments are prioritized | ||||
|                 // when the match count is equal, other criteria are used for sorting | ||||
|                 if (bOverlap !== aOverlap) { | ||||
|                     return bOverlap - aOverlap; | ||||
|                 } | ||||
|             } | ||||
|             if (a.isInHoistedSubTree !== b.isInHoistedSubTree) { | ||||
|                 return a.isInHoistedSubTree ? -1 : 1; | ||||
|             } else if (a.isArchived !== b.isArchived) { | ||||
| @@ -465,11 +449,10 @@ export default class FNote { | ||||
|      * Returns the note path considered to be the "best" | ||||
|      * | ||||
|      * @param {string} [hoistedNoteId='root'] | ||||
|      * @param {string|null} [activeNotePath=null] | ||||
|      * @return {string[]} array of noteIds constituting the particular note path | ||||
|      */ | ||||
|     getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) { | ||||
|         return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath; | ||||
|     getBestNotePath(hoistedNoteId = "root") { | ||||
|         return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -26,12 +26,21 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|     } | ||||
|  | ||||
|     const path = notePath.split("/").reverse(); | ||||
|  | ||||
|     if (!path.includes("root")) { | ||||
|         path.push("root"); | ||||
|     } | ||||
|  | ||||
|     const effectivePathSegments: string[] = []; | ||||
|     let childNoteId: string | null = null; | ||||
|     let i = 0; | ||||
|  | ||||
|     for (let i = 0; i < path.length; i++) { | ||||
|         const parentNoteId = path[i]; | ||||
|     while (true) { | ||||
|         if (i >= path.length) { | ||||
|             break; | ||||
|         } | ||||
|  | ||||
|         const parentNoteId = path[i++]; | ||||
|  | ||||
|         if (childNoteId !== null) { | ||||
|             const child = await froca.getNote(childNoteId, !logErrors); | ||||
| @@ -56,7 +65,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (!parents.some(p => p.noteId === parentNoteId) || (i === path.length - 1 && parentNoteId !== 'root')) { | ||||
|             if (!parents.some((p) => p.noteId === parentNoteId)) { | ||||
|                 if (logErrors) { | ||||
|                     const parent = froca.getNoteFromCache(parentNoteId); | ||||
|  | ||||
| @@ -68,8 +77,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 const activeNotePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|                 const bestNotePath = child.getBestNotePath(hoistedNoteId, activeNotePath); | ||||
|                 const bestNotePath = child.getBestNotePath(hoistedNoteId); | ||||
|  | ||||
|                 if (bestNotePath) { | ||||
|                     const pathToRoot = bestNotePath.reverse().slice(1); | ||||
| @@ -100,9 +108,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|         if (!note) { | ||||
|             throw new Error(`Unable to find note: ${notePath}.`); | ||||
|         } | ||||
|  | ||||
|         const activeNotePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|         const bestNotePath = note.getBestNotePath(hoistedNoteId, activeNotePath); | ||||
|         const bestNotePath = note.getBestNotePath(hoistedNoteId); | ||||
|  | ||||
|         if (!bestNotePath) { | ||||
|             throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`); | ||||
|   | ||||
| @@ -259,6 +259,7 @@ | ||||
|     "delete_all_revisions": "删除此笔记的所有修订版本", | ||||
|     "delete_all_button": "删除所有修订版本", | ||||
|     "help_title": "关于笔记修订版本的帮助", | ||||
|     "revision_last_edited": "此修订版本上次编辑于 {{date}}", | ||||
|     "confirm_delete_all": "您是否要删除此笔记的所有修订版本?", | ||||
|     "no_revisions": "此笔记暂无修订版本...", | ||||
|     "restore_button": "恢复", | ||||
|   | ||||
| @@ -260,6 +260,7 @@ | ||||
|     "delete_all_revisions": "Lösche alle Revisionen dieser Notiz", | ||||
|     "delete_all_button": "Alle Revisionen löschen", | ||||
|     "help_title": "Hilfe zu Notizrevisionen", | ||||
|     "revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet", | ||||
|     "confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?", | ||||
|     "no_revisions": "Für diese Notiz gibt es noch keine Revisionen...", | ||||
|     "confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.", | ||||
|   | ||||
| @@ -261,6 +261,7 @@ | ||||
|     "delete_all_revisions": "Delete all revisions of this note", | ||||
|     "delete_all_button": "Delete all revisions", | ||||
|     "help_title": "Help on Note Revisions", | ||||
|     "revision_last_edited": "This revision was last edited on {{date}}", | ||||
|     "confirm_delete_all": "Do you want to delete all revisions of this note?", | ||||
|     "no_revisions": "No revisions for this note yet...", | ||||
|     "restore_button": "Restore", | ||||
|   | ||||
| @@ -259,6 +259,7 @@ | ||||
|     "delete_all_revisions": "Eliminar todas las revisiones de esta nota", | ||||
|     "delete_all_button": "Eliminar todas las revisiones", | ||||
|     "help_title": "Ayuda sobre revisiones de notas", | ||||
|     "revision_last_edited": "Esta revisión se editó por última vez en {{date}}", | ||||
|     "confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?", | ||||
|     "no_revisions": "Aún no hay revisiones para esta nota...", | ||||
|     "restore_button": "Restaurar", | ||||
|   | ||||
| @@ -260,6 +260,7 @@ | ||||
|     "delete_all_revisions": "Supprimer toutes les versions de cette note", | ||||
|     "delete_all_button": "Supprimer toutes les versions", | ||||
|     "help_title": "Aide sur les versions de notes", | ||||
|     "revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}", | ||||
|     "confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?", | ||||
|     "no_revisions": "Aucune version pour cette note pour l'instant...", | ||||
|     "confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.", | ||||
|   | ||||
| @@ -867,6 +867,7 @@ | ||||
|     "delete_all_revisions": "Elimina tutte le revisioni di questa nota", | ||||
|     "delete_all_button": "Elimina tutte le revisioni", | ||||
|     "help_title": "Aiuto sulle revisioni delle note", | ||||
|     "revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}", | ||||
|     "confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?", | ||||
|     "no_revisions": "Ancora nessuna revisione per questa nota...", | ||||
|     "restore_button": "Ripristina", | ||||
|   | ||||
| @@ -610,6 +610,7 @@ | ||||
|     "delete_all_revisions": "このノートの変更履歴をすべて削除", | ||||
|     "delete_all_button": "変更履歴をすべて削除", | ||||
|     "help_title": "変更履歴のヘルプ", | ||||
|     "revision_last_edited": "この変更は{{date}}に行われました", | ||||
|     "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?", | ||||
|     "no_revisions": "このノートに変更履歴はまだありません...", | ||||
|     "restore_button": "復元", | ||||
|   | ||||
| @@ -912,6 +912,7 @@ | ||||
|     "delete_all_revisions": "Usuń wszystkie wersje tej notatki", | ||||
|     "delete_all_button": "Usuń wszystkie wersje", | ||||
|     "help_title": "Pomoc dotycząca wersji notatki", | ||||
|     "revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}", | ||||
|     "confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?", | ||||
|     "no_revisions": "Brak wersji dla tej notatki...", | ||||
|     "restore_button": "Przywróć", | ||||
|   | ||||
| @@ -259,6 +259,7 @@ | ||||
|     "delete_all_revisions": "Apagar todas as versões desta nota", | ||||
|     "delete_all_button": "Apagar todas as versões", | ||||
|     "help_title": "Ajuda sobre as versões da nota", | ||||
|     "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}", | ||||
|     "confirm_delete_all": "Quer apagar todas as versões desta nota?", | ||||
|     "no_revisions": "Ainda não há versões para esta nota...", | ||||
|     "restore_button": "Recuperar", | ||||
|   | ||||
| @@ -415,6 +415,7 @@ | ||||
|     "delete_all_revisions": "Excluir todas as versões desta nota", | ||||
|     "delete_all_button": "Excluir todas as versões", | ||||
|     "help_title": "Ajuda sobre as versões da nota", | ||||
|     "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}", | ||||
|     "confirm_delete_all": "Você quer excluir todas as versões desta nota?", | ||||
|     "no_revisions": "Ainda não há versões para esta nota...", | ||||
|     "restore_button": "Recuperar", | ||||
|   | ||||
| @@ -1090,6 +1090,7 @@ | ||||
|     "preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.", | ||||
|     "restore_button": "Restaurează", | ||||
|     "revision_deleted": "Revizia notiței a fost ștearsă.", | ||||
|     "revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}", | ||||
|     "revision_restored": "Revizia notiței a fost restaurată.", | ||||
|     "revisions_deleted": "Notița reviziei a fost ștearsă.", | ||||
|     "maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.", | ||||
|   | ||||
| @@ -366,6 +366,7 @@ | ||||
|     "delete_all_button": "Удалить все версии", | ||||
|     "help_title": "Помощь по версиям заметок", | ||||
|     "confirm_delete_all": "Вы хотите удалить все версии этой заметки?", | ||||
|     "revision_last_edited": "Эта версия последний раз редактировалась {{date}}", | ||||
|     "confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.", | ||||
|     "confirm_delete": "Вы хотите удалить эту версию?", | ||||
|     "revisions_deleted": "Версии заметки были удалены.", | ||||
|   | ||||
| @@ -256,6 +256,7 @@ | ||||
|         "delete_all_revisions": "Obriši sve revizije ove beleške", | ||||
|         "delete_all_button": "Obriši sve revizije", | ||||
|         "help_title": "Pomoć za Revizije beleški", | ||||
|         "revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}", | ||||
|         "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?", | ||||
|         "no_revisions": "Još uvek nema revizija za ovu belešku...", | ||||
|         "restore_button": "Vrati", | ||||
|   | ||||
| @@ -260,6 +260,7 @@ | ||||
|     "delete_all_revisions": "刪除此筆記的所有歷史版本", | ||||
|     "delete_all_button": "刪除所有歷史版本", | ||||
|     "help_title": "關於筆記歷史版本的說明", | ||||
|     "revision_last_edited": "此歷史版本上次於 {{date}} 編輯", | ||||
|     "confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?", | ||||
|     "no_revisions": "此筆記暫無歷史版本…", | ||||
|     "confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。", | ||||
|   | ||||
| @@ -309,6 +309,7 @@ | ||||
|     "delete_all_revisions": "Видалити всі версії цієї нотатки", | ||||
|     "delete_all_button": "Видалити всі версії", | ||||
|     "help_title": "Довідка щодо Версій нотаток", | ||||
|     "revision_last_edited": "Цю версію востаннє редагували {{date}}", | ||||
|     "confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?", | ||||
|     "no_revisions": "Поки що немає версій цієї нотатки...", | ||||
|     "restore_button": "Відновити", | ||||
|   | ||||
| @@ -140,10 +140,11 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re | ||||
|         <FormList onSelect={onSelect} fullHeight> | ||||
|             {revisions.map((item) => | ||||
|                 <FormListItem | ||||
|                     title={t("revisions.revision_last_edited", { date: item.dateLastEdited })} | ||||
|                     value={item.revisionId} | ||||
|                     active={currentRevision && item.revisionId === currentRevision.revisionId} | ||||
|                 > | ||||
|                     {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)}) | ||||
|                     {item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)}) | ||||
|                 </FormListItem> | ||||
|             )} | ||||
|         </FormList>); | ||||
|   | ||||
							
								
								
									
										502
									
								
								apps/server-e2e/src/exact_search.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										502
									
								
								apps/server-e2e/src/exact_search.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,502 @@ | ||||
| import { test, expect } from "@playwright/test"; | ||||
| import App from "./support/app"; | ||||
|  | ||||
| const BASE_URL = "http://127.0.0.1:8082"; | ||||
|  | ||||
| /** | ||||
|  * E2E tests for exact search functionality using the leading "=" operator. | ||||
|  * | ||||
|  * These tests validate the GitHub issue: | ||||
|  * - Searching for "pagio" returns many false positives (e.g., "page", "pages") | ||||
|  * - Searching for "=pagio" should return ONLY exact matches for "pagio" | ||||
|  */ | ||||
|  | ||||
| test.describe("Exact Search with Leading = Operator", () => { | ||||
|     let csrfToken: string; | ||||
|     let createdNoteIds: string[] = []; | ||||
|  | ||||
|     test.beforeEach(async ({ page, context }) => { | ||||
|         const app = new App(page, context); | ||||
|         await app.goto(); | ||||
|  | ||||
|         // Get CSRF token | ||||
|         csrfToken = await page.evaluate(() => { | ||||
|             return (window as any).glob.csrfToken; | ||||
|         }); | ||||
|  | ||||
|         expect(csrfToken).toBeTruthy(); | ||||
|  | ||||
|         // Create test notes with specific content patterns | ||||
|         // Note 1: Contains exactly "pagio" in title | ||||
|         const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Test Note with pagio", | ||||
|                 content: "This note contains the word pagio in the content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note1.ok()).toBeTruthy(); | ||||
|         const note1Data = await note1.json(); | ||||
|         createdNoteIds.push(note1Data.note.noteId); | ||||
|  | ||||
|         // Note 2: Contains "page" (not exact match) | ||||
|         const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Test Note with page", | ||||
|                 content: "This note contains the word page in the content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note2.ok()).toBeTruthy(); | ||||
|         const note2Data = await note2.json(); | ||||
|         createdNoteIds.push(note2Data.note.noteId); | ||||
|  | ||||
|         // Note 3: Contains "pages" (plural, not exact match) | ||||
|         const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Test Note with pages", | ||||
|                 content: "This note contains the word pages in the content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note3.ok()).toBeTruthy(); | ||||
|         const note3Data = await note3.json(); | ||||
|         createdNoteIds.push(note3Data.note.noteId); | ||||
|  | ||||
|         // Note 4: Contains "homepage" (contains "page", not exact match) | ||||
|         const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Homepage Note", | ||||
|                 content: "This note is about homepage content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note4.ok()).toBeTruthy(); | ||||
|         const note4Data = await note4.json(); | ||||
|         createdNoteIds.push(note4Data.note.noteId); | ||||
|  | ||||
|         // Note 5: Another note with exact "pagio" in content | ||||
|         const note5 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Another pagio Note", | ||||
|                 content: "This is another note with pagio content for testing exact matches.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note5.ok()).toBeTruthy(); | ||||
|         const note5Data = await note5.json(); | ||||
|         createdNoteIds.push(note5Data.note.noteId); | ||||
|  | ||||
|         // Note 6: Contains "pagio" in title only | ||||
|         const note6 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "pagio", | ||||
|                 content: "This note has pagio as the title.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note6.ok()).toBeTruthy(); | ||||
|         const note6Data = await note6.json(); | ||||
|         createdNoteIds.push(note6Data.note.noteId); | ||||
|  | ||||
|         // Wait a bit for indexing | ||||
|         await page.waitForTimeout(500); | ||||
|     }); | ||||
|  | ||||
|     test.afterEach(async ({ page }) => { | ||||
|         // Clean up created notes | ||||
|         for (const noteId of createdNoteIds) { | ||||
|             try { | ||||
|                 const taskId = `cleanup-${Math.random().toString(36).substr(2, 9)}`; | ||||
|                 await page.request.delete(`${BASE_URL}/api/notes/${noteId}?taskId=${taskId}&last=true`, { | ||||
|                     headers: { "x-csrf-token": csrfToken } | ||||
|                 }); | ||||
|             } catch (e) { | ||||
|                 console.error(`Failed to delete note ${noteId}:`, e); | ||||
|             } | ||||
|         } | ||||
|         createdNoteIds = []; | ||||
|     }); | ||||
|  | ||||
|     test("Quick search without = operator returns all partial matches", async ({ page }) => { | ||||
|         // Test the /quick-search endpoint without the = operator | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/pag`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         // Should return multiple notes including "page", "pages", "homepage" | ||||
|         expect(data.searchResultNoteIds).toBeDefined(); | ||||
|         expect(data.searchResults).toBeDefined(); | ||||
|  | ||||
|         // Filter to only our test notes | ||||
|         const testResults = data.searchResults.filter((result: any) => | ||||
|             result.noteTitle.includes("page") || | ||||
|             result.noteTitle.includes("pagio") || | ||||
|             result.noteTitle.includes("Homepage") | ||||
|         ); | ||||
|  | ||||
|         // Should find at least "page", "pages", "homepage", and "pagio" notes | ||||
|         expect(testResults.length).toBeGreaterThanOrEqual(4); | ||||
|  | ||||
|         console.log("Quick search 'pag' found:", testResults.length, "matching notes"); | ||||
|         console.log("Note titles:", testResults.map((r: any) => r.noteTitle)); | ||||
|     }); | ||||
|  | ||||
|     test("Quick search with = operator returns only exact matches", async ({ page }) => { | ||||
|         // Test the /quick-search endpoint WITH the = operator | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/=pagio`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         // Should return only notes with exact "pagio" match | ||||
|         expect(data.searchResultNoteIds).toBeDefined(); | ||||
|         expect(data.searchResults).toBeDefined(); | ||||
|  | ||||
|         // Filter to only our test notes | ||||
|         const testResults = data.searchResults.filter((result: any) => | ||||
|             createdNoteIds.includes(result.notePath.split("/").pop() || "") | ||||
|         ); | ||||
|  | ||||
|         console.log("Quick search '=pagio' found:", testResults.length, "matching notes"); | ||||
|         console.log("Note titles:", testResults.map((r: any) => r.noteTitle)); | ||||
|  | ||||
|         // Should find exactly 3 notes: "Test Note with pagio", "Another pagio Note", "pagio" | ||||
|         expect(testResults.length).toBe(3); | ||||
|  | ||||
|         // Verify that none of the results contain "page" or "pages" (only "pagio") | ||||
|         for (const result of testResults) { | ||||
|             const title = result.noteTitle.toLowerCase(); | ||||
|             const hasPageNotPagio = (title.includes("page") && !title.includes("pagio")); | ||||
|             expect(hasPageNotPagio).toBe(false); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     test("Full search API without = operator returns partial matches", async ({ page }) => { | ||||
|         // Test the /search endpoint without the = operator | ||||
|         const response = await page.request.get(`${BASE_URL}/api/search/pag`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         // Should return an array of note IDs | ||||
|         expect(Array.isArray(data)).toBe(true); | ||||
|  | ||||
|         // Filter to only our test notes | ||||
|         const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id)); | ||||
|  | ||||
|         console.log("Full search 'pag' found:", testNoteIds.length, "matching notes from our test set"); | ||||
|  | ||||
|         // Should find at least 4 notes | ||||
|         expect(testNoteIds.length).toBeGreaterThanOrEqual(4); | ||||
|     }); | ||||
|  | ||||
|     test("Full search API with = operator returns only exact matches", async ({ page }) => { | ||||
|         // Test the /search endpoint WITH the = operator | ||||
|         const response = await page.request.get(`${BASE_URL}/api/search/=pagio`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         // Should return an array of note IDs | ||||
|         expect(Array.isArray(data)).toBe(true); | ||||
|  | ||||
|         // Filter to only our test notes | ||||
|         const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id)); | ||||
|  | ||||
|         console.log("Full search '=pagio' found:", testNoteIds.length, "matching notes from our test set"); | ||||
|  | ||||
|         // Should find exactly 3 notes with exact "pagio" match | ||||
|         expect(testNoteIds.length).toBe(3); | ||||
|     }); | ||||
|  | ||||
|     test("Exact search operator works with content search", async ({ page }) => { | ||||
|         // Create a note with "test" in title but different content | ||||
|         const noteWithTest = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Testing Content", | ||||
|                 content: "This note contains the exact word test in content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteWithTest.ok()).toBeTruthy(); | ||||
|         const noteWithTestData = await noteWithTest.json(); | ||||
|         const testNoteId = noteWithTestData.note.noteId; | ||||
|         createdNoteIds.push(testNoteId); | ||||
|  | ||||
|         // Create a note with "testing" (not exact match) | ||||
|         const noteWithTesting = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Testing More", | ||||
|                 content: "This note has testing in the content.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteWithTesting.ok()).toBeTruthy(); | ||||
|         const noteWithTestingData = await noteWithTesting.json(); | ||||
|         createdNoteIds.push(noteWithTestingData.note.noteId); | ||||
|  | ||||
|         await page.waitForTimeout(500); | ||||
|  | ||||
|         // Search with exact operator | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/=test`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         const ourTestNotes = data.searchResults.filter((result: any) => { | ||||
|             const noteId = result.notePath.split("/").pop(); | ||||
|             return noteId === testNoteId || noteId === noteWithTestingData.note.noteId; | ||||
|         }); | ||||
|  | ||||
|         console.log("Exact search '=test' found our test notes:", ourTestNotes.length); | ||||
|         console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); | ||||
|  | ||||
|         // Should find the note with exact "test" match, but not "testing" | ||||
|         // Note: This test may fail if the implementation doesn't properly handle exact matching in content | ||||
|         expect(ourTestNotes.length).toBeGreaterThan(0); | ||||
|     }); | ||||
|  | ||||
|     test("Exact search is case-insensitive", async ({ page }) => { | ||||
|         // Create notes with different case variations | ||||
|         const noteUpper = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "EXACT MATCH", | ||||
|                 content: "This note has EXACT in uppercase.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteUpper.ok()).toBeTruthy(); | ||||
|         const noteUpperData = await noteUpper.json(); | ||||
|         createdNoteIds.push(noteUpperData.note.noteId); | ||||
|  | ||||
|         const noteLower = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "exact match", | ||||
|                 content: "This note has exact in lowercase.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteLower.ok()).toBeTruthy(); | ||||
|         const noteLowerData = await noteLower.json(); | ||||
|         createdNoteIds.push(noteLowerData.note.noteId); | ||||
|  | ||||
|         await page.waitForTimeout(500); | ||||
|  | ||||
|         // Search with exact operator in lowercase | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/=exact`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         const ourTestNotes = data.searchResults.filter((result: any) => { | ||||
|             const noteId = result.notePath.split("/").pop(); | ||||
|             return noteId === noteUpperData.note.noteId || noteId === noteLowerData.note.noteId; | ||||
|         }); | ||||
|  | ||||
|         console.log("Case-insensitive exact search found:", ourTestNotes.length, "notes"); | ||||
|  | ||||
|         // Should find both uppercase and lowercase versions | ||||
|         expect(ourTestNotes.length).toBe(2); | ||||
|     }); | ||||
|  | ||||
|     test("Exact phrase matching with multi-word searches", async ({ page }) => { | ||||
|         // Create notes with various phrase patterns | ||||
|         const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "exact phrase", | ||||
|                 content: "This note contains the exact phrase.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note1.ok()).toBeTruthy(); | ||||
|         const note1Data = await note1.json(); | ||||
|         createdNoteIds.push(note1Data.note.noteId); | ||||
|  | ||||
|         const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "exact phrase match", | ||||
|                 content: "This note has exact phrase followed by more words.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note2.ok()).toBeTruthy(); | ||||
|         const note2Data = await note2.json(); | ||||
|         createdNoteIds.push(note2Data.note.noteId); | ||||
|  | ||||
|         const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "phrase exact", | ||||
|                 content: "This note has the words in reverse order.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note3.ok()).toBeTruthy(); | ||||
|         const note3Data = await note3.json(); | ||||
|         createdNoteIds.push(note3Data.note.noteId); | ||||
|  | ||||
|         const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "this exact and that phrase", | ||||
|                 content: "Words are separated but both present.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(note4.ok()).toBeTruthy(); | ||||
|         const note4Data = await note4.json(); | ||||
|         createdNoteIds.push(note4Data.note.noteId); | ||||
|  | ||||
|         await page.waitForTimeout(500); | ||||
|  | ||||
|         // Search for exact phrase "exact phrase" | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/='exact phrase'`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         const ourTestNotes = data.searchResults.filter((result: any) => { | ||||
|             const noteId = result.notePath.split("/").pop(); | ||||
|             return [note1Data.note.noteId, note2Data.note.noteId, note3Data.note.noteId, note4Data.note.noteId].includes(noteId || ""); | ||||
|         }); | ||||
|  | ||||
|         console.log("Exact phrase search '=\"exact phrase\"' found:", ourTestNotes.length, "notes"); | ||||
|         console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); | ||||
|  | ||||
|         // Should find only notes 1 and 2 (consecutive "exact phrase") | ||||
|         // Should NOT find note 3 (reversed order) or note 4 (words separated) | ||||
|         expect(ourTestNotes.length).toBe(2); | ||||
|  | ||||
|         const foundTitles = ourTestNotes.map((r: any) => r.noteTitle); | ||||
|         expect(foundTitles).toContain("exact phrase"); | ||||
|         expect(foundTitles).toContain("exact phrase match"); | ||||
|         expect(foundTitles).not.toContain("phrase exact"); | ||||
|         expect(foundTitles).not.toContain("this exact and that phrase"); | ||||
|     }); | ||||
|  | ||||
|     test("Exact phrase matching respects word order", async ({ page }) => { | ||||
|         // Create notes to test word order sensitivity | ||||
|         const noteForward = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Testing Order", | ||||
|                 content: "This is a test sentence for verification.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteForward.ok()).toBeTruthy(); | ||||
|         const noteForwardData = await noteForward.json(); | ||||
|         createdNoteIds.push(noteForwardData.note.noteId); | ||||
|  | ||||
|         const noteReverse = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Order Testing", | ||||
|                 content: "A sentence test is this for verification.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteReverse.ok()).toBeTruthy(); | ||||
|         const noteReverseData = await noteReverse.json(); | ||||
|         createdNoteIds.push(noteReverseData.note.noteId); | ||||
|  | ||||
|         await page.waitForTimeout(500); | ||||
|  | ||||
|         // Search for exact phrase "test sentence" | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/='test sentence'`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         const ourTestNotes = data.searchResults.filter((result: any) => { | ||||
|             const noteId = result.notePath.split("/").pop(); | ||||
|             return noteId === noteForwardData.note.noteId || noteId === noteReverseData.note.noteId; | ||||
|         }); | ||||
|  | ||||
|         console.log("Exact phrase search '=\"test sentence\"' found:", ourTestNotes.length, "notes"); | ||||
|         console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); | ||||
|  | ||||
|         // Should find only the forward order note | ||||
|         expect(ourTestNotes.length).toBe(1); | ||||
|         expect(ourTestNotes[0].noteTitle).toBe("Testing Order"); | ||||
|     }); | ||||
|  | ||||
|     test("Multi-word exact search without quotes", async ({ page }) => { | ||||
|         // Test that multi-word search with = but without quotes also does exact phrase matching | ||||
|         const notePhrase = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Quick Test Note", | ||||
|                 content: "A simple note for multi word testing.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(notePhrase.ok()).toBeTruthy(); | ||||
|         const notePhraseData = await notePhrase.json(); | ||||
|         createdNoteIds.push(notePhraseData.note.noteId); | ||||
|  | ||||
|         const noteScattered = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { | ||||
|             headers: { "x-csrf-token": csrfToken }, | ||||
|             data: { | ||||
|                 title: "Word Multi Testing", | ||||
|                 content: "Words are multi scattered in this testing example.", | ||||
|                 type: "text" | ||||
|             } | ||||
|         }); | ||||
|         expect(noteScattered.ok()).toBeTruthy(); | ||||
|         const noteScatteredData = await noteScattered.json(); | ||||
|         createdNoteIds.push(noteScatteredData.note.noteId); | ||||
|  | ||||
|         await page.waitForTimeout(500); | ||||
|  | ||||
|         // Search for "=multi word" without quotes (parser tokenizes as two words) | ||||
|         const response = await page.request.get(`${BASE_URL}/api/quick-search/=multi word`, { | ||||
|             headers: { "x-csrf-token": csrfToken } | ||||
|         }); | ||||
|  | ||||
|         expect(response.ok()).toBeTruthy(); | ||||
|         const data = await response.json(); | ||||
|  | ||||
|         const ourTestNotes = data.searchResults.filter((result: any) => { | ||||
|             const noteId = result.notePath.split("/").pop(); | ||||
|             return noteId === notePhraseData.note.noteId || noteId === noteScatteredData.note.noteId; | ||||
|         }); | ||||
|  | ||||
|         console.log("Multi-word exact search '=multi word' found:", ourTestNotes.length, "notes"); | ||||
|         console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); | ||||
|  | ||||
|         // Should find only the note with consecutive "multi word" phrase | ||||
|         expect(ourTestNotes.length).toBe(1); | ||||
|         expect(ourTestNotes[0].noteTitle).toBe("Quick Test Note"); | ||||
|     }); | ||||
| }); | ||||
| @@ -1,28 +0,0 @@ | ||||
| FROM node:22.21.0-bullseye-slim AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| WORKDIR /usr/src/app/build | ||||
| COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:24.10.0-bullseye-slim | ||||
| # Install only runtime dependencies | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends \ | ||||
|     gosu && \ | ||||
|     rm -rf \ | ||||
|     /var/lib/apt/lists/* \ | ||||
|     /var/cache/apt/* | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
| COPY ./dist /usr/src/app | ||||
| RUN rm -rf /usr/src/app/node_modules/better-sqlite3 | ||||
| COPY --from=builder /usr/src/app/node_modules/better-sqlite3 /usr/src/app/node_modules/better-sqlite3 | ||||
| COPY ./start-docker.sh /usr/src/app | ||||
|  | ||||
| # Configure container | ||||
| EXPOSE 8080 | ||||
| CMD [ "sh", "./start-docker.sh" ] | ||||
| HEALTHCHECK --start-period=10s CMD exec gosu node node /usr/src/app/docker_healthcheck.cjs | ||||
| @@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) { | ||||
|                     AND (noteId NOT LIKE '_%') | ||||
|             UNION ALL | ||||
|                 SELECT noteId FROM revisions | ||||
|                 WHERE revisions.dateCreated LIKE :date | ||||
|                 WHERE revisions.dateLastEdited LIKE :date | ||||
|         ) | ||||
|         ORDER BY isDeleted | ||||
|         LIMIT 50`, | ||||
|   | ||||
| @@ -10,6 +10,8 @@ import cls from "../../services/cls.js"; | ||||
| import attributeFormatter from "../../services/attribute_formatter.js"; | ||||
| import ValidationError from "../../errors/validation_error.js"; | ||||
| import type SearchResult from "../../services/search/search_result.js"; | ||||
| import hoistedNoteService from "../../services/hoisted_note.js"; | ||||
| import beccaService from "../../becca/becca_service.js"; | ||||
|  | ||||
| function searchFromNote(req: Request): SearchNoteResult { | ||||
|     const note = becca.getNoteOrThrow(req.params.noteId); | ||||
| @@ -49,13 +51,41 @@ function quickSearch(req: Request) { | ||||
|     const searchContext = new SearchContext({ | ||||
|         fastSearch: false, | ||||
|         includeArchivedNotes: false, | ||||
|         fuzzyAttributeSearch: false | ||||
|         includeHiddenNotes: true, | ||||
|         fuzzyAttributeSearch: true, | ||||
|         ignoreInternalAttributes: true, | ||||
|         ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId() | ||||
|     }); | ||||
|  | ||||
|     // Use the same highlighting logic as autocomplete for consistency | ||||
|     const searchResults = searchService.searchNotesForAutocomplete(searchString, false); | ||||
|     // Execute search with our context | ||||
|     const allSearchResults = searchService.findResultsWithQuery(searchString, searchContext); | ||||
|     const trimmed = allSearchResults.slice(0, 200); | ||||
|  | ||||
|     // Extract snippets using highlightedTokens from our context | ||||
|     for (const result of trimmed) { | ||||
|         result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens); | ||||
|         result.attributeSnippet = searchService.extractAttributeSnippet(result.noteId, searchContext.highlightedTokens); | ||||
|     } | ||||
|  | ||||
|     // Highlight the results | ||||
|     searchService.highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); | ||||
|  | ||||
|     // Map to API format | ||||
|     const searchResults = trimmed.map((result) => { | ||||
|         const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); | ||||
|         return { | ||||
|             notePath: result.notePath, | ||||
|             noteTitle: title, | ||||
|             notePathTitle: result.notePathTitle, | ||||
|             highlightedNotePathTitle: result.highlightedNotePathTitle, | ||||
|             contentSnippet: result.contentSnippet, | ||||
|             highlightedContentSnippet: result.highlightedContentSnippet, | ||||
|             attributeSnippet: result.attributeSnippet, | ||||
|             highlightedAttributeSnippet: result.highlightedAttributeSnippet, | ||||
|             icon: icon | ||||
|         }; | ||||
|     }); | ||||
|  | ||||
|     // Extract note IDs for backward compatibility | ||||
|     const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[]; | ||||
|  | ||||
|     return { | ||||
|   | ||||
| @@ -75,8 +75,16 @@ class NoteContentFulltextExp extends Expression { | ||||
|             return inputNoteSet; | ||||
|         } | ||||
|  | ||||
|         // Add tokens to highlightedTokens so snippet extraction knows what to look for | ||||
|         for (const token of this.tokens) { | ||||
|             if (!searchContext.highlightedTokens.includes(token)) { | ||||
|                 searchContext.highlightedTokens.push(token); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         const resultNoteSet = new NoteSet(); | ||||
|  | ||||
|         // Search through notes with content | ||||
|         for (const row of sql.iterateRows<SearchRow>(` | ||||
|                 SELECT noteId, type, mime, content, isProtected | ||||
|                 FROM notes JOIN blobs USING (blobId) | ||||
| @@ -86,9 +94,82 @@ class NoteContentFulltextExp extends Expression { | ||||
|             this.findInText(row, inputNoteSet, resultNoteSet); | ||||
|         } | ||||
|  | ||||
|         // For exact match with flatText, also search notes WITHOUT content (they may have matching attributes) | ||||
|         if (this.flatText && (this.operator === "=" || this.operator === "!=")) { | ||||
|             for (const noteId of inputNoteSet.noteIdSet) { | ||||
|                 // Skip if already found or doesn't exist | ||||
|                 if (resultNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 const note = becca.notes[noteId]; | ||||
|                 const flatText = note.getFlatText(); | ||||
|  | ||||
|                 // For flatText, only check attribute values (format: #name=value or ~name=value) | ||||
|                 // Don't match against noteId, type, mime, or title which are also in flatText | ||||
|                 let matches = false; | ||||
|                 const phrase = this.tokens.join(" "); | ||||
|                 const normalizedPhrase = normalizeSearchText(phrase); | ||||
|                 const normalizedFlatText = normalizeSearchText(flatText); | ||||
|  | ||||
|                 // Check if =phrase appears in flatText (indicates attribute value match) | ||||
|                 matches = normalizedFlatText.includes(`=${normalizedPhrase}`); | ||||
|  | ||||
|                 if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) { | ||||
|                     resultNoteSet.add(note); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return resultNoteSet; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if content contains the exact word (with word boundaries) or exact phrase | ||||
|      * This is case-insensitive since content and token are already normalized | ||||
|      */ | ||||
|     private containsExactWord(token: string, content: string): boolean { | ||||
|         // Normalize both for case-insensitive comparison | ||||
|         const normalizedToken = normalizeSearchText(token); | ||||
|         const normalizedContent = normalizeSearchText(content); | ||||
|  | ||||
|         // If token contains spaces, it's a multi-word phrase from quotes | ||||
|         // Check for substring match (consecutive phrase) | ||||
|         if (normalizedToken.includes(' ')) { | ||||
|             return normalizedContent.includes(normalizedToken); | ||||
|         } | ||||
|  | ||||
|         // For single words, split content into words and check for exact match | ||||
|         const words = normalizedContent.split(/\s+/); | ||||
|         return words.some(word => word === normalizedToken); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if content contains the exact phrase (consecutive words in order) | ||||
|      * This is case-insensitive since content and tokens are already normalized | ||||
|      */ | ||||
|     private containsExactPhrase(tokens: string[], content: string, checkFlatTextAttributes: boolean = false): boolean { | ||||
|         const normalizedTokens = tokens.map(t => normalizeSearchText(t)); | ||||
|         const normalizedContent = normalizeSearchText(content); | ||||
|  | ||||
|         // Join tokens with single space to form the phrase | ||||
|         const phrase = normalizedTokens.join(" "); | ||||
|  | ||||
|         // Check if the phrase appears as a substring (consecutive words) | ||||
|         if (normalizedContent.includes(phrase)) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         // For flatText, also check if the phrase appears in attribute values | ||||
|         // Attributes in flatText appear as "#name=value" or "~name=value" | ||||
|         // So we need to check for "=phrase" to match attribute values | ||||
|         if (checkFlatTextAttributes && normalizedContent.includes(`=${phrase}`)) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { | ||||
|         if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { | ||||
|             return; | ||||
| @@ -123,9 +204,25 @@ class NoteContentFulltextExp extends Expression { | ||||
|         if (this.tokens.length === 1) { | ||||
|             const [token] = this.tokens; | ||||
|  | ||||
|             let matches = false; | ||||
|             if (this.operator === "=") { | ||||
|                 matches = this.containsExactWord(token, content); | ||||
|                 // Also check flatText if enabled (includes attributes) | ||||
|                 if (!matches && this.flatText) { | ||||
|                     const flatText = becca.notes[noteId].getFlatText(); | ||||
|                     matches = this.containsExactPhrase([token], flatText, true); | ||||
|                 } | ||||
|             } else if (this.operator === "!=") { | ||||
|                 matches = !this.containsExactWord(token, content); | ||||
|                 // For negation, check flatText too | ||||
|                 if (matches && this.flatText) { | ||||
|                     const flatText = becca.notes[noteId].getFlatText(); | ||||
|                     matches = !this.containsExactPhrase([token], flatText, true); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|                 (this.operator === "=" && token === content) || | ||||
|                 (this.operator === "!=" && token !== content) || | ||||
|                 matches || | ||||
|                 (this.operator === "*=" && content.endsWith(token)) || | ||||
|                 (this.operator === "=*" && content.startsWith(token)) || | ||||
|                 (this.operator === "*=*" && content.includes(token)) || | ||||
| @@ -138,10 +235,26 @@ class NoteContentFulltextExp extends Expression { | ||||
|         } else { | ||||
|             // Multi-token matching with fuzzy support and phrase proximity | ||||
|             if (this.operator === "~=" || this.operator === "~*") { | ||||
|                 // Fuzzy phrase matching | ||||
|                 if (this.matchesWithFuzzy(content, noteId)) { | ||||
|                     resultNoteSet.add(becca.notes[noteId]); | ||||
|                 } | ||||
|             } else if (this.operator === "=" || this.operator === "!=") { | ||||
|                 // Exact phrase matching for = and != | ||||
|                 let matches = this.containsExactPhrase(this.tokens, content, false); | ||||
|  | ||||
|                 // Also check flatText if enabled (includes attributes) | ||||
|                 if (!matches && this.flatText) { | ||||
|                     const flatText = becca.notes[noteId].getFlatText(); | ||||
|                     matches = this.containsExactPhrase(this.tokens, flatText, true); | ||||
|                 } | ||||
|  | ||||
|                 if ((this.operator === "=" && matches) || | ||||
|                     (this.operator === "!=" && !matches)) { | ||||
|                     resultNoteSet.add(becca.notes[noteId]); | ||||
|                 } | ||||
|             } else { | ||||
|                 // Other operators: check all tokens present (any order) | ||||
|                 const nonMatchingToken = this.tokens.find( | ||||
|                     (token) => | ||||
|                         !this.tokenMatchesContent(token, content, noteId) | ||||
|   | ||||
| @@ -13,8 +13,41 @@ function getRegex(str: string) { | ||||
| type Comparator<T> = (comparedValue: T) => (val: string) => boolean; | ||||
|  | ||||
| const stringComparators: Record<string, Comparator<string>> = { | ||||
|     "=": (comparedValue) => (val) => val === comparedValue, | ||||
|     "!=": (comparedValue) => (val) => val !== comparedValue, | ||||
|     "=": (comparedValue) => (val) => { | ||||
|         // For the = operator, check if the value contains the exact word or phrase | ||||
|         // This is case-insensitive | ||||
|         if (!val) return false; | ||||
|  | ||||
|         const normalizedVal = normalizeSearchText(val); | ||||
|         const normalizedCompared = normalizeSearchText(comparedValue); | ||||
|  | ||||
|         // If comparedValue has spaces, it's a multi-word phrase | ||||
|         // Check for substring match (consecutive phrase) | ||||
|         if (normalizedCompared.includes(" ")) { | ||||
|             return normalizedVal.includes(normalizedCompared); | ||||
|         } | ||||
|  | ||||
|         // For single word, split into words and check for exact word match | ||||
|         const words = normalizedVal.split(/\s+/); | ||||
|         return words.some(word => word === normalizedCompared); | ||||
|     }, | ||||
|     "!=": (comparedValue) => (val) => { | ||||
|         // Negation of exact word/phrase match | ||||
|         if (!val) return true; | ||||
|  | ||||
|         const normalizedVal = normalizeSearchText(val); | ||||
|         const normalizedCompared = normalizeSearchText(comparedValue); | ||||
|  | ||||
|         // If comparedValue has spaces, it's a multi-word phrase | ||||
|         // Check for substring match (consecutive phrase) and negate | ||||
|         if (normalizedCompared.includes(" ")) { | ||||
|             return !normalizedVal.includes(normalizedCompared); | ||||
|         } | ||||
|  | ||||
|         // For single word, split into words and check for exact word match, then negate | ||||
|         const words = normalizedVal.split(/\s+/); | ||||
|         return !words.some(word => word === normalizedCompared); | ||||
|     }, | ||||
|     ">": (comparedValue) => (val) => val > comparedValue, | ||||
|     ">=": (comparedValue) => (val) => val >= comparedValue, | ||||
|     "<": (comparedValue) => (val) => val < comparedValue, | ||||
|   | ||||
| @@ -38,11 +38,14 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leading | ||||
|  | ||||
|     if (!searchContext.fastSearch) { | ||||
|         // For exact match with "=", we need different behavior | ||||
|         if (leadingOperator === "=" && tokens.length === 1) { | ||||
|             // Exact match on title OR exact match on content | ||||
|         if (leadingOperator === "=" && tokens.length >= 1) { | ||||
|             // Exact match on title OR exact match on content OR exact match in flat text (includes attributes) | ||||
|             // For multi-word, join tokens with space to form exact phrase | ||||
|             const titleSearchValue = tokens.join(" "); | ||||
|             return new OrExp([ | ||||
|                 new PropertyComparisonExp(searchContext, "title", "=", tokens[0]), | ||||
|                 new NoteContentFulltextExp("=", { tokens, flatText: false }) | ||||
|                 new PropertyComparisonExp(searchContext, "title", "=", titleSearchValue), | ||||
|                 new NoteContentFulltextExp("=", { tokens, flatText: false }), | ||||
|                 new NoteContentFulltextExp("=", { tokens, flatText: true }) | ||||
|             ]); | ||||
|         } | ||||
|         return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]); | ||||
|   | ||||
| @@ -242,18 +242,149 @@ describe("Search", () => { | ||||
|  | ||||
|         const searchContext = new SearchContext(); | ||||
|  | ||||
|         // Using leading = for exact title match | ||||
|         let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         // Using leading = for exact word match - should find notes containing the exact word "example" | ||||
|         let searchResults = searchService.findResultsWithQuery("=example", searchContext); | ||||
|         expect(searchResults.length).toEqual(2); // "Example Note" and "Sample" (has label "example") | ||||
|         expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "Sample")).toBeTruthy(); | ||||
|  | ||||
|         // Without =, it should find all notes containing "example" | ||||
|         // Without =, it should find all notes containing "example" (substring match) | ||||
|         searchResults = searchService.findResultsWithQuery("example", searchContext); | ||||
|         expect(searchResults.length).toEqual(3); | ||||
|         expect(searchResults.length).toEqual(3); // All notes | ||||
|  | ||||
|         // = operator should not match partial words | ||||
|         searchResults = searchService.findResultsWithQuery("=Example", searchContext); | ||||
|         expect(searchResults.length).toEqual(0); | ||||
|         searchResults = searchService.findResultsWithQuery("=examples", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); // Only "Examples of Usage" | ||||
|         expect(findNoteByTitle(searchResults, "Examples of Usage")).toBeTruthy(); | ||||
|     }); | ||||
|  | ||||
|     it("leading = operator for exact match - comprehensive title tests", () => { | ||||
|         // Create notes with varying titles to test exact vs contains matching | ||||
|         rootNote | ||||
|             .child(note("testing")) | ||||
|             .child(note("testing123")) | ||||
|             .child(note("My testing notes")) | ||||
|             .child(note("123testing")) | ||||
|             .child(note("test")); | ||||
|  | ||||
|         const searchContext = new SearchContext(); | ||||
|  | ||||
|         // Test 1: Exact word match with leading = should find notes containing the exact word "testing" | ||||
|         let searchResults = searchService.findResultsWithQuery("=testing", searchContext); | ||||
|         expect(searchResults.length).toEqual(2); // "testing" and "My testing notes" (word boundary) | ||||
|         expect(findNoteByTitle(searchResults, "testing")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "My testing notes")).toBeTruthy(); | ||||
|  | ||||
|         // Test 2: Without =, it should find all notes containing "testing" (substring contains behavior) | ||||
|         searchResults = searchService.findResultsWithQuery("testing", searchContext); | ||||
|         expect(searchResults.length).toEqual(4); // All notes with "testing" substring | ||||
|  | ||||
|         // Test 3: Exact match should only find the exact composite word | ||||
|         searchResults = searchService.findResultsWithQuery("=testing123", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "testing123")).toBeTruthy(); | ||||
|  | ||||
|         // Test 4: Exact match should only find the exact composite word | ||||
|         searchResults = searchService.findResultsWithQuery("=123testing", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "123testing")).toBeTruthy(); | ||||
|  | ||||
|         // Test 5: Verify that "test" doesn't match "testing" with exact search | ||||
|         searchResults = searchService.findResultsWithQuery("=test", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "test")).toBeTruthy(); | ||||
|     }); | ||||
|  | ||||
|     it("leading = operator with quoted phrases", () => { | ||||
|         rootNote | ||||
|             .child(note("exact phrase")) | ||||
|             .child(note("exact phrase match")) | ||||
|             .child(note("this exact phrase here")) | ||||
|             .child(note("phrase exact")); | ||||
|  | ||||
|         const searchContext = new SearchContext(); | ||||
|  | ||||
|         // Test 1: With = and quotes, treat as exact phrase match (consecutive words in order) | ||||
|         let searchResults = searchService.findResultsWithQuery("='exact phrase'", searchContext); | ||||
|         // Should match only notes containing the exact phrase "exact phrase" | ||||
|         expect(searchResults.length).toEqual(3); // Only notes with consecutive "exact phrase" | ||||
|         expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy(); | ||||
|  | ||||
|         // Test 2: Without =, quoted phrase should find substring/contains matches | ||||
|         searchResults = searchService.findResultsWithQuery("'exact phrase'", searchContext); | ||||
|         expect(searchResults.length).toEqual(3); // All notes containing the phrase substring | ||||
|         expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy(); | ||||
|         expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy(); | ||||
|  | ||||
|         // Test 3: Verify word order matters with exact phrase matching | ||||
|         searchResults = searchService.findResultsWithQuery("='phrase exact'", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); // Only "phrase exact" matches | ||||
|         expect(findNoteByTitle(searchResults, "phrase exact")).toBeTruthy(); | ||||
|     }); | ||||
|  | ||||
|     it("leading = operator case sensitivity", () => { | ||||
|         rootNote | ||||
|             .child(note("TESTING")) | ||||
|             .child(note("testing")) | ||||
|             .child(note("Testing")) | ||||
|             .child(note("TeStiNg")); | ||||
|  | ||||
|         const searchContext = new SearchContext(); | ||||
|  | ||||
|         // Exact match should be case-insensitive (based on lex.ts line 4: str.toLowerCase()) | ||||
|         let searchResults = searchService.findResultsWithQuery("=testing", searchContext); | ||||
|         expect(searchResults.length).toEqual(4); // All variants of "testing" | ||||
|  | ||||
|         searchResults = searchService.findResultsWithQuery("=TESTING", searchContext); | ||||
|         expect(searchResults.length).toEqual(4); // All variants | ||||
|  | ||||
|         searchResults = searchService.findResultsWithQuery("=Testing", searchContext); | ||||
|         expect(searchResults.length).toEqual(4); // All variants | ||||
|  | ||||
|         searchResults = searchService.findResultsWithQuery("=TeStiNg", searchContext); | ||||
|         expect(searchResults.length).toEqual(4); // All variants | ||||
|     }); | ||||
|  | ||||
|     it("leading = operator with special characters", () => { | ||||
|         rootNote | ||||
|             .child(note("test-note")) | ||||
|             .child(note("test_note")) | ||||
|             .child(note("test.note")) | ||||
|             .child(note("test note")) | ||||
|             .child(note("testnote")); | ||||
|  | ||||
|         const searchContext = new SearchContext(); | ||||
|  | ||||
|         // Each exact match should only find its specific variant (compound words are treated as single words) | ||||
|         let searchResults = searchService.findResultsWithQuery("=test-note", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "test-note")).toBeTruthy(); | ||||
|  | ||||
|         searchResults = searchService.findResultsWithQuery("=test_note", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "test_note")).toBeTruthy(); | ||||
|  | ||||
|         searchResults = searchService.findResultsWithQuery("=test.note", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); | ||||
|         expect(findNoteByTitle(searchResults, "test.note")).toBeTruthy(); | ||||
|  | ||||
|         // For phrases with spaces, use quotes to keep them together | ||||
|         // With exact phrase matching, this finds notes with the consecutive phrase | ||||
|         searchResults = searchService.findResultsWithQuery("='test note'", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase | ||||
|         expect(findNoteByTitle(searchResults, "test note")).toBeTruthy(); | ||||
|  | ||||
|         // Without quotes, "test note" is tokenized as two separate tokens | ||||
|         // and will be treated as an exact phrase search with = operator | ||||
|         searchResults = searchService.findResultsWithQuery("=test note", searchContext); | ||||
|         expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase | ||||
|  | ||||
|         // Without =, should find all matches containing "test" substring | ||||
|         searchResults = searchService.findResultsWithQuery("test", searchContext); | ||||
|         expect(searchResults.length).toEqual(5); | ||||
|     }); | ||||
|  | ||||
|     it("fuzzy attribute search", () => { | ||||
|   | ||||
| @@ -504,15 +504,34 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength | ||||
|         // If snippet contains linebreaks, limit to max 4 lines and override character limit | ||||
|         const lines = snippet.split('\n'); | ||||
|         if (lines.length > 4) { | ||||
|             // Find which lines contain the search tokens to ensure they're included | ||||
|             const normalizedLines = lines.map(line => normalizeString(line.toLowerCase())); | ||||
|             const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase())); | ||||
|  | ||||
|             // Find the first line that contains a search token | ||||
|             let firstMatchLine = -1; | ||||
|             for (let i = 0; i < normalizedLines.length; i++) { | ||||
|                 if (normalizedTokens.some(token => normalizedLines[i].includes(token))) { | ||||
|                     firstMatchLine = i; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (firstMatchLine !== -1) { | ||||
|                 // Center the 4-line window around the first match | ||||
|                 // Try to show 1 line before and 2 lines after the match | ||||
|                 const startLine = Math.max(0, firstMatchLine - 1); | ||||
|                 const endLine = Math.min(lines.length, startLine + 4); | ||||
|                 snippet = lines.slice(startLine, endLine).join('\n'); | ||||
|             } else { | ||||
|                 // No match found in lines (shouldn't happen), just take first 4 | ||||
|                 snippet = lines.slice(0, 4).join('\n'); | ||||
|             } | ||||
|             // Add ellipsis if we truncated lines | ||||
|             snippet = snippet + "..."; | ||||
|         } else if (lines.length > 1) { | ||||
|             // For multi-line snippets, just limit to 4 lines (keep existing snippet) | ||||
|             snippet = lines.slice(0, 4).join('\n'); | ||||
|             if (lines.length > 4) { | ||||
|                 snippet = snippet + "..."; | ||||
|             } | ||||
|             // For multi-line snippets that are 4 or fewer lines, keep them as-is | ||||
|             // No need to truncate | ||||
|         } else { | ||||
|             // Single line content - apply original word boundary logic | ||||
|             // Try to start/end at word boundaries | ||||
| @@ -770,5 +789,8 @@ export default { | ||||
|     searchNotesForAutocomplete, | ||||
|     findResultsWithQuery, | ||||
|     findFirstNoteWithQuery, | ||||
|     searchNotes | ||||
|     searchNotes, | ||||
|     extractContentSnippet, | ||||
|     extractAttributeSnippet, | ||||
|     highlightSearchResults | ||||
| }; | ||||
|   | ||||
| @@ -29,7 +29,7 @@ export interface DeleteNotesPreview { | ||||
| export interface RevisionItem { | ||||
|     noteId: string; | ||||
|     revisionId?: string; | ||||
|     dateCreated?: string; | ||||
|     dateLastEdited?: string; | ||||
|     contentLength?: number; | ||||
|     type: NoteType; | ||||
|     title: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user