2022-08-31 06:33:37 +05:00
|
|
|
/*
|
|
|
|
|
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
|
2022-08-31 06:33:37 +05:00
|
|
|
|
|
|
|
|
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
|
|
|
|
2023-11-11 13:05:03 +05:00
|
|
|
import { match } from "fuzzyjs";
|
2024-09-23 15:08:57 +05:00
|
|
|
import Database from "./index.js";
|
2024-12-23 12:15:38 +05:00
|
|
|
import { Item, Note, SortOptions, TrashItem } from "../types.js";
|
2024-09-23 15:08:57 +05:00
|
|
|
import { DatabaseSchema, RawDatabaseSchema } from "../database/index.js";
|
2024-09-13 08:39:18 +05:00
|
|
|
import { AnyColumnWithTable, Kysely, sql } from "@streetwriters/kysely";
|
2024-09-23 15:08:57 +05:00
|
|
|
import { FilteredSelector } from "../database/sql-collection.js";
|
|
|
|
|
import { VirtualizedGrouping } from "../utils/virtualized-grouping.js";
|
|
|
|
|
import { logger } from "../logger.js";
|
|
|
|
|
import { rebuildSearchIndex } from "../database/fts.js";
|
|
|
|
|
import { transformQuery } from "../utils/query-transformer.js";
|
2024-12-23 12:15:38 +05:00
|
|
|
import { getSortSelectors } from "../utils/grouping.js";
|
2020-03-09 12:39:49 +05:00
|
|
|
|
2023-11-21 13:16:49 +05:00
|
|
|
type SearchResults<T> = {
|
|
|
|
|
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
|
|
|
|
|
items: (limit?: number) => Promise<T[]>;
|
|
|
|
|
ids: () => Promise<string[]>;
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-11 13:05:03 +05:00
|
|
|
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 {
|
2023-08-21 13:32:06 +05:00
|
|
|
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> {
|
2024-12-23 12:15:38 +05:00
|
|
|
return this.toSearchResults(async (limit, sortOptions) => {
|
2024-02-09 10:41:00 +05:00
|
|
|
const db = this.db.sql() as unknown as Kysely<RawDatabaseSchema>;
|
2024-02-19 16:34:39 +05:00
|
|
|
const excludedIds = this.db.trash.cache.notes;
|
2024-09-02 15:29:18 +05:00
|
|
|
|
2024-09-03 10:30:20 +05:00
|
|
|
query = transformQuery(query);
|
2024-02-19 16:34:39 +05:00
|
|
|
const results = await db
|
|
|
|
|
.selectFrom((eb) =>
|
2023-11-21 13:16:49 +05:00
|
|
|
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)
|
|
|
|
|
)
|
2024-05-01 09:16:14 +05:00
|
|
|
.where("title", "match", query)
|
2024-02-19 16:34:39 +05:00
|
|
|
.select(["id", sql<number>`rank * 10`.as("rank")])
|
|
|
|
|
.unionAll((eb) =>
|
2023-11-21 13:16:49 +05:00
|
|
|
eb
|
2024-02-19 16:34:39 +05:00
|
|
|
.selectFrom("content_fts")
|
|
|
|
|
.$if(!!notes, (eb) =>
|
2024-12-27 16:20:53 +05:00
|
|
|
eb.where("noteId", "in", notes!.filter.select("id"))
|
2024-02-19 16:34:39 +05:00
|
|
|
)
|
|
|
|
|
.$if(excludedIds.length > 0, (eb) =>
|
|
|
|
|
eb.where("id", "not in", excludedIds)
|
|
|
|
|
)
|
2024-05-01 09:16:14 +05:00
|
|
|
.where("data", "match", query)
|
2024-02-19 16:34:39 +05:00
|
|
|
.select(["noteId as id", "rank"])
|
2024-12-23 12:15:38 +05:00
|
|
|
.$castTo<{
|
|
|
|
|
id: string;
|
|
|
|
|
rank: number;
|
|
|
|
|
}>()
|
2023-11-21 13:16:49 +05:00
|
|
|
)
|
2024-02-19 16:34:39 +05:00
|
|
|
.as("results")
|
2023-11-21 13:16:49 +05:00
|
|
|
)
|
2024-02-19 16:34:39 +05:00
|
|
|
.select(["results.id"])
|
|
|
|
|
.groupBy("results.id")
|
2024-12-23 12:15:38 +05:00
|
|
|
.orderBy(sql`SUM(results.rank)`, sortOptions?.sortDirection || "desc")
|
2023-11-21 13:16:49 +05:00
|
|
|
.$if(!!limit, (eb) => eb.limit(limit!))
|
2024-03-21 12:04:07 +05:00
|
|
|
|
|
|
|
|
// filter out ids that have no note against them
|
|
|
|
|
.where(
|
|
|
|
|
"results.id",
|
|
|
|
|
"in",
|
|
|
|
|
(notes || this.db.notes.all).filter.select("id")
|
|
|
|
|
)
|
2024-05-01 09:16:14 +05:00
|
|
|
.execute()
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
logger.error(e, `Error while searching`, { query });
|
|
|
|
|
return [];
|
|
|
|
|
});
|
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
|
|
|
}
|
|
|
|
|
|
2023-11-11 13:05:03 +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
|
|
|
}
|
|
|
|
|
|
2023-11-11 13:05:03 +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
|
|
|
}
|
|
|
|
|
|
2023-11-11 13:05:03 +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
|
|
|
}
|
|
|
|
|
|
2023-11-21 13:16:49 +05:00
|
|
|
trash(query: string): SearchResults<TrashItem> {
|
2024-12-23 12:15:38 +05:00
|
|
|
const sortOptions: SortOptions = {
|
|
|
|
|
sortBy: "dateDeleted",
|
|
|
|
|
sortDirection: "desc"
|
|
|
|
|
};
|
2023-11-21 13:16:49 +05:00
|
|
|
return {
|
|
|
|
|
sorted: async (limit?: number) => {
|
2024-12-23 12:15:38 +05:00
|
|
|
const { ids, items } = await this.filterTrash(
|
|
|
|
|
query,
|
|
|
|
|
limit,
|
|
|
|
|
sortOptions
|
|
|
|
|
);
|
2023-11-21 13:16:49 +05:00
|
|
|
return new VirtualizedGrouping<TrashItem>(
|
2023-12-05 15:34:18 +05:00
|
|
|
ids.length,
|
2023-11-21 13:16:49 +05:00
|
|
|
this.db.options.batchSize,
|
2023-12-26 09:34:29 +05:00
|
|
|
() => Promise.resolve(ids),
|
2023-12-05 15:34:18 +05:00
|
|
|
async (start, end) => {
|
|
|
|
|
return {
|
|
|
|
|
ids: ids.slice(start, end),
|
|
|
|
|
items: items.slice(start, end)
|
|
|
|
|
};
|
|
|
|
|
}
|
2023-11-21 13:16:49 +05:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
items: async (limit?: number) => {
|
2024-12-23 12:15:38 +05:00
|
|
|
const { items } = await this.filterTrash(query, limit, sortOptions);
|
2023-12-05 15:34:18 +05:00
|
|
|
return items;
|
2023-11-21 13:16:49 +05:00
|
|
|
},
|
|
|
|
|
ids: () => this.filterTrash(query).then(({ ids }) => ids)
|
|
|
|
|
};
|
2022-02-28 13:02:16 +05:00
|
|
|
}
|
|
|
|
|
|
2023-11-11 13:05:03 +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
|
|
|
}
|
|
|
|
|
|
2023-11-21 13:16:49 +05:00
|
|
|
private search<T extends Item>(
|
2023-11-11 13:05:03 +05:00
|
|
|
selector: FilteredSelector<T>,
|
|
|
|
|
query: string,
|
|
|
|
|
fields: FuzzySearchField<T>[]
|
|
|
|
|
) {
|
2023-11-21 13:16:49 +05:00
|
|
|
return this.toSearchResults(
|
2024-12-23 12:15:38 +05:00
|
|
|
(limit, sortOptions) =>
|
|
|
|
|
this.filter(selector, query, fields, limit, sortOptions),
|
2023-11-21 13:16:49 +05:00
|
|
|
selector
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async filter<T extends Item>(
|
|
|
|
|
selector: FilteredSelector<T>,
|
|
|
|
|
query: string,
|
|
|
|
|
fields: FuzzySearchField<T>[],
|
2024-12-23 12:15:38 +05:00
|
|
|
limit?: number,
|
|
|
|
|
sortOptions?: SortOptions
|
2023-11-21 13:16:49 +05:00
|
|
|
) {
|
|
|
|
|
const results: Map<string, number> = new Map();
|
2023-11-11 13:05:03 +05:00
|
|
|
const columns = fields.map((f) => f.column);
|
|
|
|
|
for await (const item of selector.fields(columns)) {
|
2023-11-21 13:16:49 +05:00
|
|
|
if (limit && results.size >= limit) break;
|
|
|
|
|
|
2023-11-11 13:05:03 +05:00
|
|
|
for (const field of fields) {
|
|
|
|
|
const result = match(query, `${item[field.name]}`);
|
|
|
|
|
if (result.match) {
|
2023-11-21 13:16:49 +05:00
|
|
|
const oldScore = results.get(item.id) || 0;
|
|
|
|
|
results.set(item.id, oldScore + result.score * (field.weight || 1));
|
2023-11-11 13:05:03 +05:00
|
|
|
}
|
|
|
|
|
}
|
2023-10-07 13:26:03 +05:00
|
|
|
}
|
2023-11-11 13:05:03 +05:00
|
|
|
selector.fields([]);
|
|
|
|
|
|
2024-12-23 12:15:38 +05:00
|
|
|
const sorted = Array.from(results.entries());
|
|
|
|
|
|
|
|
|
|
if (!sortOptions)
|
|
|
|
|
// || sortOptions.sortBy === "relevance")
|
|
|
|
|
sorted.sort(
|
|
|
|
|
// sortOptions?.sortDirection === "desc"
|
|
|
|
|
// ? (a, b) => a[1] - b[1]
|
|
|
|
|
// :
|
|
|
|
|
(a, b) => b[1] - a[1]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return sorted.map((a) => a[0]);
|
2023-11-21 13:16:49 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toSearchResults<T extends Item>(
|
2024-12-23 12:15:38 +05:00
|
|
|
ids: (limit?: number, sortOptions?: SortOptions) => Promise<string[]>,
|
2023-11-21 13:16:49 +05:00
|
|
|
selector: FilteredSelector<T>
|
|
|
|
|
): SearchResults<T> {
|
2024-12-23 12:15:38 +05:00
|
|
|
const sortOptions: SortOptions = {
|
|
|
|
|
sortBy: "dateCreated",
|
|
|
|
|
sortDirection: "desc"
|
|
|
|
|
};
|
2023-11-21 13:16:49 +05:00
|
|
|
return {
|
|
|
|
|
sorted: async (limit?: number) =>
|
2024-12-23 12:15:38 +05:00
|
|
|
this.toVirtualizedGrouping(
|
|
|
|
|
await ids(limit, sortOptions),
|
|
|
|
|
selector,
|
|
|
|
|
sortOptions
|
|
|
|
|
),
|
|
|
|
|
items: async (limit?: number) =>
|
|
|
|
|
this.toItems(await ids(limit, sortOptions), selector, sortOptions),
|
2023-11-21 13:16:49 +05:00
|
|
|
ids
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-23 12:15:38 +05:00
|
|
|
private async filterTrash(
|
|
|
|
|
query: string,
|
|
|
|
|
limit?: number,
|
|
|
|
|
sortOptions?: SortOptions
|
|
|
|
|
) {
|
2023-11-21 13:16:49 +05:00
|
|
|
const items = await this.db.trash.all();
|
|
|
|
|
|
2023-12-05 15:34:18 +05:00
|
|
|
const results: Map<string, { rank: number; item: TrashItem }> = new Map();
|
2023-11-21 13:16:49 +05:00
|
|
|
for (const item of items) {
|
|
|
|
|
if (limit && results.size >= limit) break;
|
|
|
|
|
|
|
|
|
|
const result = match(query, item.title);
|
|
|
|
|
if (result.match) {
|
2023-12-05 15:34:18 +05:00
|
|
|
results.set(item.id, { rank: result.score, item });
|
2023-11-21 13:16:49 +05:00
|
|
|
}
|
|
|
|
|
}
|
2024-12-23 12:15:38 +05:00
|
|
|
const sorted = Array.from(results.entries());
|
2023-11-21 13:16:49 +05:00
|
|
|
|
2024-12-23 12:15:38 +05:00
|
|
|
if (!sortOptions)
|
|
|
|
|
// || sortOptions.sortBy === "relevance")
|
|
|
|
|
sorted.sort(
|
|
|
|
|
// sortOptions?.sortDirection === "desc"
|
|
|
|
|
// ? (a, b) => a[1].rank - b[1].rank
|
|
|
|
|
// :
|
|
|
|
|
(a, b) => b[1].rank - a[1].rank
|
|
|
|
|
);
|
|
|
|
|
else {
|
|
|
|
|
const selector = getSortSelectors(sortOptions)[sortOptions.sortDirection];
|
|
|
|
|
sorted.sort((a, b) => selector(a[1].item, b[1].item));
|
|
|
|
|
}
|
2023-12-05 15:34:18 +05:00
|
|
|
return {
|
|
|
|
|
ids: sorted.map((a) => a[0]),
|
|
|
|
|
items: sorted.map((a) => a[1].item)
|
|
|
|
|
};
|
2023-11-21 13:16:49 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toVirtualizedGrouping<T extends Item>(
|
|
|
|
|
ids: string[],
|
2024-12-23 12:15:38 +05:00
|
|
|
selector: FilteredSelector<T>,
|
|
|
|
|
sortOptions?: SortOptions
|
2023-11-21 13:16:49 +05:00
|
|
|
) {
|
2024-12-23 12:15:38 +05:00
|
|
|
// if (sortOptions?.sortBy === "relevance") sortOptions = undefined;
|
2023-11-11 13:05:03 +05:00
|
|
|
return new VirtualizedGrouping<T>(
|
2023-12-05 15:34:18 +05:00
|
|
|
ids.length,
|
2023-11-11 13:05:03 +05:00
|
|
|
this.db.options.batchSize,
|
2023-12-26 09:34:29 +05:00
|
|
|
() => Promise.resolve(ids),
|
2023-12-05 15:34:18 +05:00
|
|
|
async (start, end) => {
|
2024-12-23 12:15:38 +05:00
|
|
|
const items = await selector.items(ids.slice(start, end), sortOptions);
|
2023-12-05 15:34:18 +05:00
|
|
|
return {
|
|
|
|
|
ids: ids.slice(start, end),
|
2024-12-23 12:15:38 +05:00
|
|
|
items
|
2023-12-05 15:34:18 +05:00
|
|
|
};
|
|
|
|
|
}
|
2024-12-23 12:15:38 +05:00
|
|
|
// (items) => groupArray(items, () => `${items.length} results`)
|
2023-11-11 13:05:03 +05:00
|
|
|
);
|
2020-03-09 12:39:49 +05:00
|
|
|
}
|
2023-11-21 13:16:49 +05:00
|
|
|
|
|
|
|
|
private toItems<T extends Item>(
|
|
|
|
|
ids: string[],
|
2024-12-23 12:15:38 +05:00
|
|
|
selector: FilteredSelector<T>,
|
|
|
|
|
sortOptions?: SortOptions
|
2023-11-21 13:16:49 +05:00
|
|
|
) {
|
|
|
|
|
if (!ids.length) return [];
|
2024-12-23 12:15:38 +05:00
|
|
|
// if (sortOptions?.sortBy === "relevance") sortOptions = undefined;
|
|
|
|
|
return selector.items(ids, sortOptions);
|
2023-11-21 13:16:49 +05:00
|
|
|
}
|
2024-05-09 10:08:44 +05:00
|
|
|
|
|
|
|
|
async rebuild() {
|
|
|
|
|
const db = this.db.sql() as unknown as Kysely<RawDatabaseSchema>;
|
|
|
|
|
await rebuildSearchIndex(db);
|
|
|
|
|
}
|
2020-03-09 12:39:49 +05:00
|
|
|
}
|