Files
notesnook/packages/core/src/api/lookup.ts

246 lines
7.5 KiB
TypeScript
Raw Normal View History

/*
This file is part of the Notesnook project (https://notesnook.com/)
2023-01-16 13:44:52 +05:00
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
2022-08-30 16:13:11 +05:00
import { match } from "fuzzyjs";
import Database from ".";
2023-12-18 15:09:08 +05:00
import { Item, Note, TrashItem } from "../types";
2024-02-19 16:34:39 +05:00
import { DatabaseSchema, RawDatabaseSchema } from "../database";
import { AnyColumnWithTable, Kysely, sql } from "kysely";
import { FilteredSelector } from "../database/sql-collection";
import { VirtualizedGrouping } from "../utils/virtualized-grouping";
2020-03-09 12:39:49 +05:00
type SearchResults<T> = {
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
items: (limit?: number) => Promise<T[]>;
ids: () => Promise<string[]>;
};
type FuzzySearchField<T> = {
weight?: number;
name: keyof T;
column: AnyColumnWithTable<DatabaseSchema, keyof DatabaseSchema>;
};
2020-03-09 12:39:49 +05:00
export default class Lookup {
constructor(private readonly db: Database) {}
2020-03-19 12:38:33 +05:00
2023-12-18 15:09:08 +05:00
notes(query: string, notes?: FilteredSelector<Note>): SearchResults<Note> {
return this.toSearchResults(async (limit) => {
if (query.length < 3) return [];
2023-12-18 15:09:08 +05:00
2024-02-09 10:41:00 +05:00
const db = this.db.sql() as unknown as Kysely<RawDatabaseSchema>;
2023-11-22 08:48:16 +05:00
query = query.replace(/"/, '""');
2024-02-19 16:34:39 +05:00
const excludedIds = this.db.trash.cache.notes;
const results = await db
.selectFrom((eb) =>
eb
2024-02-19 16:34:39 +05:00
.selectFrom("notes_fts")
.$if(!!notes, (eb) =>
eb.where("id", "in", notes!.filter.select("id"))
)
.$if(excludedIds.length > 0, (eb) =>
eb.where("id", "not in", excludedIds)
)
.where("title", "match", `"${query}"`)
.select(["id", sql<number>`rank * 10`.as("rank")])
.unionAll((eb) =>
eb
2024-02-19 16:34:39 +05:00
.selectFrom("content_fts")
.$if(!!notes, (eb) =>
eb.where("id", "in", notes!.filter.select("id"))
)
.$if(excludedIds.length > 0, (eb) =>
eb.where("id", "not in", excludedIds)
)
.where("data", "match", `"${query}"`)
.select(["noteId as id", "rank"])
.$castTo<{ id: string; rank: number }>()
)
2024-02-19 16:34:39 +05:00
.as("results")
)
2024-02-19 16:34:39 +05:00
.select(["results.id"])
.groupBy("results.id")
.orderBy(sql`SUM(results.rank)`, "asc")
.$if(!!limit, (eb) => eb.limit(limit!))
// filter out ids that have no note against them
.where(
"results.id",
"in",
(notes || this.db.notes.all).filter.select("id")
)
.execute();
2024-02-19 16:34:39 +05:00
return results.map((r) => r.id);
2023-12-18 15:09:08 +05:00
}, notes || this.db.notes.all);
2020-03-09 12:39:49 +05:00
}
notebooks(query: string) {
return this.search(this.db.notebooks.all, query, [
{ name: "id", column: "notebooks.id", weight: -100 },
{ name: "title", column: "notebooks.title", weight: 10 },
{ name: "description", column: "notebooks.description" }
]);
2020-03-09 12:39:49 +05:00
}
tags(query: string) {
return this.search(this.db.tags.all, query, [
{ name: "id", column: "tags.id", weight: -100 },
{ name: "title", column: "tags.title" }
]);
2022-11-17 15:41:20 +05:00
}
reminders(query: string) {
return this.search(this.db.reminders.all, query, [
{ name: "id", column: "reminders.id", weight: -100 },
{ name: "title", column: "reminders.title", weight: 10 },
{ name: "description", column: "reminders.description" }
]);
2020-03-09 12:39:49 +05:00
}
trash(query: string): SearchResults<TrashItem> {
return {
sorted: async (limit?: number) => {
const { ids, items } = await this.filterTrash(query, limit);
return new VirtualizedGrouping<TrashItem>(
ids.length,
this.db.options.batchSize,
2023-12-26 09:34:29 +05:00
() => Promise.resolve(ids),
async (start, end) => {
return {
ids: ids.slice(start, end),
items: items.slice(start, end)
};
}
);
},
items: async (limit?: number) => {
const { items } = await this.filterTrash(query, limit);
return items;
},
ids: () => this.filterTrash(query).then(({ ids }) => ids)
};
2022-02-28 13:02:16 +05:00
}
attachments(query: string) {
return this.search(this.db.attachments.all, query, [
{ name: "id", column: "attachments.id", weight: -100 },
{ name: "filename", column: "attachments.filename", weight: 5 },
{ name: "mimeType", column: "attachments.mimeType" },
{ name: "hash", column: "attachments.hash" }
]);
2021-12-21 11:24:45 +05:00
}
private search<T extends Item>(
selector: FilteredSelector<T>,
query: string,
fields: FuzzySearchField<T>[]
) {
return this.toSearchResults(
(limit) => this.filter(selector, query, fields, limit),
selector
);
}
private async filter<T extends Item>(
selector: FilteredSelector<T>,
query: string,
fields: FuzzySearchField<T>[],
limit?: number
) {
const results: Map<string, number> = new Map();
const columns = fields.map((f) => f.column);
for await (const item of selector.fields(columns)) {
if (limit && results.size >= limit) break;
for (const field of fields) {
const result = match(query, `${item[field.name]}`);
if (result.match) {
const oldScore = results.get(item.id) || 0;
results.set(item.id, oldScore + result.score * (field.weight || 1));
}
}
2023-10-07 13:26:03 +05:00
}
selector.fields([]);
return Array.from(results.entries())
.sort((a, b) => a[1] - b[1])
.map((a) => a[0]);
}
private toSearchResults<T extends Item>(
ids: (limit?: number) => Promise<string[]>,
selector: FilteredSelector<T>
): SearchResults<T> {
return {
sorted: async (limit?: number) =>
this.toVirtualizedGrouping(await ids(limit), selector),
items: async (limit?: number) => this.toItems(await ids(limit), selector),
ids
};
}
private async filterTrash(query: string, limit?: number) {
const items = await this.db.trash.all();
const results: Map<string, { rank: number; item: TrashItem }> = new Map();
for (const item of items) {
if (limit && results.size >= limit) break;
const result = match(query, item.title);
if (result.match) {
results.set(item.id, { rank: result.score, item });
}
}
const sorted = Array.from(results.entries()).sort(
(a, b) => a[1].rank - b[1].rank
);
return {
ids: sorted.map((a) => a[0]),
items: sorted.map((a) => a[1].item)
};
}
private toVirtualizedGrouping<T extends Item>(
ids: string[],
selector: FilteredSelector<T>
) {
return new VirtualizedGrouping<T>(
ids.length,
this.db.options.batchSize,
2023-12-26 09:34:29 +05:00
() => Promise.resolve(ids),
async (start, end) => {
2024-02-19 16:34:39 +05:00
const items = await selector.records(ids);
return {
ids: ids.slice(start, end),
2024-02-19 16:34:39 +05:00
items: Object.values(items).slice(start, end)
};
}
);
2020-03-09 12:39:49 +05:00
}
private toItems<T extends Item>(
ids: string[],
selector: FilteredSelector<T>
) {
if (!ids.length) return [];
return selector.items(ids);
}
2020-03-09 12:39:49 +05:00
}