mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Fuzzy search support
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SearchService } from '../../services/SearchService';
|
||||
import type { SearchQuery } from '@open-archiver/types';
|
||||
import { MatchingStrategies } from 'meilisearch';
|
||||
|
||||
export class SearchController {
|
||||
private searchService: SearchService;
|
||||
@@ -11,7 +11,7 @@ export class SearchController {
|
||||
|
||||
public search = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { keywords, page, limit } = req.query;
|
||||
const { keywords, page, limit, matchingStrategy } = req.query;
|
||||
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: 'Keywords are required' });
|
||||
@@ -21,7 +21,8 @@ export class SearchController {
|
||||
const results = await this.searchService.searchEmails({
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies
|
||||
});
|
||||
|
||||
res.status(200).json(results);
|
||||
|
||||
@@ -34,15 +34,17 @@ export class SearchService {
|
||||
}
|
||||
|
||||
public async searchEmails(dto: SearchQuery): Promise<SearchResult> {
|
||||
const { query, filters, page = 1, limit = 10 } = dto;
|
||||
const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto;
|
||||
console.log('matchingStrategy ', matchingStrategy);
|
||||
const index = await this.getIndex<EmailDocument>('emails');
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
attributesToHighlight: ["*"],
|
||||
attributesToHighlight: ['*'],
|
||||
showMatchesPosition: true,
|
||||
sort: ['timestamp:desc']
|
||||
sort: ['timestamp:desc'],
|
||||
matchingStrategy
|
||||
};
|
||||
|
||||
if (filters) {
|
||||
|
||||
@@ -3,28 +3,40 @@ import { api } from '$lib/server/api';
|
||||
import type { SearchResult } from '@open-archiver/types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
async function performSearch(keywords: string, page: number, event: RequestEvent) {
|
||||
import type { MatchingStrategy } from '@open-archiver/types';
|
||||
|
||||
async function performSearch(
|
||||
keywords: string,
|
||||
page: number,
|
||||
matchingStrategy: MatchingStrategy,
|
||||
event: RequestEvent
|
||||
) {
|
||||
if (!keywords) {
|
||||
return { searchResult: null, keywords: '', page: 1 };
|
||||
return { searchResult: null, keywords: '', page: 1, matchingStrategy: 'last' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api(`/search?keywords=${keywords}&page=${page}&limit=10`, event, {
|
||||
method: 'GET'
|
||||
});
|
||||
const response = await api(
|
||||
`/search?keywords=${keywords}&page=${page}&limit=10&matchingStrategy=${matchingStrategy}`,
|
||||
event,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return { searchResult: null, keywords, page, error: error.message };
|
||||
return { searchResult: null, keywords, page, matchingStrategy, error: error.message };
|
||||
}
|
||||
|
||||
const searchResult = (await response.json()) as SearchResult;
|
||||
return { searchResult, keywords, page };
|
||||
return { searchResult, keywords, page, matchingStrategy };
|
||||
} catch (error) {
|
||||
return {
|
||||
searchResult: null,
|
||||
keywords,
|
||||
page,
|
||||
matchingStrategy,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
@@ -33,22 +45,31 @@ async function performSearch(keywords: string, page: number, event: RequestEvent
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const keywords = event.url.searchParams.get('keywords') || '';
|
||||
const page = parseInt(event.url.searchParams.get('page') || '1');
|
||||
return performSearch(keywords, page, event);
|
||||
const matchingStrategy = (event.url.searchParams.get('matchingStrategy') ||
|
||||
'last') as MatchingStrategy;
|
||||
return performSearch(keywords, page, matchingStrategy, event);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const formData = await event.request.formData();
|
||||
const keywords = formData.get('keywords') as string;
|
||||
const matchingStrategy = formData.get('matchingStrategy') as MatchingStrategy;
|
||||
|
||||
if (keywords) {
|
||||
throw redirect(303, `/dashboard/search?keywords=${keywords}`);
|
||||
throw redirect(
|
||||
303,
|
||||
`/dashboard/search?keywords=${encodeURIComponent(
|
||||
keywords
|
||||
)}&page=1&matchingStrategy=${matchingStrategy}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
searchResult: null,
|
||||
keywords: '',
|
||||
page: 1
|
||||
page: 1,
|
||||
matchingStrategy: 'last'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -11,12 +12,26 @@
|
||||
} from '$lib/components/ui/card';
|
||||
import { onMount } from 'svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import type { MatchingStrategy } from '@open-archiver/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let searchResult = $derived(data.searchResult);
|
||||
let keywords = $derived(data.keywords);
|
||||
let page = $derived(data.page);
|
||||
let error = $derived(data.error);
|
||||
let matchingStrategy: MatchingStrategy = $derived(
|
||||
(data.matchingStrategy as MatchingStrategy) || 'last'
|
||||
);
|
||||
|
||||
const strategies = [
|
||||
{ value: 'last', label: 'Fuzzy' },
|
||||
{ value: 'all', label: 'Verbatim' },
|
||||
{ value: 'frequency', label: 'Frequency' }
|
||||
];
|
||||
|
||||
const triggerContent = $derived(
|
||||
strategies.find((s) => s.value === matchingStrategy)?.label ?? 'Select a strategy'
|
||||
);
|
||||
|
||||
let isMounted = $state(false);
|
||||
onMount(() => {
|
||||
@@ -148,15 +163,30 @@
|
||||
<div class="container mx-auto p-4 md:p-8">
|
||||
<h1 class="mb-4 text-2xl font-bold">Email Search</h1>
|
||||
|
||||
<form method="POST" action="/dashboard/search?action=search" class="mb-8 flex items-center gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
name="keywords"
|
||||
placeholder="Search by keyword, sender, recipient..."
|
||||
class="flex-grow"
|
||||
value={keywords}
|
||||
/>
|
||||
<Button type="submit">Search</Button>
|
||||
<form method="POST" action="/dashboard/search?action=search" class="mb-8 flex flex-col space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
name="keywords"
|
||||
placeholder="Search by keyword, sender, recipient..."
|
||||
class=" h-12 flex-grow"
|
||||
value={keywords}
|
||||
/>
|
||||
|
||||
<Button type="submit" class="h-12 cursor-pointer">Search</Button>
|
||||
</div>
|
||||
<Select.Root type="single" name="matchingStrategy" bind:value={matchingStrategy}>
|
||||
<Select.Trigger class=" w-[180px] cursor-pointer">
|
||||
{triggerContent}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each strategies as strategy (strategy.value)}
|
||||
<Select.Item value={strategy.value} label={strategy.label} class="cursor-pointer">
|
||||
{strategy.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</form>
|
||||
|
||||
{#if error}
|
||||
@@ -255,7 +285,9 @@
|
||||
{#if searchResult.total > searchResult.limit}
|
||||
<div class="mt-8 flex flex-row items-center justify-center space-x-2">
|
||||
<a
|
||||
href={`/dashboard/search?keywords=${keywords}&page=${page - 1}`}
|
||||
href={`/dashboard/search?keywords=${keywords}&page=${
|
||||
page - 1
|
||||
}&matchingStrategy=${matchingStrategy}`}
|
||||
class={page === 1 ? 'pointer-events-none' : ''}
|
||||
>
|
||||
<Button variant="outline" disabled={page === 1}>Prev</Button>
|
||||
@@ -263,7 +295,9 @@
|
||||
|
||||
{#each paginationItems as item}
|
||||
{#if typeof item === 'number'}
|
||||
<a href={`/dashboard/search?keywords=${keywords}&page=${item}`}>
|
||||
<a
|
||||
href={`/dashboard/search?keywords=${keywords}&page=${item}&matchingStrategy=${matchingStrategy}`}
|
||||
>
|
||||
<Button variant={item === page ? 'default' : 'outline'}>{item}</Button>
|
||||
</a>
|
||||
{:else}
|
||||
@@ -272,7 +306,9 @@
|
||||
{/each}
|
||||
|
||||
<a
|
||||
href={`/dashboard/search?keywords=${keywords}&page=${page + 1}`}
|
||||
href={`/dashboard/search?keywords=${keywords}&page=${
|
||||
page + 1
|
||||
}&matchingStrategy=${matchingStrategy}`}
|
||||
class={page === Math.ceil(searchResult.total / searchResult.limit)
|
||||
? 'pointer-events-none'
|
||||
: ''}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { EmailDocument } from './email.types';
|
||||
|
||||
export type MatchingStrategy = 'last' | 'all' | 'frequency';
|
||||
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
filters?: Record<string, any>;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
matchingStrategy?: MatchingStrategy;
|
||||
}
|
||||
|
||||
export interface SearchHit extends EmailDocument {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user