mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			copilot/fi
			...
			fix/fix-eq
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8d88411fda | ||
|  | 8e227a6146 | ||
|  | b03cb1ce1b | ||
|  | fb0d971e48 | ||
|  | 4fa4112840 | ||
|  | 50f0b88eff | 
							
								
								
									
										6
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -116,6 +116,12 @@ jobs: | |||||||
|           - dockerfile: Dockerfile |           - dockerfile: Dockerfile | ||||||
|             platform: linux/arm64 |             platform: linux/arm64 | ||||||
|             image: ubuntu-24.04-arm |             image: ubuntu-24.04-arm | ||||||
|  |           - dockerfile: Dockerfile | ||||||
|  |             platform: linux/arm/v7 | ||||||
|  |             image: ubuntu-24.04-arm | ||||||
|  |           - dockerfile: Dockerfile | ||||||
|  |             platform: linux/arm/v8 | ||||||
|  |             image: ubuntu-24.04-arm | ||||||
|     runs-on: ${{ matrix.image }} |     runs-on: ${{ matrix.image }} | ||||||
|     needs: |     needs: | ||||||
|       - test_docker |       - test_docker | ||||||
|   | |||||||
| @@ -259,6 +259,7 @@ | |||||||
|     "delete_all_revisions": "删除此笔记的所有修订版本", |     "delete_all_revisions": "删除此笔记的所有修订版本", | ||||||
|     "delete_all_button": "删除所有修订版本", |     "delete_all_button": "删除所有修订版本", | ||||||
|     "help_title": "关于笔记修订版本的帮助", |     "help_title": "关于笔记修订版本的帮助", | ||||||
|  |     "revision_last_edited": "此修订版本上次编辑于 {{date}}", | ||||||
|     "confirm_delete_all": "您是否要删除此笔记的所有修订版本?", |     "confirm_delete_all": "您是否要删除此笔记的所有修订版本?", | ||||||
|     "no_revisions": "此笔记暂无修订版本...", |     "no_revisions": "此笔记暂无修订版本...", | ||||||
|     "restore_button": "恢复", |     "restore_button": "恢复", | ||||||
|   | |||||||
| @@ -260,6 +260,7 @@ | |||||||
|     "delete_all_revisions": "Lösche alle Revisionen dieser Notiz", |     "delete_all_revisions": "Lösche alle Revisionen dieser Notiz", | ||||||
|     "delete_all_button": "Alle Revisionen löschen", |     "delete_all_button": "Alle Revisionen löschen", | ||||||
|     "help_title": "Hilfe zu Notizrevisionen", |     "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?", |     "confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?", | ||||||
|     "no_revisions": "Für diese Notiz gibt es noch keine Revisionen...", |     "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.", |     "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_revisions": "Delete all revisions of this note", | ||||||
|     "delete_all_button": "Delete all revisions", |     "delete_all_button": "Delete all revisions", | ||||||
|     "help_title": "Help on Note 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?", |     "confirm_delete_all": "Do you want to delete all revisions of this note?", | ||||||
|     "no_revisions": "No revisions for this note yet...", |     "no_revisions": "No revisions for this note yet...", | ||||||
|     "restore_button": "Restore", |     "restore_button": "Restore", | ||||||
|   | |||||||
| @@ -259,6 +259,7 @@ | |||||||
|     "delete_all_revisions": "Eliminar todas las revisiones de esta nota", |     "delete_all_revisions": "Eliminar todas las revisiones de esta nota", | ||||||
|     "delete_all_button": "Eliminar todas las revisiones", |     "delete_all_button": "Eliminar todas las revisiones", | ||||||
|     "help_title": "Ayuda sobre revisiones de notas", |     "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?", |     "confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?", | ||||||
|     "no_revisions": "Aún no hay revisiones para esta nota...", |     "no_revisions": "Aún no hay revisiones para esta nota...", | ||||||
|     "restore_button": "Restaurar", |     "restore_button": "Restaurar", | ||||||
|   | |||||||
| @@ -260,6 +260,7 @@ | |||||||
|     "delete_all_revisions": "Supprimer toutes les versions de cette note", |     "delete_all_revisions": "Supprimer toutes les versions de cette note", | ||||||
|     "delete_all_button": "Supprimer toutes les versions", |     "delete_all_button": "Supprimer toutes les versions", | ||||||
|     "help_title": "Aide sur les versions de notes", |     "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 ?", |     "confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?", | ||||||
|     "no_revisions": "Aucune version pour cette note pour l'instant...", |     "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.", |     "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_revisions": "Elimina tutte le revisioni di questa nota", | ||||||
|     "delete_all_button": "Elimina tutte le revisioni", |     "delete_all_button": "Elimina tutte le revisioni", | ||||||
|     "help_title": "Aiuto sulle revisioni delle note", |     "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?", |     "confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?", | ||||||
|     "no_revisions": "Ancora nessuna revisione per questa nota...", |     "no_revisions": "Ancora nessuna revisione per questa nota...", | ||||||
|     "restore_button": "Ripristina", |     "restore_button": "Ripristina", | ||||||
|   | |||||||
| @@ -610,6 +610,7 @@ | |||||||
|     "delete_all_revisions": "このノートの変更履歴をすべて削除", |     "delete_all_revisions": "このノートの変更履歴をすべて削除", | ||||||
|     "delete_all_button": "変更履歴をすべて削除", |     "delete_all_button": "変更履歴をすべて削除", | ||||||
|     "help_title": "変更履歴のヘルプ", |     "help_title": "変更履歴のヘルプ", | ||||||
|  |     "revision_last_edited": "この変更は{{date}}に行われました", | ||||||
|     "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?", |     "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?", | ||||||
|     "no_revisions": "このノートに変更履歴はまだありません...", |     "no_revisions": "このノートに変更履歴はまだありません...", | ||||||
|     "restore_button": "復元", |     "restore_button": "復元", | ||||||
|   | |||||||
| @@ -912,6 +912,7 @@ | |||||||
|     "delete_all_revisions": "Usuń wszystkie wersje tej notatki", |     "delete_all_revisions": "Usuń wszystkie wersje tej notatki", | ||||||
|     "delete_all_button": "Usuń wszystkie wersje", |     "delete_all_button": "Usuń wszystkie wersje", | ||||||
|     "help_title": "Pomoc dotycząca wersji notatki", |     "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?", |     "confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?", | ||||||
|     "no_revisions": "Brak wersji dla tej notatki...", |     "no_revisions": "Brak wersji dla tej notatki...", | ||||||
|     "restore_button": "Przywróć", |     "restore_button": "Przywróć", | ||||||
|   | |||||||
| @@ -259,6 +259,7 @@ | |||||||
|     "delete_all_revisions": "Apagar todas as versões desta nota", |     "delete_all_revisions": "Apagar todas as versões desta nota", | ||||||
|     "delete_all_button": "Apagar todas as versões", |     "delete_all_button": "Apagar todas as versões", | ||||||
|     "help_title": "Ajuda sobre as versões da nota", |     "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?", |     "confirm_delete_all": "Quer apagar todas as versões desta nota?", | ||||||
|     "no_revisions": "Ainda não há versões para esta nota...", |     "no_revisions": "Ainda não há versões para esta nota...", | ||||||
|     "restore_button": "Recuperar", |     "restore_button": "Recuperar", | ||||||
|   | |||||||
| @@ -415,6 +415,7 @@ | |||||||
|     "delete_all_revisions": "Excluir todas as versões desta nota", |     "delete_all_revisions": "Excluir todas as versões desta nota", | ||||||
|     "delete_all_button": "Excluir todas as versões", |     "delete_all_button": "Excluir todas as versões", | ||||||
|     "help_title": "Ajuda sobre as versões da nota", |     "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?", |     "confirm_delete_all": "Você quer excluir todas as versões desta nota?", | ||||||
|     "no_revisions": "Ainda não há versões para esta nota...", |     "no_revisions": "Ainda não há versões para esta nota...", | ||||||
|     "restore_button": "Recuperar", |     "restore_button": "Recuperar", | ||||||
|   | |||||||
| @@ -1090,6 +1090,7 @@ | |||||||
|     "preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.", |     "preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.", | ||||||
|     "restore_button": "Restaurează", |     "restore_button": "Restaurează", | ||||||
|     "revision_deleted": "Revizia notiței a fost ștearsă.", |     "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ă.", |     "revision_restored": "Revizia notiței a fost restaurată.", | ||||||
|     "revisions_deleted": "Notița reviziei a fost ștearsă.", |     "revisions_deleted": "Notița reviziei a fost ștearsă.", | ||||||
|     "maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.", |     "maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.", | ||||||
|   | |||||||
| @@ -366,6 +366,7 @@ | |||||||
|     "delete_all_button": "Удалить все версии", |     "delete_all_button": "Удалить все версии", | ||||||
|     "help_title": "Помощь по версиям заметок", |     "help_title": "Помощь по версиям заметок", | ||||||
|     "confirm_delete_all": "Вы хотите удалить все версии этой заметки?", |     "confirm_delete_all": "Вы хотите удалить все версии этой заметки?", | ||||||
|  |     "revision_last_edited": "Эта версия последний раз редактировалась {{date}}", | ||||||
|     "confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.", |     "confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.", | ||||||
|     "confirm_delete": "Вы хотите удалить эту версию?", |     "confirm_delete": "Вы хотите удалить эту версию?", | ||||||
|     "revisions_deleted": "Версии заметки были удалены.", |     "revisions_deleted": "Версии заметки были удалены.", | ||||||
|   | |||||||
| @@ -256,6 +256,7 @@ | |||||||
|         "delete_all_revisions": "Obriši sve revizije ove beleške", |         "delete_all_revisions": "Obriši sve revizije ove beleške", | ||||||
|         "delete_all_button": "Obriši sve revizije", |         "delete_all_button": "Obriši sve revizije", | ||||||
|         "help_title": "Pomoć za Revizije beleški", |         "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?", |         "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?", | ||||||
|         "no_revisions": "Još uvek nema revizija za ovu belešku...", |         "no_revisions": "Još uvek nema revizija za ovu belešku...", | ||||||
|         "restore_button": "Vrati", |         "restore_button": "Vrati", | ||||||
|   | |||||||
| @@ -260,6 +260,7 @@ | |||||||
|     "delete_all_revisions": "刪除此筆記的所有歷史版本", |     "delete_all_revisions": "刪除此筆記的所有歷史版本", | ||||||
|     "delete_all_button": "刪除所有歷史版本", |     "delete_all_button": "刪除所有歷史版本", | ||||||
|     "help_title": "關於筆記歷史版本的說明", |     "help_title": "關於筆記歷史版本的說明", | ||||||
|  |     "revision_last_edited": "此歷史版本上次於 {{date}} 編輯", | ||||||
|     "confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?", |     "confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?", | ||||||
|     "no_revisions": "此筆記暫無歷史版本…", |     "no_revisions": "此筆記暫無歷史版本…", | ||||||
|     "confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。", |     "confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。", | ||||||
|   | |||||||
| @@ -309,6 +309,7 @@ | |||||||
|     "delete_all_revisions": "Видалити всі версії цієї нотатки", |     "delete_all_revisions": "Видалити всі версії цієї нотатки", | ||||||
|     "delete_all_button": "Видалити всі версії", |     "delete_all_button": "Видалити всі версії", | ||||||
|     "help_title": "Довідка щодо Версій нотаток", |     "help_title": "Довідка щодо Версій нотаток", | ||||||
|  |     "revision_last_edited": "Цю версію востаннє редагували {{date}}", | ||||||
|     "confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?", |     "confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?", | ||||||
|     "no_revisions": "Поки що немає версій цієї нотатки...", |     "no_revisions": "Поки що немає версій цієї нотатки...", | ||||||
|     "restore_button": "Відновити", |     "restore_button": "Відновити", | ||||||
|   | |||||||
| @@ -140,10 +140,11 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re | |||||||
|         <FormList onSelect={onSelect} fullHeight> |         <FormList onSelect={onSelect} fullHeight> | ||||||
|             {revisions.map((item) => |             {revisions.map((item) => | ||||||
|                 <FormListItem |                 <FormListItem | ||||||
|  |                     title={t("revisions.revision_last_edited", { date: item.dateLastEdited })} | ||||||
|                     value={item.revisionId} |                     value={item.revisionId} | ||||||
|                     active={currentRevision && item.revisionId === currentRevision.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> |                 </FormListItem> | ||||||
|             )} |             )} | ||||||
|         </FormList>); |         </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"); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
| @@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) { | |||||||
|                     AND (noteId NOT LIKE '_%') |                     AND (noteId NOT LIKE '_%') | ||||||
|             UNION ALL |             UNION ALL | ||||||
|                 SELECT noteId FROM revisions |                 SELECT noteId FROM revisions | ||||||
|                 WHERE revisions.dateCreated LIKE :date |                 WHERE revisions.dateLastEdited LIKE :date | ||||||
|         ) |         ) | ||||||
|         ORDER BY isDeleted |         ORDER BY isDeleted | ||||||
|         LIMIT 50`, |         LIMIT 50`, | ||||||
|   | |||||||
| @@ -10,6 +10,8 @@ import cls from "../../services/cls.js"; | |||||||
| import attributeFormatter from "../../services/attribute_formatter.js"; | import attributeFormatter from "../../services/attribute_formatter.js"; | ||||||
| import ValidationError from "../../errors/validation_error.js"; | import ValidationError from "../../errors/validation_error.js"; | ||||||
| import type SearchResult from "../../services/search/search_result.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 { | function searchFromNote(req: Request): SearchNoteResult { | ||||||
|     const note = becca.getNoteOrThrow(req.params.noteId); |     const note = becca.getNoteOrThrow(req.params.noteId); | ||||||
| @@ -49,13 +51,41 @@ function quickSearch(req: Request) { | |||||||
|     const searchContext = new SearchContext({ |     const searchContext = new SearchContext({ | ||||||
|         fastSearch: false, |         fastSearch: false, | ||||||
|         includeArchivedNotes: 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 |     // Execute search with our context | ||||||
|     const searchResults = searchService.searchNotesForAutocomplete(searchString, false); |     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[]; |     const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[]; | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|   | |||||||
| @@ -75,8 +75,16 @@ class NoteContentFulltextExp extends Expression { | |||||||
|             return inputNoteSet; |             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(); |         const resultNoteSet = new NoteSet(); | ||||||
|  |  | ||||||
|  |         // Search through notes with content | ||||||
|         for (const row of sql.iterateRows<SearchRow>(` |         for (const row of sql.iterateRows<SearchRow>(` | ||||||
|                 SELECT noteId, type, mime, content, isProtected |                 SELECT noteId, type, mime, content, isProtected | ||||||
|                 FROM notes JOIN blobs USING (blobId) |                 FROM notes JOIN blobs USING (blobId) | ||||||
| @@ -86,9 +94,82 @@ class NoteContentFulltextExp extends Expression { | |||||||
|             this.findInText(row, inputNoteSet, resultNoteSet); |             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; |         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) { |     findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { | ||||||
|         if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { |         if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { | ||||||
|             return; |             return; | ||||||
| @@ -123,9 +204,25 @@ class NoteContentFulltextExp extends Expression { | |||||||
|         if (this.tokens.length === 1) { |         if (this.tokens.length === 1) { | ||||||
|             const [token] = this.tokens; |             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 ( |             if ( | ||||||
|                 (this.operator === "=" && token === content) || |                 matches || | ||||||
|                 (this.operator === "!=" && token !== content) || |  | ||||||
|                 (this.operator === "*=" && content.endsWith(token)) || |                 (this.operator === "*=" && content.endsWith(token)) || | ||||||
|                 (this.operator === "=*" && content.startsWith(token)) || |                 (this.operator === "=*" && content.startsWith(token)) || | ||||||
|                 (this.operator === "*=*" && content.includes(token)) || |                 (this.operator === "*=*" && content.includes(token)) || | ||||||
| @@ -138,10 +235,26 @@ class NoteContentFulltextExp extends Expression { | |||||||
|         } else { |         } else { | ||||||
|             // Multi-token matching with fuzzy support and phrase proximity |             // Multi-token matching with fuzzy support and phrase proximity | ||||||
|             if (this.operator === "~=" || this.operator === "~*") { |             if (this.operator === "~=" || this.operator === "~*") { | ||||||
|  |                 // Fuzzy phrase matching | ||||||
|                 if (this.matchesWithFuzzy(content, noteId)) { |                 if (this.matchesWithFuzzy(content, noteId)) { | ||||||
|                     resultNoteSet.add(becca.notes[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 { |             } else { | ||||||
|  |                 // Other operators: check all tokens present (any order) | ||||||
|                 const nonMatchingToken = this.tokens.find( |                 const nonMatchingToken = this.tokens.find( | ||||||
|                     (token) => |                     (token) => | ||||||
|                         !this.tokenMatchesContent(token, content, noteId) |                         !this.tokenMatchesContent(token, content, noteId) | ||||||
|   | |||||||
| @@ -13,8 +13,41 @@ function getRegex(str: string) { | |||||||
| type Comparator<T> = (comparedValue: T) => (val: string) => boolean; | type Comparator<T> = (comparedValue: T) => (val: string) => boolean; | ||||||
|  |  | ||||||
| const stringComparators: Record<string, Comparator<string>> = { | const stringComparators: Record<string, Comparator<string>> = { | ||||||
|     "=": (comparedValue) => (val) => val === comparedValue, |     "=": (comparedValue) => (val) => { | ||||||
|     "!=": (comparedValue) => (val) => val !== comparedValue, |         // 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, |     ">=": (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) { |     if (!searchContext.fastSearch) { | ||||||
|         // For exact match with "=", we need different behavior |         // For exact match with "=", we need different behavior | ||||||
|         if (leadingOperator === "=" && tokens.length === 1) { |         if (leadingOperator === "=" && tokens.length >= 1) { | ||||||
|             // Exact match on title OR exact match on content |             // 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([ |             return new OrExp([ | ||||||
|                 new PropertyComparisonExp(searchContext, "title", "=", tokens[0]), |                 new PropertyComparisonExp(searchContext, "title", "=", titleSearchValue), | ||||||
|                 new NoteContentFulltextExp("=", { tokens, flatText: false }) |                 new NoteContentFulltextExp("=", { tokens, flatText: false }), | ||||||
|  |                 new NoteContentFulltextExp("=", { tokens, flatText: true }) | ||||||
|             ]); |             ]); | ||||||
|         } |         } | ||||||
|         return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { 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(); |         const searchContext = new SearchContext(); | ||||||
|  |  | ||||||
|         // Using leading = for exact title match |         // Using leading = for exact word match - should find notes containing the exact word "example" | ||||||
|         let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext); |         let searchResults = searchService.findResultsWithQuery("=example", searchContext); | ||||||
|         expect(searchResults.length).toEqual(1); |         expect(searchResults.length).toEqual(2); // "Example Note" and "Sample" (has label "example") | ||||||
|         expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy(); |         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); |         searchResults = searchService.findResultsWithQuery("example", searchContext); | ||||||
|         expect(searchResults.length).toEqual(3); |         expect(searchResults.length).toEqual(3); // All notes | ||||||
|  |  | ||||||
|         // = operator should not match partial words |         // = operator should not match partial words | ||||||
|         searchResults = searchService.findResultsWithQuery("=Example", searchContext); |         searchResults = searchService.findResultsWithQuery("=examples", searchContext); | ||||||
|         expect(searchResults.length).toEqual(0); |         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", () => { |     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 |         // If snippet contains linebreaks, limit to max 4 lines and override character limit | ||||||
|         const lines = snippet.split('\n'); |         const lines = snippet.split('\n'); | ||||||
|         if (lines.length > 4) { |         if (lines.length > 4) { | ||||||
|             snippet = lines.slice(0, 4).join('\n'); |             // 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 |             // Add ellipsis if we truncated lines | ||||||
|             snippet = snippet + "..."; |             snippet = snippet + "..."; | ||||||
|         } else if (lines.length > 1) { |         } else if (lines.length > 1) { | ||||||
|             // For multi-line snippets, just limit to 4 lines (keep existing snippet) |             // For multi-line snippets that are 4 or fewer lines, keep them as-is | ||||||
|             snippet = lines.slice(0, 4).join('\n'); |             // No need to truncate | ||||||
|             if (lines.length > 4) { |  | ||||||
|                 snippet = snippet + "..."; |  | ||||||
|             } |  | ||||||
|         } else { |         } else { | ||||||
|             // Single line content - apply original word boundary logic |             // Single line content - apply original word boundary logic | ||||||
|             // Try to start/end at word boundaries |             // Try to start/end at word boundaries | ||||||
| @@ -770,5 +789,8 @@ export default { | |||||||
|     searchNotesForAutocomplete, |     searchNotesForAutocomplete, | ||||||
|     findResultsWithQuery, |     findResultsWithQuery, | ||||||
|     findFirstNoteWithQuery, |     findFirstNoteWithQuery, | ||||||
|     searchNotes |     searchNotes, | ||||||
|  |     extractContentSnippet, | ||||||
|  |     extractAttributeSnippet, | ||||||
|  |     highlightSearchResults | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ export interface DeleteNotesPreview { | |||||||
| export interface RevisionItem { | export interface RevisionItem { | ||||||
|     noteId: string; |     noteId: string; | ||||||
|     revisionId?: string; |     revisionId?: string; | ||||||
|     dateCreated?: string; |     dateLastEdited?: string; | ||||||
|     contentLength?: number; |     contentLength?: number; | ||||||
|     type: NoteType; |     type: NoteType; | ||||||
|     title: string; |     title: string; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user