mirror of
https://github.com/zadam/trilium.git
synced 2026-03-22 20:01:42 +01:00
Compare commits
3 Commits
standalone
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5ce95093 | ||
|
|
77971a10d1 | ||
|
|
28a56ff7bf |
@@ -34,9 +34,9 @@ function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter) {
|
||||
function createApiRoute(router: BrowserRouter, transactional: boolean) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler, true));
|
||||
router.register(method, path, wrapHandler(handler, transactional));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,21 +46,21 @@ function createApiRoute(router: BrowserRouter) {
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
const apiRoute = createApiRoute(router, true);
|
||||
routes.buildSharedApiRoutes({
|
||||
apiRoute,
|
||||
asyncApiRoute: createApiRoute(router, false)
|
||||
});
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
|
||||
apiRoute("get", "/api/search/:searchString", () => []);
|
||||
apiRoute("get", "/api/search-templates", () => []);
|
||||
apiRoute("get", "/api/autocomplete", () => []);
|
||||
}
|
||||
|
||||
function bootstrapRoute() {
|
||||
|
||||
const assetPath = ".";
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,8 @@ class WasmStatement implements Statement {
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database,
|
||||
private sqlite3: Sqlite3Module
|
||||
private sqlite3: Sqlite3Module,
|
||||
private sql: string
|
||||
) {}
|
||||
|
||||
run(...params: unknown[]): RunResult {
|
||||
@@ -137,6 +138,24 @@ class WasmStatement implements Statement {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the prefix used for a parameter name in the SQL query.
|
||||
* SQLite supports @name, :name, and $name parameter styles.
|
||||
* Returns the prefix character, or ':' as default if not found.
|
||||
*/
|
||||
private detectParamPrefix(paramName: string): string {
|
||||
// Search for the parameter with each possible prefix
|
||||
for (const prefix of [':', '@', '$']) {
|
||||
// Use word boundary to avoid partial matches
|
||||
const pattern = new RegExp(`\\${prefix}${paramName}(?![a-zA-Z0-9_])`);
|
||||
if (pattern.test(this.sql)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
// Default to ':' if not found (most common in Trilium)
|
||||
return ':';
|
||||
}
|
||||
|
||||
private bindParams(params: unknown[]): void {
|
||||
this.stmt.clearBindings();
|
||||
if (params.length === 0) {
|
||||
@@ -148,16 +167,16 @@ class WasmStatement implements Statement {
|
||||
const inputBindings = params[0] as { [paramName: string]: BindableValue };
|
||||
|
||||
// SQLite WASM expects parameter names to include the prefix (@ : or $)
|
||||
// better-sqlite3 automatically maps unprefixed names to @name
|
||||
// We need to add the @ prefix for compatibility
|
||||
// We detect the prefix used in the SQL for each parameter
|
||||
const bindings: { [paramName: string]: BindableValue } = {};
|
||||
for (const [key, value] of Object.entries(inputBindings)) {
|
||||
// If the key already has a prefix, use it as-is
|
||||
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
|
||||
bindings[key] = value;
|
||||
} else {
|
||||
// Add @ prefix to match better-sqlite3 behavior
|
||||
bindings[`@${key}`] = value;
|
||||
// Detect the prefix used in the SQL and apply it
|
||||
const prefix = this.detectParamPrefix(key);
|
||||
bindings[`${prefix}${key}`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +512,7 @@ export default class BrowserSqlProvider implements DatabaseProvider {
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!, query);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
@@ -35,11 +35,9 @@ import otherRoute from "./api/other.js";
|
||||
import passwordApiRoute from "./api/password.js";
|
||||
import recoveryCodes from './api/recovery_codes.js';
|
||||
import scriptRoute from "./api/script.js";
|
||||
import searchRoute from "./api/search.js";
|
||||
import senderRoute from "./api/sender.js";
|
||||
import setupApiRoute from "./api/setup.js";
|
||||
import similarNotesRoute from "./api/similar_notes.js";
|
||||
import specialNotesRoute from "./api/special_notes.js";
|
||||
import syncApiRoute from "./api/sync.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
import totp from './api/totp.js';
|
||||
@@ -87,7 +85,10 @@ function register(app: express.Application) {
|
||||
apiRoute(GET, '/api/totp_recovery/enabled', recoveryCodes.checkForRecoveryKeys);
|
||||
apiRoute(GET, '/api/totp_recovery/used', recoveryCodes.getUsedRecoveryCodes);
|
||||
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
routes.buildSharedApiRoutes({
|
||||
apiRoute,
|
||||
asyncApiRoute
|
||||
});
|
||||
|
||||
route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler);
|
||||
route(GET, "/api/notes/:noteId/open", [auth.checkApiAuthOrElectron], filesRoute.openFile);
|
||||
@@ -171,12 +172,6 @@ function register(app: express.Application) {
|
||||
|
||||
apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete);
|
||||
apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount);
|
||||
apiRoute(GET, "/api/quick-search/:searchString", searchRoute.quickSearch);
|
||||
apiRoute(GET, "/api/search-note/:noteId", searchRoute.searchFromNote);
|
||||
apiRoute(PST, "/api/search-and-execute-note/:noteId", searchRoute.searchAndExecute);
|
||||
apiRoute(PST, "/api/search-related", searchRoute.getRelatedNotes);
|
||||
apiRoute(GET, "/api/search/:searchString", searchRoute.search);
|
||||
apiRoute(GET, "/api/search-templates", searchRoute.searchTemplates);
|
||||
|
||||
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
|
||||
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
|
||||
@@ -200,22 +195,6 @@ function register(app: express.Application) {
|
||||
route(PST, "/api/clipper/open/:noteId", clipperMiddleware, clipperRoute.openNote, apiResultHandler);
|
||||
asyncRoute(GET, "/api/clipper/notes-by-url/:noteUrl", clipperMiddleware, clipperRoute.findNotesByUrl, apiResultHandler);
|
||||
|
||||
asyncApiRoute(GET, "/api/special-notes/inbox/:date", specialNotesRoute.getInboxNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/days/:date", specialNotesRoute.getDayNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/week-first-day/:date", specialNotesRoute.getWeekFirstDayNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/weeks/:week", specialNotesRoute.getWeekNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/months/:month", specialNotesRoute.getMonthNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/quarters/:quarter", specialNotesRoute.getQuarterNote);
|
||||
apiRoute(GET, "/api/special-notes/years/:year", specialNotesRoute.getYearNote);
|
||||
apiRoute(GET, "/api/special-notes/notes-for-month/:month", specialNotesRoute.getDayNotesForMonth);
|
||||
apiRoute(PST, "/api/special-notes/sql-console", specialNotesRoute.createSqlConsole);
|
||||
asyncApiRoute(PST, "/api/special-notes/save-sql-console", specialNotesRoute.saveSqlConsole);
|
||||
apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote);
|
||||
apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote);
|
||||
apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher);
|
||||
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
|
||||
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
|
||||
|
||||
asyncRoute(PST, "/api/database/anonymize/:type", [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.anonymize, apiResultHandler);
|
||||
apiRoute(GET, "/api/database/anonymized-databases", databaseRoute.getExistingAnonymizedDatabases);
|
||||
|
||||
|
||||
@@ -1,436 +1,2 @@
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
|
||||
import attributeService from "./attributes.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import { dayjs, Dayjs, getFirstDayOfWeek1, getWeekInfo, WeekSettings } from "@triliumnext/commons";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import noteService from "./notes.js";
|
||||
import optionService from "./options.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import searchContext from "../services/search/search_context.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import sql from "./sql.js";
|
||||
import { t } from "i18next";
|
||||
import { ordinal } from "./i18n.js";
|
||||
|
||||
const CALENDAR_ROOT_LABEL = "calendarRoot";
|
||||
const YEAR_LABEL = "yearNote";
|
||||
const QUARTER_LABEL = "quarterNote";
|
||||
const MONTH_LABEL = "monthNote";
|
||||
const WEEK_LABEL = "weekNote";
|
||||
const DATE_LABEL = "dateNote";
|
||||
|
||||
const WEEKDAY_TRANSLATION_IDS = [
|
||||
"weekdays.sunday", "weekdays.monday", "weekdays.tuesday",
|
||||
"weekdays.wednesday", "weekdays.thursday", "weekdays.friday",
|
||||
"weekdays.saturday", "weekdays.sunday"
|
||||
];
|
||||
|
||||
const MONTH_TRANSLATION_IDS = [
|
||||
"months.january",
|
||||
"months.february",
|
||||
"months.march",
|
||||
"months.april",
|
||||
"months.may",
|
||||
"months.june",
|
||||
"months.july",
|
||||
"months.august",
|
||||
"months.september",
|
||||
"months.october",
|
||||
"months.november",
|
||||
"months.december"
|
||||
];
|
||||
|
||||
type TimeUnit = "year" | "quarter" | "month" | "week" | "day";
|
||||
|
||||
const baseReplacements = {
|
||||
year: [ "year" ],
|
||||
quarter: [ "quarterNumber", "shortQuarter" ],
|
||||
month: [ "isoMonth", "monthNumber", "monthNumberPadded",
|
||||
"month", "shortMonth3", "shortMonth4" ],
|
||||
week: [ "weekNumber", "weekNumberPadded", "shortWeek", "shortWeek3" ],
|
||||
day: [ "isoDate", "dateNumber", "dateNumberPadded",
|
||||
"ordinal", "weekDay", "weekDay3", "weekDay2" ]
|
||||
};
|
||||
|
||||
function getTimeUnitReplacements(timeUnit: TimeUnit): string[] {
|
||||
const units: TimeUnit[] = [ "year", "quarter", "month", "week", "day" ];
|
||||
const index = units.indexOf(timeUnit);
|
||||
return units.slice(0, index + 1).flatMap(unit => baseReplacements[unit]);
|
||||
}
|
||||
|
||||
function getJournalNoteTitle(
|
||||
rootNote: BNote,
|
||||
timeUnit: TimeUnit,
|
||||
dateObj: Dayjs,
|
||||
number: number,
|
||||
weekYear?: number // Optional: the week year for cross-year weeks
|
||||
) {
|
||||
const patterns = {
|
||||
year: rootNote.getOwnedLabelValue("yearPattern") || "{year}",
|
||||
quarter: rootNote.getOwnedLabelValue("quarterPattern") || t("quarterNumber"),
|
||||
month: rootNote.getOwnedLabelValue("monthPattern") || "{monthNumberPadded} - {month}",
|
||||
week: rootNote.getOwnedLabelValue("weekPattern") || t("weekdayNumber"),
|
||||
day: rootNote.getOwnedLabelValue("datePattern") || "{dateNumberPadded} - {weekDay}"
|
||||
};
|
||||
|
||||
const pattern = patterns[timeUnit];
|
||||
const monthName = t(MONTH_TRANSLATION_IDS[dateObj.month()]);
|
||||
const weekDay = t(WEEKDAY_TRANSLATION_IDS[dateObj.day()]);
|
||||
const numberStr = number.toString();
|
||||
const ordinalStr = ordinal(dateObj);
|
||||
|
||||
// For week notes, use the weekYear if provided (handles cross-year weeks)
|
||||
const yearForDisplay = (timeUnit === "week" && weekYear !== undefined)
|
||||
? weekYear.toString()
|
||||
: dateObj.format("YYYY");
|
||||
|
||||
const allReplacements: Record<string, string> = {
|
||||
// Common date formats
|
||||
"{year}": yearForDisplay,
|
||||
|
||||
// Month related
|
||||
"{isoMonth}": dateObj.format("YYYY-MM"),
|
||||
"{monthNumber}": numberStr,
|
||||
"{monthNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{month}": monthName,
|
||||
"{shortMonth3}": monthName.slice(0, 3),
|
||||
"{shortMonth4}": monthName.slice(0, 4),
|
||||
|
||||
// Quarter related
|
||||
"{quarterNumber}": numberStr,
|
||||
"{shortQuarter}": `Q${numberStr}`,
|
||||
|
||||
// Week related
|
||||
"{weekNumber}": numberStr,
|
||||
"{weekNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{shortWeek}": `W${numberStr}`,
|
||||
"{shortWeek3}": `W${numberStr.padStart(2, "0")}`,
|
||||
|
||||
// Day related
|
||||
"{isoDate}": dateObj.format("YYYY-MM-DD"),
|
||||
"{dateNumber}": numberStr,
|
||||
"{dateNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{ordinal}": ordinalStr,
|
||||
"{weekDay}": weekDay,
|
||||
"{weekDay3}": weekDay.substring(0, 3),
|
||||
"{weekDay2}": weekDay.substring(0, 2)
|
||||
};
|
||||
|
||||
const allowedReplacements = Object.entries(allReplacements).reduce((acc, [ key, value ]) => {
|
||||
const replacementKey = key.slice(1, -1);
|
||||
if (getTimeUnitReplacements(timeUnit).includes(replacementKey)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return Object.entries(allowedReplacements).reduce(
|
||||
(title, [ key, value ]) => title.replace(new RegExp(key, "g"), value),
|
||||
pattern
|
||||
);
|
||||
}
|
||||
|
||||
function createNote(parentNote: BNote, noteTitle: string) {
|
||||
return noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title: noteTitle,
|
||||
content: "",
|
||||
isProtected: parentNote.isProtected &&
|
||||
protectedSessionService.isProtectedSessionAvailable(),
|
||||
type: "text"
|
||||
}).note;
|
||||
}
|
||||
|
||||
function getRootCalendarNote(): BNote {
|
||||
let rootNote;
|
||||
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
|
||||
if (!workspaceNote || !workspaceNote.isRoot()) {
|
||||
rootNote = searchService.findFirstNoteWithQuery(
|
||||
"#workspaceCalendarRoot", new searchContext({ ignoreHoistedNote: false })
|
||||
);
|
||||
}
|
||||
|
||||
if (!rootNote) {
|
||||
rootNote = attributeService.getNoteWithLabel(CALENDAR_ROOT_LABEL);
|
||||
}
|
||||
|
||||
if (!rootNote) {
|
||||
sql.transactional(() => {
|
||||
rootNote = noteService.createNewNote({
|
||||
parentNoteId: "root",
|
||||
title: "Calendar",
|
||||
target: "into",
|
||||
isProtected: false,
|
||||
type: "text",
|
||||
content: ""
|
||||
}).note;
|
||||
|
||||
attributeService.createLabel(rootNote.noteId, CALENDAR_ROOT_LABEL);
|
||||
attributeService.createLabel(rootNote.noteId, "sorted");
|
||||
});
|
||||
}
|
||||
|
||||
return rootNote as BNote;
|
||||
}
|
||||
|
||||
function getYearNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
const yearStr = dateStr.trim().substring(0, 4);
|
||||
|
||||
let yearNote = searchService.findFirstNoteWithQuery(
|
||||
`#${YEAR_LABEL}="${yearStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (yearNote) {
|
||||
return yearNote;
|
||||
}
|
||||
|
||||
sql.transactional(() => {
|
||||
yearNote = createNote(rootNote, yearStr);
|
||||
|
||||
attributeService.createLabel(yearNote.noteId, YEAR_LABEL, yearStr);
|
||||
attributeService.createLabel(yearNote.noteId, "sorted");
|
||||
|
||||
const yearTemplateAttr = rootNote.getOwnedAttribute("relation", "yearTemplate");
|
||||
|
||||
if (yearTemplateAttr) {
|
||||
attributeService.createRelation(yearNote.noteId, "template", yearTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return yearNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getQuarterNumberStr(date: Dayjs) {
|
||||
return `${date.year()}-Q${date.quarter()}`;
|
||||
}
|
||||
|
||||
function getQuarterNote(quarterStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
quarterStr = quarterStr.trim().substring(0, 7);
|
||||
|
||||
let quarterNote = searchService.findFirstNoteWithQuery(
|
||||
`#${QUARTER_LABEL}="${quarterStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (quarterNote) {
|
||||
return quarterNote;
|
||||
}
|
||||
|
||||
const [ yearStr, quarterNumberStr ] = quarterStr.trim().split("-Q");
|
||||
const quarterNumber = parseInt(quarterNumberStr);
|
||||
const firstMonth = (quarterNumber - 1) * 3;
|
||||
const quarterStartDate = dayjs().year(parseInt(yearStr)).month(firstMonth).date(1);
|
||||
|
||||
const yearNote = getYearNote(yearStr, rootNote);
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "quarter", quarterStartDate, quarterNumber
|
||||
);
|
||||
|
||||
sql.transactional(() => {
|
||||
quarterNote = createNote(yearNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(quarterNote.noteId, QUARTER_LABEL, quarterStr);
|
||||
attributeService.createLabel(quarterNote.noteId, "sorted");
|
||||
|
||||
const quarterTemplateAttr = rootNote.getOwnedAttribute("relation", "quarterTemplate");
|
||||
|
||||
if (quarterTemplateAttr) {
|
||||
attributeService.createRelation(
|
||||
quarterNote.noteId, "template", quarterTemplateAttr.value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return quarterNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
const monthStr = dateStr.substring(0, 7);
|
||||
const monthNumber = dateStr.substring(5, 7);
|
||||
|
||||
let monthNote = searchService.findFirstNoteWithQuery(
|
||||
`#${MONTH_LABEL}="${monthStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (monthNote) {
|
||||
return monthNote;
|
||||
}
|
||||
|
||||
let monthParentNote: BNote | null;
|
||||
|
||||
if (rootNote.hasLabel("enableQuarterNote")) {
|
||||
monthParentNote = getQuarterNote(getQuarterNumberStr(dayjs(dateStr)), rootNote);
|
||||
} else {
|
||||
monthParentNote = getYearNote(dateStr, rootNote);
|
||||
}
|
||||
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "month", dayjs(dateStr), parseInt(monthNumber)
|
||||
);
|
||||
|
||||
sql.transactional(() => {
|
||||
monthNote = createNote(monthParentNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(monthNote.noteId, MONTH_LABEL, monthStr);
|
||||
attributeService.createLabel(monthNote.noteId, "sorted");
|
||||
|
||||
const monthTemplateAttr = rootNote.getOwnedAttribute("relation", "monthTemplate");
|
||||
|
||||
if (monthTemplateAttr) {
|
||||
attributeService.createRelation(monthNote.noteId, "template", monthTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return monthNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getWeekSettings(): WeekSettings {
|
||||
return {
|
||||
firstDayOfWeek: parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10),
|
||||
firstWeekOfYear: parseInt(optionService.getOptionOrNull("firstWeekOfYear") ?? "0", 10),
|
||||
minDaysInFirstWeek: parseInt(optionService.getOptionOrNull("minDaysInFirstWeek") ?? "4", 10)
|
||||
};
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs): Dayjs {
|
||||
const firstDayISO = parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10);
|
||||
const day = date.isoWeekday();
|
||||
const diff = (day - firstDayISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
|
||||
function getWeekNumberStr(date: Dayjs): string {
|
||||
const { weekYear, weekNumber } = getWeekInfo(date, getWeekSettings());
|
||||
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) {
|
||||
const weekStartDate = getWeekStartDate(dayjs(dateStr));
|
||||
return getDayNote(weekStartDate.format("YYYY-MM-DD"), rootNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link BNote} corresponding to the given week. If there is no note associated yet to that week, it will be created and returned instead.
|
||||
*
|
||||
* @param weekStr the week for which to return the corresponding note, in the format `2024-W04`.
|
||||
* @param _rootNote a {@link BNote} representing the calendar root, or {@code null} or not specified to use the default root calendar note.
|
||||
* @returns a Promise that resolves to the {@link BNote} corresponding to the week note.
|
||||
*/
|
||||
function getWeekNote(weekStr: string, _rootNote: BNote | null = null): BNote | null {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
if (!rootNote.hasLabel("enableWeekNote")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
weekStr = weekStr.trim().substring(0, 8);
|
||||
|
||||
let weekNote = searchService.findFirstNoteWithQuery(
|
||||
`#${WEEK_LABEL}="${weekStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (weekNote) {
|
||||
return weekNote;
|
||||
}
|
||||
|
||||
const [ yearStr, weekNumStr ] = weekStr.trim().split("-W");
|
||||
const weekNumber = parseInt(weekNumStr);
|
||||
const weekYear = parseInt(yearStr);
|
||||
|
||||
// Calculate week start date based on user's first week of year settings.
|
||||
// This correctly handles cross-year weeks based on user preferences.
|
||||
const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, getWeekSettings());
|
||||
const startDate = firstDayOfWeek1.add(weekNumber - 1, "week");
|
||||
const endDate = startDate.add(6, "day");
|
||||
|
||||
const startMonth = startDate.month();
|
||||
const endMonth = endDate.month();
|
||||
|
||||
const monthNote = getMonthNote(startDate.format("YYYY-MM-DD"), rootNote);
|
||||
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber, weekYear);
|
||||
|
||||
sql.transactional(() => {
|
||||
weekNote = createNote(monthNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(weekNote.noteId, WEEK_LABEL, weekStr);
|
||||
attributeService.createLabel(weekNote.noteId, "sorted");
|
||||
|
||||
const weekTemplateAttr = rootNote.getOwnedAttribute("relation", "weekTemplate");
|
||||
|
||||
if (weekTemplateAttr) {
|
||||
attributeService.createRelation(weekNote.noteId, "template", weekTemplateAttr.value);
|
||||
}
|
||||
|
||||
// If the week spans different months, clone the week note in the other month as well
|
||||
if (startMonth !== endMonth) {
|
||||
const secondMonthNote = getMonthNote(endDate.format("YYYY-MM-DD"), rootNote);
|
||||
cloningService.cloneNoteToParentNote(weekNote.noteId, secondMonthNote.noteId);
|
||||
}
|
||||
});
|
||||
|
||||
return weekNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getDayNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
dateStr = dateStr.trim().substring(0, 10);
|
||||
|
||||
let dateNote = searchService.findFirstNoteWithQuery(
|
||||
`#${DATE_LABEL}="${dateStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (dateNote) {
|
||||
return dateNote;
|
||||
}
|
||||
|
||||
let dateParentNote: BNote | null;
|
||||
|
||||
if (rootNote.hasLabel("enableWeekNote")) {
|
||||
dateParentNote = getWeekNote(getWeekNumberStr(dayjs(dateStr)), rootNote);
|
||||
} else {
|
||||
dateParentNote = getMonthNote(dateStr, rootNote);
|
||||
}
|
||||
|
||||
const dayNumber = dateStr.substring(8, 10);
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "day", dayjs(dateStr), parseInt(dayNumber)
|
||||
);
|
||||
|
||||
sql.transactional(() => {
|
||||
dateNote = createNote(dateParentNote as BNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(dateNote.noteId, DATE_LABEL, dateStr.substring(0, 10));
|
||||
|
||||
const dateTemplateAttr = rootNote.getOwnedAttribute("relation", "dateTemplate");
|
||||
|
||||
if (dateTemplateAttr) {
|
||||
attributeService.createRelation(dateNote.noteId, "template", dateTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return dateNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getTodayNote(rootNote: BNote | null = null) {
|
||||
return getDayNote(dayjs().format("YYYY-MM-DD"), rootNote);
|
||||
}
|
||||
|
||||
export default {
|
||||
getRootCalendarNote,
|
||||
getYearNote,
|
||||
getQuarterNote,
|
||||
getMonthNote,
|
||||
getWeekNote,
|
||||
getWeekFirstDayNote,
|
||||
getDayNote,
|
||||
getTodayNote,
|
||||
getJournalNoteTitle
|
||||
};
|
||||
import { date_notes } from "@triliumnext/core";
|
||||
export default date_notes;
|
||||
|
||||
@@ -1,40 +1,2 @@
|
||||
import cls from "./cls.js";
|
||||
import becca from "../becca/becca.js";
|
||||
|
||||
function getHoistedNoteId() {
|
||||
return cls.getHoistedNoteId();
|
||||
}
|
||||
|
||||
function isHoistedInHiddenSubtree() {
|
||||
const hoistedNoteId = getHoistedNoteId();
|
||||
|
||||
if (hoistedNoteId === "root") {
|
||||
return false;
|
||||
} else if (hoistedNoteId === "_hidden") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hoistedNote = becca.getNote(hoistedNoteId);
|
||||
|
||||
if (!hoistedNote) {
|
||||
throw new Error(`Cannot find hoisted note '${hoistedNoteId}'`);
|
||||
}
|
||||
|
||||
return hoistedNote.isHiddenCompletely();
|
||||
}
|
||||
|
||||
function getWorkspaceNote() {
|
||||
const hoistedNote = becca.getNote(cls.getHoistedNoteId());
|
||||
|
||||
if (hoistedNote && (hoistedNote.isRoot() || hoistedNote.hasLabel("workspace"))) {
|
||||
return hoistedNote;
|
||||
} else {
|
||||
return becca.getRoot();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getHoistedNoteId,
|
||||
getWorkspaceNote,
|
||||
isHoistedInHiddenSubtree
|
||||
};
|
||||
import { hoisted_note } from "@triliumnext/core";
|
||||
export default hoisted_note;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { NoteSet } from "@triliumnext/core";
|
||||
export default NoteSet;
|
||||
@@ -1,794 +0,0 @@
|
||||
import { becca_service } from "@triliumnext/core";
|
||||
import normalizeString from "normalize-strings";
|
||||
import striptags from "striptags";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import hoistedNoteService from "../../hoisted_note.js";
|
||||
import log from "../../log.js";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import scriptService from "../../script.js";
|
||||
import sql from "../../sql.js";
|
||||
import { escapeHtml, escapeRegExp } from "../../utils.js";
|
||||
import type Expression from "../expressions/expression.js";
|
||||
import SearchContext from "../search_context.js";
|
||||
import SearchResult from "../search_result.js";
|
||||
import handleParens from "./handle_parens.js";
|
||||
import lex from "./lex.js";
|
||||
import parse from "./parse.js";
|
||||
import type { SearchParams, TokenStructure } from "./types.js";
|
||||
|
||||
export interface SearchNoteResult {
|
||||
searchResultNoteIds: string[];
|
||||
highlightedTokens: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_RESULT: SearchNoteResult = {
|
||||
searchResultNoteIds: [],
|
||||
highlightedTokens: [],
|
||||
error: null
|
||||
};
|
||||
|
||||
function searchFromNote(note: BNote): SearchNoteResult {
|
||||
let searchResultNoteIds;
|
||||
let highlightedTokens: string[];
|
||||
|
||||
const searchScript = note.getRelationValue("searchScript");
|
||||
const searchString = note.getLabelValue("searchString") || "";
|
||||
let error: string | null = null;
|
||||
|
||||
if (searchScript) {
|
||||
searchResultNoteIds = searchFromRelation(note, "searchScript");
|
||||
highlightedTokens = [];
|
||||
} else {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch: note.hasLabel("fastSearch"),
|
||||
ancestorNoteId: note.getRelationValue("ancestor") || undefined,
|
||||
ancestorDepth: note.getLabelValue("ancestorDepth") || undefined,
|
||||
includeArchivedNotes: note.hasLabel("includeArchivedNotes"),
|
||||
orderBy: note.getLabelValue("orderBy") || undefined,
|
||||
orderDirection: note.getLabelValue("orderDirection") || undefined,
|
||||
limit: parseInt(note.getLabelValue("limit") || "0", 10),
|
||||
debug: note.hasLabel("debug"),
|
||||
fuzzyAttributeSearch: false
|
||||
});
|
||||
|
||||
searchResultNoteIds = findResultsWithQuery(searchString, searchContext).map((sr) => sr.noteId);
|
||||
|
||||
highlightedTokens = searchContext.highlightedTokens;
|
||||
error = searchContext.getError();
|
||||
}
|
||||
|
||||
// we won't return search note's own noteId
|
||||
// also don't allow root since that would force infinite cycle
|
||||
return {
|
||||
searchResultNoteIds: searchResultNoteIds.filter((resultNoteId) => !["root", note.noteId].includes(resultNoteId)),
|
||||
highlightedTokens,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
function searchFromRelation(note: BNote, relationName: string) {
|
||||
const scriptNote = note.getRelationTarget(relationName);
|
||||
|
||||
if (!scriptNote) {
|
||||
log.info(`Search note's relation ${relationName} has not been found.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!scriptNote.isJavaScript() || scriptNote.getScriptEnv() !== "backend") {
|
||||
log.info(`Note ${scriptNote.noteId} is not executable.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!note.isContentAvailable()) {
|
||||
log.info(`Note ${scriptNote.noteId} is not available outside of protected session.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = scriptService.executeNote(scriptNote, { originEntity: note });
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
log.info(`Result from ${scriptNote.noteId} is not an array.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// we expect either array of noteIds (strings) or notes, in that case we extract noteIds ourselves
|
||||
return typeof result[0] === "string" ? result : result.map((item) => item.noteId);
|
||||
}
|
||||
|
||||
function loadNeededInfoFromDatabase() {
|
||||
/**
|
||||
* This complex structure is needed to calculate total occupied space by a note. Several object instances
|
||||
* (note, revisions, attachments) can point to a single blobId, and thus the blob size should count towards the total
|
||||
* only once.
|
||||
*
|
||||
* noteId => { blobId => blobSize }
|
||||
*/
|
||||
const noteBlobs: Record<string, Record<string, number>> = {};
|
||||
|
||||
type NoteContentLengthsRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
};
|
||||
const noteContentLengths = sql.getRows<NoteContentLengthsRow>(`
|
||||
SELECT
|
||||
noteId,
|
||||
blobId,
|
||||
LENGTH(content) AS length
|
||||
FROM notes
|
||||
JOIN blobs USING(blobId)
|
||||
WHERE notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length } of noteContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
becca.notes[noteId].contentSize = length;
|
||||
becca.notes[noteId].revisionCount = 0;
|
||||
|
||||
noteBlobs[noteId] = { [blobId]: length };
|
||||
}
|
||||
|
||||
type AttachmentContentLengthsRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
};
|
||||
const attachmentContentLengths = sql.getRows<AttachmentContentLengthsRow>(`
|
||||
SELECT
|
||||
ownerId AS noteId,
|
||||
attachments.blobId,
|
||||
LENGTH(content) AS length
|
||||
FROM attachments
|
||||
JOIN notes ON attachments.ownerId = notes.noteId
|
||||
JOIN blobs ON attachments.blobId = blobs.blobId
|
||||
WHERE attachments.isDeleted = 0
|
||||
AND notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length } of attachmentContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(noteId in noteBlobs)) {
|
||||
log.error(`Did not find a '${noteId}' in the noteBlobs.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
noteBlobs[noteId][blobId] = length;
|
||||
}
|
||||
|
||||
for (const noteId in noteBlobs) {
|
||||
becca.notes[noteId].contentAndAttachmentsSize = Object.values(noteBlobs[noteId]).reduce((acc, size) => acc + size, 0);
|
||||
}
|
||||
|
||||
type RevisionRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
isNoteRevision: true;
|
||||
};
|
||||
const revisionContentLengths = sql.getRows<RevisionRow>(`
|
||||
SELECT
|
||||
noteId,
|
||||
revisions.blobId,
|
||||
LENGTH(content) AS length,
|
||||
1 AS isNoteRevision
|
||||
FROM notes
|
||||
JOIN revisions USING(noteId)
|
||||
JOIN blobs ON revisions.blobId = blobs.blobId
|
||||
WHERE notes.isDeleted = 0
|
||||
UNION ALL
|
||||
SELECT
|
||||
noteId,
|
||||
revisions.blobId,
|
||||
LENGTH(content) AS length,
|
||||
0 AS isNoteRevision -- it's attachment not counting towards revision count
|
||||
FROM notes
|
||||
JOIN revisions USING(noteId)
|
||||
JOIN attachments ON attachments.ownerId = revisions.revisionId
|
||||
JOIN blobs ON attachments.blobId = blobs.blobId
|
||||
WHERE notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length, isNoteRevision } of revisionContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(noteId in noteBlobs)) {
|
||||
log.error(`Did not find a '${noteId}' in the noteBlobs.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
noteBlobs[noteId][blobId] = length;
|
||||
|
||||
if (isNoteRevision) {
|
||||
const noteRevision = becca.notes[noteId];
|
||||
if (noteRevision && noteRevision.revisionCount) {
|
||||
noteRevision.revisionCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const noteId in noteBlobs) {
|
||||
becca.notes[noteId].contentAndAttachmentsAndRevisionsSize = Object.values(noteBlobs[noteId]).reduce((acc, size) => acc + size, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function findResultsWithExpression(expression: Expression, searchContext: SearchContext): SearchResult[] {
|
||||
if (searchContext.dbLoadNeeded) {
|
||||
loadNeededInfoFromDatabase();
|
||||
}
|
||||
|
||||
// If there's an explicit orderBy clause, skip progressive search
|
||||
// as it would interfere with the ordering
|
||||
if (searchContext.orderBy) {
|
||||
// For ordered queries, don't use progressive search but respect
|
||||
// the original fuzzy matching setting
|
||||
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||
}
|
||||
|
||||
// If fuzzy matching is explicitly disabled, skip progressive search
|
||||
if (!searchContext.enableFuzzyMatching) {
|
||||
return performSearch(expression, searchContext, false);
|
||||
}
|
||||
|
||||
// Phase 1: Try exact matches first (without fuzzy matching)
|
||||
const exactResults = performSearch(expression, searchContext, false);
|
||||
|
||||
// Check if we have sufficient high-quality results
|
||||
const minResultThreshold = 5;
|
||||
const minScoreForQuality = 10; // Minimum score to consider a result "high quality"
|
||||
|
||||
const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality);
|
||||
|
||||
// If we have enough high-quality exact matches, return them
|
||||
if (highQualityResults.length >= minResultThreshold) {
|
||||
return exactResults;
|
||||
}
|
||||
|
||||
// Phase 2: Add fuzzy matching as fallback when exact matches are insufficient
|
||||
const fuzzyResults = performSearch(expression, searchContext, true);
|
||||
|
||||
// Merge results, ensuring exact matches always rank higher than fuzzy matches
|
||||
return mergeExactAndFuzzyResults(exactResults, fuzzyResults);
|
||||
}
|
||||
|
||||
function performSearch(expression: Expression, searchContext: SearchContext, enableFuzzyMatching: boolean): SearchResult[] {
|
||||
const allNoteSet = becca.getAllNoteSet();
|
||||
|
||||
const noteIdToNotePath: Record<string, string[]> = {};
|
||||
const executionContext = {
|
||||
noteIdToNotePath
|
||||
};
|
||||
|
||||
// Store original fuzzy setting and temporarily override it
|
||||
const originalFuzzyMatching = searchContext.enableFuzzyMatching;
|
||||
searchContext.enableFuzzyMatching = enableFuzzyMatching;
|
||||
|
||||
const noteSet = expression.execute(allNoteSet, executionContext, searchContext);
|
||||
|
||||
const searchResults = noteSet.notes.map((note) => {
|
||||
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
|
||||
|
||||
if (!notePathArray) {
|
||||
throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`);
|
||||
}
|
||||
|
||||
return new SearchResult(notePathArray);
|
||||
});
|
||||
|
||||
for (const res of searchResults) {
|
||||
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching);
|
||||
}
|
||||
|
||||
// Restore original fuzzy setting
|
||||
searchContext.enableFuzzyMatching = originalFuzzyMatching;
|
||||
|
||||
if (!noteSet.sorted) {
|
||||
searchResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
// This is based on the assumption that more important results are closer to the note root.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] {
|
||||
// Create a map of exact result note IDs for deduplication
|
||||
const exactNoteIds = new Set(exactResults.map(result => result.noteId));
|
||||
|
||||
// Add fuzzy results that aren't already in exact results
|
||||
const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId));
|
||||
|
||||
// Sort exact results by score (best exact matches first)
|
||||
exactResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
|
||||
// Sort fuzzy results by score (best fuzzy matches first)
|
||||
additionalFuzzyResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
|
||||
// CRITICAL: Always put exact matches before fuzzy matches, regardless of scores
|
||||
return [...exactResults, ...additionalFuzzyResults];
|
||||
}
|
||||
|
||||
function parseQueryToExpression(query: string, searchContext: SearchContext) {
|
||||
const { fulltextQuery, fulltextTokens, expressionTokens, leadingOperator } = lex(query);
|
||||
searchContext.fulltextQuery = fulltextQuery;
|
||||
|
||||
let structuredExpressionTokens: TokenStructure;
|
||||
|
||||
try {
|
||||
structuredExpressionTokens = handleParens(expressionTokens);
|
||||
} catch (e: any) {
|
||||
structuredExpressionTokens = [];
|
||||
searchContext.addError(e.message);
|
||||
}
|
||||
|
||||
const expression = parse({
|
||||
fulltextTokens,
|
||||
expressionTokens: structuredExpressionTokens,
|
||||
searchContext,
|
||||
originalQuery: query,
|
||||
leadingOperator
|
||||
});
|
||||
|
||||
if (searchContext.debug) {
|
||||
searchContext.debugInfo = {
|
||||
fulltextTokens,
|
||||
structuredExpressionTokens,
|
||||
expression
|
||||
};
|
||||
|
||||
log.info(`Search debug: ${JSON.stringify(searchContext.debugInfo, null, 4)}`);
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
function searchNotes(query: string, params: SearchParams = {}): BNote[] {
|
||||
const searchResults = findResultsWithQuery(query, new SearchContext(params));
|
||||
|
||||
return searchResults.map((sr) => becca.notes[sr.noteId]);
|
||||
}
|
||||
|
||||
function findResultsWithQuery(query: string, searchContext: SearchContext): SearchResult[] {
|
||||
query = query || "";
|
||||
searchContext.originalQuery = query;
|
||||
|
||||
const expression = parseQueryToExpression(query, searchContext);
|
||||
|
||||
if (!expression) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If the query starts with '#', it's a pure expression query.
|
||||
// Don't use progressive search for these as they may have complex
|
||||
// ordering or other logic that shouldn't be interfered with.
|
||||
const isPureExpressionQuery = query.trim().startsWith('#');
|
||||
|
||||
if (isPureExpressionQuery) {
|
||||
// For pure expression queries, use standard search without progressive phases
|
||||
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||
}
|
||||
|
||||
return findResultsWithExpression(expression, searchContext);
|
||||
}
|
||||
|
||||
function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null {
|
||||
const searchResults = findResultsWithQuery(query, searchContext);
|
||||
|
||||
return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null;
|
||||
}
|
||||
|
||||
function extractContentSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Only extract content for text-based notes
|
||||
if (!["text", "code", "mermaid", "canvas", "mindMap"].includes(note.type)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
let content = note.getContent();
|
||||
|
||||
if (!content || typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle protected notes
|
||||
if (note.isProtected && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
try {
|
||||
content = protectedSessionService.decryptString(content) || "";
|
||||
} catch (e) {
|
||||
return ""; // Can't decrypt, don't show content
|
||||
}
|
||||
} else if (note.isProtected) {
|
||||
return ""; // Protected but no session available
|
||||
}
|
||||
|
||||
// Strip HTML tags for text notes
|
||||
if (note.type === "text") {
|
||||
content = striptags(content);
|
||||
}
|
||||
|
||||
// Normalize whitespace while preserving paragraph breaks
|
||||
// First, normalize multiple newlines to double newlines (paragraph breaks)
|
||||
content = content.replace(/\n\s*\n/g, "\n\n");
|
||||
// Then normalize spaces within lines
|
||||
content = content.split('\n').map(line => line.replace(/\s+/g, " ").trim()).join('\n');
|
||||
// Finally trim the whole content
|
||||
content = content.trim();
|
||||
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try to find a snippet around the first matching token
|
||||
const normalizedContent = normalizeString(content.toLowerCase());
|
||||
let snippetStart = 0;
|
||||
let matchFound = false;
|
||||
|
||||
for (const token of searchTokens) {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
const matchIndex = normalizedContent.indexOf(normalizedToken);
|
||||
|
||||
if (matchIndex !== -1) {
|
||||
// Center the snippet around the match
|
||||
snippetStart = Math.max(0, matchIndex - maxLength / 2);
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet
|
||||
let snippet = content.substring(snippetStart, snippetStart + maxLength);
|
||||
|
||||
// If snippet contains linebreaks, limit to max 4 lines and override character limit
|
||||
const lines = snippet.split('\n');
|
||||
if (lines.length > 4) {
|
||||
// Find which lines contain the search tokens to ensure they're included
|
||||
const normalizedLines = lines.map(line => normalizeString(line.toLowerCase()));
|
||||
const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase()));
|
||||
|
||||
// Find the first line that contains a search token
|
||||
let firstMatchLine = -1;
|
||||
for (let i = 0; i < normalizedLines.length; i++) {
|
||||
if (normalizedTokens.some(token => normalizedLines[i].includes(token))) {
|
||||
firstMatchLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstMatchLine !== -1) {
|
||||
// Center the 4-line window around the first match
|
||||
// Try to show 1 line before and 2 lines after the match
|
||||
const startLine = Math.max(0, firstMatchLine - 1);
|
||||
const endLine = Math.min(lines.length, startLine + 4);
|
||||
snippet = lines.slice(startLine, endLine).join('\n');
|
||||
} else {
|
||||
// No match found in lines (shouldn't happen), just take first 4
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
}
|
||||
// Add ellipsis if we truncated lines
|
||||
snippet = `${snippet }...`;
|
||||
} else if (lines.length > 1) {
|
||||
// For multi-line snippets that are 4 or fewer lines, keep them as-is
|
||||
// No need to truncate
|
||||
} else {
|
||||
// Single line content - apply original word boundary logic
|
||||
// Try to start/end at word boundaries
|
||||
if (snippetStart > 0) {
|
||||
const firstSpace = snippet.search(/\s/);
|
||||
if (firstSpace > 0 && firstSpace < 20) {
|
||||
snippet = snippet.substring(firstSpace + 1);
|
||||
}
|
||||
snippet = `...${ snippet}`;
|
||||
}
|
||||
|
||||
if (snippetStart + maxLength < content.length) {
|
||||
const lastSpace = snippet.search(/\s[^\s]*$/);
|
||||
if (lastSpace > snippet.length - 20 && lastSpace > 0) {
|
||||
snippet = snippet.substring(0, lastSpace);
|
||||
}
|
||||
snippet = `${snippet }...`;
|
||||
}
|
||||
}
|
||||
|
||||
return snippet;
|
||||
} catch (e) {
|
||||
log.error(`Error extracting content snippet for note ${noteId}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all attributes for this note
|
||||
const attributes = note.getAttributes();
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const matchingAttributes: Array<{name: string, value: string, type: string}> = [];
|
||||
|
||||
// Look for attributes that match the search tokens
|
||||
for (const attr of attributes) {
|
||||
const attrName = attr.name?.toLowerCase() || "";
|
||||
const attrValue = attr.value?.toLowerCase() || "";
|
||||
const attrType = attr.type || "";
|
||||
|
||||
// Check if any search token matches the attribute name or value
|
||||
const hasMatch = searchTokens.some(token => {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken);
|
||||
});
|
||||
|
||||
if (hasMatch) {
|
||||
matchingAttributes.push({
|
||||
name: attr.name || "",
|
||||
value: attr.value || "",
|
||||
type: attrType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingAttributes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Limit to 4 lines maximum, similar to content snippet logic
|
||||
const lines: string[] = [];
|
||||
for (const attr of matchingAttributes.slice(0, 4)) {
|
||||
let line = "";
|
||||
if (attr.type === "label") {
|
||||
line = attr.value ? `#${attr.name}="${attr.value}"` : `#${attr.name}`;
|
||||
} else if (attr.type === "relation") {
|
||||
// For relations, show the target note title if possible
|
||||
const targetNote = attr.value ? becca.notes[attr.value] : null;
|
||||
const targetTitle = targetNote ? targetNote.title : attr.value;
|
||||
line = `~${attr.name}="${targetTitle}"`;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
let snippet = lines.join('\n');
|
||||
|
||||
// Apply length limit while preserving line structure
|
||||
if (snippet.length > maxLength) {
|
||||
// Try to truncate at word boundaries but keep lines intact
|
||||
const truncated = snippet.substring(0, maxLength);
|
||||
const lastNewline = truncated.lastIndexOf('\n');
|
||||
|
||||
if (lastNewline > maxLength / 2) {
|
||||
// If we can keep most content by truncating to last complete line
|
||||
snippet = truncated.substring(0, lastNewline);
|
||||
} else {
|
||||
// Otherwise just truncate and add ellipsis
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3);
|
||||
snippet = `${snippet }...`;
|
||||
}
|
||||
}
|
||||
|
||||
return snippet;
|
||||
} catch (e) {
|
||||
log.error(`Error extracting attribute snippet for note ${noteId}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch,
|
||||
includeArchivedNotes: false,
|
||||
includeHiddenNotes: true,
|
||||
fuzzyAttributeSearch: true,
|
||||
ignoreInternalAttributes: true,
|
||||
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId()
|
||||
});
|
||||
|
||||
const allSearchResults = findResultsWithQuery(query, searchContext);
|
||||
|
||||
const trimmed = allSearchResults.slice(0, 200);
|
||||
|
||||
// Extract content and attribute snippets
|
||||
for (const result of trimmed) {
|
||||
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
}
|
||||
|
||||
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
|
||||
|
||||
return trimmed.map((result) => {
|
||||
const { title, icon } = becca_service.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 ?? "bx bx-note"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ignoreInternalAttributes whether to ignore certain attributes from the search such as ~internalLink.
|
||||
*/
|
||||
function highlightSearchResults(searchResults: SearchResult[], highlightedTokens: string[], ignoreInternalAttributes = false) {
|
||||
highlightedTokens = Array.from(new Set(highlightedTokens));
|
||||
|
||||
// we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks
|
||||
// which would make the resulting HTML string invalid.
|
||||
// { and } are used for marking <b> and </b> tag (to avoid matches on single 'b' character)
|
||||
// < and > are used for marking <small> and </small>
|
||||
highlightedTokens = highlightedTokens.map((token) => token.replace("/[<\{\}]/g", "")).filter((token) => !!token?.trim());
|
||||
|
||||
// sort by the longest, so we first highlight the longest matches
|
||||
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
|
||||
|
||||
for (const result of searchResults) {
|
||||
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
|
||||
|
||||
// Initialize highlighted content snippet
|
||||
if (result.contentSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
result.highlightedContentSnippet = escapeHtml(result.contentSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, "");
|
||||
}
|
||||
|
||||
// Initialize highlighted attribute snippet
|
||||
if (result.attributeSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function wrapText(text: string, start: number, length: number, prefix: string, suffix: string) {
|
||||
return text.substring(0, start) + prefix + text.substr(start, length) + suffix + text.substring(start + length);
|
||||
}
|
||||
|
||||
for (const token of highlightedTokens) {
|
||||
if (!token) {
|
||||
// Avoid empty tokens, which might cause an infinite loop.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const result of searchResults) {
|
||||
// Reset token
|
||||
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
let match;
|
||||
|
||||
// Highlight in note path title
|
||||
if (result.highlightedNotePathTitle) {
|
||||
const titleRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
|
||||
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
titleRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight in content snippet
|
||||
if (result.highlightedContentSnippet) {
|
||||
const contentRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) {
|
||||
result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
contentRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight in attribute snippet
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
const attributeRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) {
|
||||
result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
attributeRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of searchResults) {
|
||||
if (result.highlightedNotePathTitle) {
|
||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
}
|
||||
|
||||
if (result.highlightedContentSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "<br>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
searchFromNote,
|
||||
searchNotesForAutocomplete,
|
||||
findResultsWithQuery,
|
||||
findFirstNoteWithQuery,
|
||||
searchNotes,
|
||||
extractContentSnippet,
|
||||
extractAttributeSnippet,
|
||||
highlightSearchResults
|
||||
};
|
||||
@@ -1,288 +1,2 @@
|
||||
import attributeService from "./attributes.js";
|
||||
import dateNoteService from "./date_notes.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import noteService from "./notes.js";
|
||||
import dateUtils from "./date_utils.js";
|
||||
import log from "./log.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import searchService from "./search/services/search.js";
|
||||
import SearchContext from "./search/search_context.js";
|
||||
import { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
|
||||
import { t } from "i18next";
|
||||
import BNote from '../becca/entities/bnote.js';
|
||||
import { SaveSearchNoteResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
|
||||
|
||||
function getInboxNote(date: string) {
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
if (!workspaceNote) {
|
||||
throw new Error("Unable to find workspace note");
|
||||
}
|
||||
|
||||
let inbox: BNote;
|
||||
|
||||
if (!workspaceNote.isRoot()) {
|
||||
inbox = workspaceNote.searchNoteInSubtree("#workspaceInbox");
|
||||
|
||||
if (!inbox) {
|
||||
inbox = workspaceNote.searchNoteInSubtree("#inbox");
|
||||
}
|
||||
|
||||
if (!inbox) {
|
||||
inbox = workspaceNote;
|
||||
}
|
||||
} else {
|
||||
inbox = attributeService.getNoteWithLabel("inbox") || dateNoteService.getDayNote(date);
|
||||
}
|
||||
|
||||
return inbox;
|
||||
}
|
||||
|
||||
function createSqlConsole() {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: getMonthlyParentNoteId("_sqlConsole", "sqlConsole"),
|
||||
title: "SQL Console - " + dateUtils.localNowDate(),
|
||||
content: "SELECT title, isDeleted, isProtected FROM notes WHERE noteId = ''\n\n\n\n",
|
||||
type: "code",
|
||||
mime: "text/x-sqlite;schema=trilium"
|
||||
});
|
||||
|
||||
note.setLabel("iconClass", "bx bx-data");
|
||||
note.setLabel("keepCurrentHoisting");
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async function saveSqlConsole(sqlConsoleNoteId: string) {
|
||||
const sqlConsoleNote = becca.getNote(sqlConsoleNoteId);
|
||||
if (!sqlConsoleNote) throw new Error(`Unable to find SQL console note ID: ${sqlConsoleNoteId}`);
|
||||
const today = dateUtils.localNowDate();
|
||||
|
||||
const sqlConsoleHome = attributeService.getNoteWithLabel("sqlConsoleHome") || await dateNoteService.getDayNote(today);
|
||||
|
||||
const result = sqlConsoleNote.cloneTo(sqlConsoleHome.noteId);
|
||||
|
||||
for (const parentBranch of sqlConsoleNote.getParentBranches()) {
|
||||
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
|
||||
parentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
return result satisfies SaveSqlConsoleResponse;
|
||||
}
|
||||
|
||||
function createSearchNote(searchString: string, ancestorNoteId: string) {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: getMonthlyParentNoteId("_search", "search"),
|
||||
title: `${t("special_notes.search_prefix")} ${searchString}`,
|
||||
content: "",
|
||||
type: "search",
|
||||
mime: "application/json"
|
||||
});
|
||||
|
||||
note.setLabel("searchString", searchString);
|
||||
note.setLabel("keepCurrentHoisting");
|
||||
|
||||
if (ancestorNoteId) {
|
||||
note.setRelation("ancestor", ancestorNoteId);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function getSearchHome() {
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
if (!workspaceNote) {
|
||||
throw new Error("Unable to find workspace note");
|
||||
}
|
||||
|
||||
if (!workspaceNote.isRoot()) {
|
||||
return workspaceNote.searchNoteInSubtree("#workspaceSearchHome") || workspaceNote.searchNoteInSubtree("#searchHome") || workspaceNote;
|
||||
} else {
|
||||
const today = dateUtils.localNowDate();
|
||||
|
||||
return workspaceNote.searchNoteInSubtree("#searchHome") || dateNoteService.getDayNote(today);
|
||||
}
|
||||
}
|
||||
|
||||
function saveSearchNote(searchNoteId: string) {
|
||||
const searchNote = becca.getNote(searchNoteId);
|
||||
if (!searchNote) {
|
||||
throw new Error("Unable to find search note");
|
||||
}
|
||||
|
||||
const searchHome = getSearchHome();
|
||||
|
||||
const result = searchNote.cloneTo(searchHome.noteId);
|
||||
|
||||
for (const parentBranch of searchNote.getParentBranches()) {
|
||||
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
|
||||
parentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
return result satisfies SaveSearchNoteResponse;
|
||||
}
|
||||
|
||||
function getMonthlyParentNoteId(rootNoteId: string, prefix: string) {
|
||||
const month = dateUtils.localNowDate().substring(0, 7);
|
||||
const labelName = `${prefix}MonthNote`;
|
||||
|
||||
let monthNote = searchService.findFirstNoteWithQuery(`#${labelName}="${month}"`, new SearchContext({ ancestorNoteId: rootNoteId }));
|
||||
|
||||
if (!monthNote) {
|
||||
monthNote = noteService.createNewNote({
|
||||
parentNoteId: rootNoteId,
|
||||
title: month,
|
||||
content: "",
|
||||
isProtected: false,
|
||||
type: "book"
|
||||
}).note;
|
||||
|
||||
monthNote.addLabel(labelName, month);
|
||||
}
|
||||
|
||||
return monthNote.noteId;
|
||||
}
|
||||
|
||||
function createScriptLauncher(parentNoteId: string, forceNoteId?: string) {
|
||||
const note = noteService.createNewNote({
|
||||
noteId: forceNoteId,
|
||||
title: "Script Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_SCRIPT);
|
||||
return note;
|
||||
}
|
||||
|
||||
export type LauncherType = "launcher" | "note" | "script" | "customWidget" | "spacer";
|
||||
|
||||
interface LauncherConfig {
|
||||
parentNoteId: string;
|
||||
launcherType: LauncherType;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
function createLauncher({ parentNoteId, launcherType, noteId }: LauncherConfig) {
|
||||
let note;
|
||||
|
||||
if (launcherType === "note") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
title: "Note Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_NOTE_LAUNCHER);
|
||||
} else if (launcherType === "script") {
|
||||
note = createScriptLauncher(parentNoteId, noteId);
|
||||
} else if (launcherType === "customWidget") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
title: "Widget Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_CUSTOM_WIDGET);
|
||||
} else if (launcherType === "spacer") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
branchId: noteId,
|
||||
title: "Spacer",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_SPACER);
|
||||
} else {
|
||||
throw new Error(`Unrecognized launcher type '${launcherType}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
note
|
||||
};
|
||||
}
|
||||
|
||||
function resetLauncher(noteId: string) {
|
||||
const note = becca.getNote(noteId);
|
||||
|
||||
if (note?.isLaunchBarConfig()) {
|
||||
if (note) {
|
||||
if (noteId === "_lbRoot" || noteId === "_lbMobileRoot") {
|
||||
// deleting hoisted notes are not allowed, so we just reset the children
|
||||
for (const childNote of note.getChildNotes()) {
|
||||
childNote.deleteNote();
|
||||
}
|
||||
} else {
|
||||
note.deleteNote();
|
||||
}
|
||||
} else {
|
||||
log.info(`Note ${noteId} has not been found and cannot be reset.`);
|
||||
}
|
||||
} else {
|
||||
log.info(`Note ${noteId} is not a resettable launcher note.`);
|
||||
}
|
||||
|
||||
// the re-building deleted launchers will be done in handlers
|
||||
}
|
||||
|
||||
/**
|
||||
* This exists to ease transition into the new launchbar, but it's not meant to be a permanent functionality.
|
||||
* Previously, the launchbar was fixed and the only way to add buttons was through this API, so a lot of buttons have been
|
||||
* created just to fill this user hole.
|
||||
*
|
||||
* Another use case was for script-packages (e.g. demo Task manager) which could this way register automatically/easily
|
||||
* into the launchbar - for this it's recommended to use backend API's createOrUpdateLauncher()
|
||||
*/
|
||||
function createOrUpdateScriptLauncherFromApi(opts: { id: string; title: string; action: string; icon?: string; shortcut?: string }) {
|
||||
if (opts.id && !/^[a-z0-9]+$/i.test(opts.id)) {
|
||||
throw new Error(`Launcher ID can be alphanumeric only, '${opts.id}' given`);
|
||||
}
|
||||
|
||||
const launcherId = opts.id || `tb_${opts.title.toLowerCase().replace(/[^[a-z0-9]/gi, "")}`;
|
||||
|
||||
if (!opts.title) {
|
||||
throw new Error("Title is mandatory property to create or update a launcher.");
|
||||
}
|
||||
|
||||
const launcherNote = becca.getNote(launcherId) || createScriptLauncher("_lbVisibleLaunchers", launcherId);
|
||||
|
||||
launcherNote.title = opts.title;
|
||||
launcherNote.setContent(`(${opts.action})()`);
|
||||
launcherNote.setLabel("scriptInLauncherContent"); // there's no target note, the script is in the launcher's content
|
||||
launcherNote.mime = "application/javascript;env=frontend";
|
||||
launcherNote.save();
|
||||
|
||||
if (opts.shortcut) {
|
||||
launcherNote.setLabel("keyboardShortcut", opts.shortcut);
|
||||
} else {
|
||||
launcherNote.removeLabel("keyboardShortcut");
|
||||
}
|
||||
|
||||
if (opts.icon) {
|
||||
launcherNote.setLabel("iconClass", `bx bx-${opts.icon}`);
|
||||
} else {
|
||||
launcherNote.removeLabel("iconClass");
|
||||
}
|
||||
|
||||
return launcherNote;
|
||||
}
|
||||
|
||||
export default {
|
||||
getInboxNote,
|
||||
createSqlConsole,
|
||||
saveSqlConsole,
|
||||
createSearchNote,
|
||||
saveSearchNote,
|
||||
createLauncher,
|
||||
resetLauncher,
|
||||
createOrUpdateScriptLauncherFromApi
|
||||
};
|
||||
import { special_notes } from "@triliumnext/core";
|
||||
export default special_notes;
|
||||
|
||||
@@ -105,10 +105,6 @@ export function stripTags(text: string) {
|
||||
return text.replace(/<(?:.|\n)*?>/gm, "");
|
||||
}
|
||||
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
export async function crash(message: string) {
|
||||
if (isElectron) {
|
||||
const electron = await import("electron");
|
||||
@@ -450,6 +446,8 @@ function slugify(text: string) {
|
||||
/** @deprecated */
|
||||
export const escapeHtml = coreUtils.escapeHtml;
|
||||
/** @deprecated */
|
||||
export const escapeRegExp = coreUtils.escapeRegExp;
|
||||
/** @deprecated */
|
||||
export const unescapeHtml = coreUtils.unescapeHtml;
|
||||
/** @deprecated */
|
||||
export const randomSecureToken = coreUtils.randomSecureToken;
|
||||
|
||||
@@ -40,6 +40,9 @@ export { default as erase } from "./services/erase";
|
||||
export { default as getSharedBootstrapItems } from "./services/bootstrap_utils";
|
||||
export { default as branches } from "./services/branches";
|
||||
export { default as bulk_actions } from "./services/bulk_actions";
|
||||
export { default as hoisted_note } from "./services/hoisted_note";
|
||||
export { default as special_notes } from "./services/special_notes";
|
||||
export { default as date_notes } from "./services/date_notes";
|
||||
|
||||
export { default as attribute_formatter} from "./services/attribute_formatter";
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { becca_service,ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import bulkActionService from "../../services/bulk_actions.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import SearchContext from "../../services/search/search_context.js";
|
||||
import type SearchResult from "../../services/search/search_result.js";
|
||||
import searchService, { EMPTY_RESULT, type SearchNoteResult } from "../../services/search/services/search.js";
|
||||
import { ValidationError } from "../../errors.js";
|
||||
import becca_service from "../../becca/becca_service.js";
|
||||
import { getHoistedNoteId } from "../../services/context.js";
|
||||
|
||||
function searchFromNote(req: Request<{ noteId: string }>): SearchNoteResult {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
@@ -146,7 +147,7 @@ function getRelatedNotes(req: Request) {
|
||||
}
|
||||
|
||||
function searchTemplates() {
|
||||
const query = cls.getHoistedNoteId() === "root" ? "#template" : "#template OR #workspaceTemplate";
|
||||
const query = getHoistedNoteId() === "root" ? "#template" : "#template OR #workspaceTemplate";
|
||||
|
||||
return searchService
|
||||
.searchNotes(query, {
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import * as cls from "../../services/context.js";
|
||||
import dateNoteService from "../../services/date_notes.js";
|
||||
import specialNotesService, { type LauncherType } from "../../services/special_notes.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import { getSql } from "../../services/sql/index.js";
|
||||
|
||||
function getInboxNote(req: Request<{ date: string }>) {
|
||||
return specialNotesService.getInboxNote(req.params.date);
|
||||
@@ -51,6 +51,7 @@ function getDayNotesForMonth(req: Request) {
|
||||
AND attr.name = 'dateNote'
|
||||
AND attr.value LIKE '${month}%'`;
|
||||
|
||||
const sql = getSql();
|
||||
if (calendarRoot) {
|
||||
const rows = sql.getRows<{ date: string; noteId: string }>(query);
|
||||
const result: Record<string, string> = {};
|
||||
@@ -17,6 +17,8 @@ import revisionsApiRoute from "./api/revisions";
|
||||
import relationMapApiRoute from "./api/relation-map";
|
||||
import recentChangesApiRoute from "./api/recent_changes";
|
||||
import bulkActionRoute from "./api/bulk_action";
|
||||
import searchRoute from "./api/search";
|
||||
import specialNotesRoute from "./api/special_notes";
|
||||
|
||||
// TODO: Deduplicate with routes.ts
|
||||
const GET = "get",
|
||||
@@ -25,7 +27,12 @@ const GET = "get",
|
||||
PATCH = "patch",
|
||||
DEL = "delete";
|
||||
|
||||
export function buildSharedApiRoutes(apiRoute: any) {
|
||||
interface SharedApiRoutesContext {
|
||||
apiRoute: any;
|
||||
asyncApiRoute: any;
|
||||
}
|
||||
|
||||
export function buildSharedApiRoutes({ apiRoute, asyncApiRoute }: SharedApiRoutesContext) {
|
||||
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
|
||||
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
|
||||
|
||||
@@ -92,11 +99,34 @@ export function buildSharedApiRoutes(apiRoute: any) {
|
||||
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
|
||||
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
|
||||
|
||||
apiRoute(GET, "/api/quick-search/:searchString", searchRoute.quickSearch);
|
||||
apiRoute(GET, "/api/search-note/:noteId", searchRoute.searchFromNote);
|
||||
apiRoute(PST, "/api/search-and-execute-note/:noteId", searchRoute.searchAndExecute);
|
||||
apiRoute(PST, "/api/search-related", searchRoute.getRelatedNotes);
|
||||
apiRoute(GET, "/api/search/:searchString", searchRoute.search);
|
||||
apiRoute(GET, "/api/search-templates", searchRoute.searchTemplates);
|
||||
|
||||
apiRoute(PUT, "/api/notes/:noteId/clone-to-branch/:parentBranchId", cloningApiRoute.cloneNoteToBranch);
|
||||
apiRoute(PUT, "/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present", cloningApiRoute.toggleNoteInParent);
|
||||
apiRoute(PUT, "/api/notes/:noteId/clone-to-note/:parentNoteId", cloningApiRoute.cloneNoteToParentNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/clone-after/:afterBranchId", cloningApiRoute.cloneNoteAfter);
|
||||
|
||||
asyncApiRoute(GET, "/api/special-notes/inbox/:date", specialNotesRoute.getInboxNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/days/:date", specialNotesRoute.getDayNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/week-first-day/:date", specialNotesRoute.getWeekFirstDayNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/weeks/:week", specialNotesRoute.getWeekNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/months/:month", specialNotesRoute.getMonthNote);
|
||||
asyncApiRoute(GET, "/api/special-notes/quarters/:quarter", specialNotesRoute.getQuarterNote);
|
||||
apiRoute(GET, "/api/special-notes/years/:year", specialNotesRoute.getYearNote);
|
||||
apiRoute(GET, "/api/special-notes/notes-for-month/:month", specialNotesRoute.getDayNotesForMonth);
|
||||
apiRoute(PST, "/api/special-notes/sql-console", specialNotesRoute.createSqlConsole);
|
||||
asyncApiRoute(PST, "/api/special-notes/save-sql-console", specialNotesRoute.saveSqlConsole);
|
||||
apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote);
|
||||
apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote);
|
||||
apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher);
|
||||
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
|
||||
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
|
||||
|
||||
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
|
||||
|
||||
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
|
||||
|
||||
436
packages/trilium-core/src/services/date_notes.ts
Normal file
436
packages/trilium-core/src/services/date_notes.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
|
||||
import attributeService from "./attributes.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import { dayjs, Dayjs, getFirstDayOfWeek1, getWeekInfo, WeekSettings } from "@triliumnext/commons";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import noteService from "./notes.js";
|
||||
import optionService from "./options.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import searchContext from "../services/search/search_context.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import { getSql } from "./sql/index.js";
|
||||
import { t } from "i18next";
|
||||
import { ordinal } from "./i18n.js";
|
||||
|
||||
const CALENDAR_ROOT_LABEL = "calendarRoot";
|
||||
const YEAR_LABEL = "yearNote";
|
||||
const QUARTER_LABEL = "quarterNote";
|
||||
const MONTH_LABEL = "monthNote";
|
||||
const WEEK_LABEL = "weekNote";
|
||||
const DATE_LABEL = "dateNote";
|
||||
|
||||
const WEEKDAY_TRANSLATION_IDS = [
|
||||
"weekdays.sunday", "weekdays.monday", "weekdays.tuesday",
|
||||
"weekdays.wednesday", "weekdays.thursday", "weekdays.friday",
|
||||
"weekdays.saturday", "weekdays.sunday"
|
||||
];
|
||||
|
||||
const MONTH_TRANSLATION_IDS = [
|
||||
"months.january",
|
||||
"months.february",
|
||||
"months.march",
|
||||
"months.april",
|
||||
"months.may",
|
||||
"months.june",
|
||||
"months.july",
|
||||
"months.august",
|
||||
"months.september",
|
||||
"months.october",
|
||||
"months.november",
|
||||
"months.december"
|
||||
];
|
||||
|
||||
type TimeUnit = "year" | "quarter" | "month" | "week" | "day";
|
||||
|
||||
const baseReplacements = {
|
||||
year: [ "year" ],
|
||||
quarter: [ "quarterNumber", "shortQuarter" ],
|
||||
month: [ "isoMonth", "monthNumber", "monthNumberPadded",
|
||||
"month", "shortMonth3", "shortMonth4" ],
|
||||
week: [ "weekNumber", "weekNumberPadded", "shortWeek", "shortWeek3" ],
|
||||
day: [ "isoDate", "dateNumber", "dateNumberPadded",
|
||||
"ordinal", "weekDay", "weekDay3", "weekDay2" ]
|
||||
};
|
||||
|
||||
function getTimeUnitReplacements(timeUnit: TimeUnit): string[] {
|
||||
const units: TimeUnit[] = [ "year", "quarter", "month", "week", "day" ];
|
||||
const index = units.indexOf(timeUnit);
|
||||
return units.slice(0, index + 1).flatMap(unit => baseReplacements[unit]);
|
||||
}
|
||||
|
||||
function getJournalNoteTitle(
|
||||
rootNote: BNote,
|
||||
timeUnit: TimeUnit,
|
||||
dateObj: Dayjs,
|
||||
number: number,
|
||||
weekYear?: number // Optional: the week year for cross-year weeks
|
||||
) {
|
||||
const patterns = {
|
||||
year: rootNote.getOwnedLabelValue("yearPattern") || "{year}",
|
||||
quarter: rootNote.getOwnedLabelValue("quarterPattern") || t("quarterNumber"),
|
||||
month: rootNote.getOwnedLabelValue("monthPattern") || "{monthNumberPadded} - {month}",
|
||||
week: rootNote.getOwnedLabelValue("weekPattern") || t("weekdayNumber"),
|
||||
day: rootNote.getOwnedLabelValue("datePattern") || "{dateNumberPadded} - {weekDay}"
|
||||
};
|
||||
|
||||
const pattern = patterns[timeUnit];
|
||||
const monthName = t(MONTH_TRANSLATION_IDS[dateObj.month()]);
|
||||
const weekDay = t(WEEKDAY_TRANSLATION_IDS[dateObj.day()]);
|
||||
const numberStr = number.toString();
|
||||
const ordinalStr = ordinal(dateObj);
|
||||
|
||||
// For week notes, use the weekYear if provided (handles cross-year weeks)
|
||||
const yearForDisplay = (timeUnit === "week" && weekYear !== undefined)
|
||||
? weekYear.toString()
|
||||
: dateObj.format("YYYY");
|
||||
|
||||
const allReplacements: Record<string, string> = {
|
||||
// Common date formats
|
||||
"{year}": yearForDisplay,
|
||||
|
||||
// Month related
|
||||
"{isoMonth}": dateObj.format("YYYY-MM"),
|
||||
"{monthNumber}": numberStr,
|
||||
"{monthNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{month}": monthName,
|
||||
"{shortMonth3}": monthName.slice(0, 3),
|
||||
"{shortMonth4}": monthName.slice(0, 4),
|
||||
|
||||
// Quarter related
|
||||
"{quarterNumber}": numberStr,
|
||||
"{shortQuarter}": `Q${numberStr}`,
|
||||
|
||||
// Week related
|
||||
"{weekNumber}": numberStr,
|
||||
"{weekNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{shortWeek}": `W${numberStr}`,
|
||||
"{shortWeek3}": `W${numberStr.padStart(2, "0")}`,
|
||||
|
||||
// Day related
|
||||
"{isoDate}": dateObj.format("YYYY-MM-DD"),
|
||||
"{dateNumber}": numberStr,
|
||||
"{dateNumberPadded}": numberStr.padStart(2, "0"),
|
||||
"{ordinal}": ordinalStr,
|
||||
"{weekDay}": weekDay,
|
||||
"{weekDay3}": weekDay.substring(0, 3),
|
||||
"{weekDay2}": weekDay.substring(0, 2)
|
||||
};
|
||||
|
||||
const allowedReplacements = Object.entries(allReplacements).reduce((acc, [ key, value ]) => {
|
||||
const replacementKey = key.slice(1, -1);
|
||||
if (getTimeUnitReplacements(timeUnit).includes(replacementKey)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return Object.entries(allowedReplacements).reduce(
|
||||
(title, [ key, value ]) => title.replace(new RegExp(key, "g"), value),
|
||||
pattern
|
||||
);
|
||||
}
|
||||
|
||||
function createNote(parentNote: BNote, noteTitle: string) {
|
||||
return noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title: noteTitle,
|
||||
content: "",
|
||||
isProtected: parentNote.isProtected &&
|
||||
protectedSessionService.isProtectedSessionAvailable(),
|
||||
type: "text"
|
||||
}).note;
|
||||
}
|
||||
|
||||
function getRootCalendarNote(): BNote {
|
||||
let rootNote;
|
||||
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
|
||||
if (!workspaceNote || !workspaceNote.isRoot()) {
|
||||
rootNote = searchService.findFirstNoteWithQuery(
|
||||
"#workspaceCalendarRoot", new searchContext({ ignoreHoistedNote: false })
|
||||
);
|
||||
}
|
||||
|
||||
if (!rootNote) {
|
||||
rootNote = attributeService.getNoteWithLabel(CALENDAR_ROOT_LABEL);
|
||||
}
|
||||
|
||||
if (!rootNote) {
|
||||
getSql().transactional(() => {
|
||||
rootNote = noteService.createNewNote({
|
||||
parentNoteId: "root",
|
||||
title: "Calendar",
|
||||
target: "into",
|
||||
isProtected: false,
|
||||
type: "text",
|
||||
content: ""
|
||||
}).note;
|
||||
|
||||
attributeService.createLabel(rootNote.noteId, CALENDAR_ROOT_LABEL);
|
||||
attributeService.createLabel(rootNote.noteId, "sorted");
|
||||
});
|
||||
}
|
||||
|
||||
return rootNote as BNote;
|
||||
}
|
||||
|
||||
function getYearNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
const yearStr = dateStr.trim().substring(0, 4);
|
||||
|
||||
let yearNote = searchService.findFirstNoteWithQuery(
|
||||
`#${YEAR_LABEL}="${yearStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (yearNote) {
|
||||
return yearNote;
|
||||
}
|
||||
|
||||
getSql().transactional(() => {
|
||||
yearNote = createNote(rootNote, yearStr);
|
||||
|
||||
attributeService.createLabel(yearNote.noteId, YEAR_LABEL, yearStr);
|
||||
attributeService.createLabel(yearNote.noteId, "sorted");
|
||||
|
||||
const yearTemplateAttr = rootNote.getOwnedAttribute("relation", "yearTemplate");
|
||||
|
||||
if (yearTemplateAttr) {
|
||||
attributeService.createRelation(yearNote.noteId, "template", yearTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return yearNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getQuarterNumberStr(date: Dayjs) {
|
||||
return `${date.year()}-Q${date.quarter()}`;
|
||||
}
|
||||
|
||||
function getQuarterNote(quarterStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
quarterStr = quarterStr.trim().substring(0, 7);
|
||||
|
||||
let quarterNote = searchService.findFirstNoteWithQuery(
|
||||
`#${QUARTER_LABEL}="${quarterStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (quarterNote) {
|
||||
return quarterNote;
|
||||
}
|
||||
|
||||
const [ yearStr, quarterNumberStr ] = quarterStr.trim().split("-Q");
|
||||
const quarterNumber = parseInt(quarterNumberStr);
|
||||
const firstMonth = (quarterNumber - 1) * 3;
|
||||
const quarterStartDate = dayjs().year(parseInt(yearStr)).month(firstMonth).date(1);
|
||||
|
||||
const yearNote = getYearNote(yearStr, rootNote);
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "quarter", quarterStartDate, quarterNumber
|
||||
);
|
||||
|
||||
getSql().transactional(() => {
|
||||
quarterNote = createNote(yearNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(quarterNote.noteId, QUARTER_LABEL, quarterStr);
|
||||
attributeService.createLabel(quarterNote.noteId, "sorted");
|
||||
|
||||
const quarterTemplateAttr = rootNote.getOwnedAttribute("relation", "quarterTemplate");
|
||||
|
||||
if (quarterTemplateAttr) {
|
||||
attributeService.createRelation(
|
||||
quarterNote.noteId, "template", quarterTemplateAttr.value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return quarterNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
const monthStr = dateStr.substring(0, 7);
|
||||
const monthNumber = dateStr.substring(5, 7);
|
||||
|
||||
let monthNote = searchService.findFirstNoteWithQuery(
|
||||
`#${MONTH_LABEL}="${monthStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (monthNote) {
|
||||
return monthNote;
|
||||
}
|
||||
|
||||
let monthParentNote: BNote | null;
|
||||
|
||||
if (rootNote.hasLabel("enableQuarterNote")) {
|
||||
monthParentNote = getQuarterNote(getQuarterNumberStr(dayjs(dateStr)), rootNote);
|
||||
} else {
|
||||
monthParentNote = getYearNote(dateStr, rootNote);
|
||||
}
|
||||
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "month", dayjs(dateStr), parseInt(monthNumber)
|
||||
);
|
||||
|
||||
getSql().transactional(() => {
|
||||
monthNote = createNote(monthParentNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(monthNote.noteId, MONTH_LABEL, monthStr);
|
||||
attributeService.createLabel(monthNote.noteId, "sorted");
|
||||
|
||||
const monthTemplateAttr = rootNote.getOwnedAttribute("relation", "monthTemplate");
|
||||
|
||||
if (monthTemplateAttr) {
|
||||
attributeService.createRelation(monthNote.noteId, "template", monthTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return monthNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getWeekSettings(): WeekSettings {
|
||||
return {
|
||||
firstDayOfWeek: parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10),
|
||||
firstWeekOfYear: parseInt(optionService.getOptionOrNull("firstWeekOfYear") ?? "0", 10),
|
||||
minDaysInFirstWeek: parseInt(optionService.getOptionOrNull("minDaysInFirstWeek") ?? "4", 10)
|
||||
};
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs): Dayjs {
|
||||
const firstDayISO = parseInt(optionService.getOptionOrNull("firstDayOfWeek") ?? "1", 10);
|
||||
const day = date.isoWeekday();
|
||||
const diff = (day - firstDayISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
|
||||
function getWeekNumberStr(date: Dayjs): string {
|
||||
const { weekYear, weekNumber } = getWeekInfo(date, getWeekSettings());
|
||||
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) {
|
||||
const weekStartDate = getWeekStartDate(dayjs(dateStr));
|
||||
return getDayNote(weekStartDate.format("YYYY-MM-DD"), rootNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link BNote} corresponding to the given week. If there is no note associated yet to that week, it will be created and returned instead.
|
||||
*
|
||||
* @param weekStr the week for which to return the corresponding note, in the format `2024-W04`.
|
||||
* @param _rootNote a {@link BNote} representing the calendar root, or {@code null} or not specified to use the default root calendar note.
|
||||
* @returns a Promise that resolves to the {@link BNote} corresponding to the week note.
|
||||
*/
|
||||
function getWeekNote(weekStr: string, _rootNote: BNote | null = null): BNote | null {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
if (!rootNote.hasLabel("enableWeekNote")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
weekStr = weekStr.trim().substring(0, 8);
|
||||
|
||||
let weekNote = searchService.findFirstNoteWithQuery(
|
||||
`#${WEEK_LABEL}="${weekStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (weekNote) {
|
||||
return weekNote;
|
||||
}
|
||||
|
||||
const [ yearStr, weekNumStr ] = weekStr.trim().split("-W");
|
||||
const weekNumber = parseInt(weekNumStr);
|
||||
const weekYear = parseInt(yearStr);
|
||||
|
||||
// Calculate week start date based on user's first week of year settings.
|
||||
// This correctly handles cross-year weeks based on user preferences.
|
||||
const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, getWeekSettings());
|
||||
const startDate = firstDayOfWeek1.add(weekNumber - 1, "week");
|
||||
const endDate = startDate.add(6, "day");
|
||||
|
||||
const startMonth = startDate.month();
|
||||
const endMonth = endDate.month();
|
||||
|
||||
const monthNote = getMonthNote(startDate.format("YYYY-MM-DD"), rootNote);
|
||||
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber, weekYear);
|
||||
|
||||
getSql().transactional(() => {
|
||||
weekNote = createNote(monthNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(weekNote.noteId, WEEK_LABEL, weekStr);
|
||||
attributeService.createLabel(weekNote.noteId, "sorted");
|
||||
|
||||
const weekTemplateAttr = rootNote.getOwnedAttribute("relation", "weekTemplate");
|
||||
|
||||
if (weekTemplateAttr) {
|
||||
attributeService.createRelation(weekNote.noteId, "template", weekTemplateAttr.value);
|
||||
}
|
||||
|
||||
// If the week spans different months, clone the week note in the other month as well
|
||||
if (startMonth !== endMonth) {
|
||||
const secondMonthNote = getMonthNote(endDate.format("YYYY-MM-DD"), rootNote);
|
||||
cloningService.cloneNoteToParentNote(weekNote.noteId, secondMonthNote.noteId);
|
||||
}
|
||||
});
|
||||
|
||||
return weekNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getDayNote(dateStr: string, _rootNote: BNote | null = null): BNote {
|
||||
const rootNote = _rootNote || getRootCalendarNote();
|
||||
|
||||
dateStr = dateStr.trim().substring(0, 10);
|
||||
|
||||
let dateNote = searchService.findFirstNoteWithQuery(
|
||||
`#${DATE_LABEL}="${dateStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
|
||||
);
|
||||
|
||||
if (dateNote) {
|
||||
return dateNote;
|
||||
}
|
||||
|
||||
let dateParentNote: BNote | null;
|
||||
|
||||
if (rootNote.hasLabel("enableWeekNote")) {
|
||||
dateParentNote = getWeekNote(getWeekNumberStr(dayjs(dateStr)), rootNote);
|
||||
} else {
|
||||
dateParentNote = getMonthNote(dateStr, rootNote);
|
||||
}
|
||||
|
||||
const dayNumber = dateStr.substring(8, 10);
|
||||
const noteTitle = getJournalNoteTitle(
|
||||
rootNote, "day", dayjs(dateStr), parseInt(dayNumber)
|
||||
);
|
||||
|
||||
getSql().transactional(() => {
|
||||
dateNote = createNote(dateParentNote as BNote, noteTitle);
|
||||
|
||||
attributeService.createLabel(dateNote.noteId, DATE_LABEL, dateStr.substring(0, 10));
|
||||
|
||||
const dateTemplateAttr = rootNote.getOwnedAttribute("relation", "dateTemplate");
|
||||
|
||||
if (dateTemplateAttr) {
|
||||
attributeService.createRelation(dateNote.noteId, "template", dateTemplateAttr.value);
|
||||
}
|
||||
});
|
||||
|
||||
return dateNote as unknown as BNote;
|
||||
}
|
||||
|
||||
function getTodayNote(rootNote: BNote | null = null) {
|
||||
return getDayNote(dayjs().format("YYYY-MM-DD"), rootNote);
|
||||
}
|
||||
|
||||
export default {
|
||||
getRootCalendarNote,
|
||||
getYearNote,
|
||||
getQuarterNote,
|
||||
getMonthNote,
|
||||
getWeekNote,
|
||||
getWeekFirstDayNote,
|
||||
getDayNote,
|
||||
getTodayNote,
|
||||
getJournalNoteTitle
|
||||
};
|
||||
40
packages/trilium-core/src/services/hoisted_note.ts
Normal file
40
packages/trilium-core/src/services/hoisted_note.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import becca from "../becca/becca.js";
|
||||
import * as cls from "./context.js";
|
||||
|
||||
function getHoistedNoteId() {
|
||||
return cls.getHoistedNoteId();
|
||||
}
|
||||
|
||||
function isHoistedInHiddenSubtree() {
|
||||
const hoistedNoteId = getHoistedNoteId();
|
||||
|
||||
if (hoistedNoteId === "root") {
|
||||
return false;
|
||||
} else if (hoistedNoteId === "_hidden") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hoistedNote = becca.getNote(hoistedNoteId);
|
||||
|
||||
if (!hoistedNote) {
|
||||
throw new Error(`Cannot find hoisted note '${hoistedNoteId}'`);
|
||||
}
|
||||
|
||||
return hoistedNote.isHiddenCompletely();
|
||||
}
|
||||
|
||||
function getWorkspaceNote() {
|
||||
const hoistedNote = becca.getNote(getHoistedNoteId());
|
||||
|
||||
if (hoistedNote && (hoistedNote.isRoot() || hoistedNote.hasLabel("workspace"))) {
|
||||
return hoistedNote;
|
||||
} else {
|
||||
return becca.getRoot();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getHoistedNoteId,
|
||||
getWorkspaceNote,
|
||||
isHoistedInHiddenSubtree
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
import type BNote from "../becca/entities/bnote";
|
||||
|
||||
export function executeNoteNoException(script: unknown) {
|
||||
console.warn("Skipped script execution");
|
||||
}
|
||||
|
||||
export default {
|
||||
executeNote(scriptNote: BNote, args: {}) {
|
||||
console.warn("Note not executed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Expression from "./expression.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import log from "../../log.js";
|
||||
import log, { getLog } from "../../log.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
|
||||
@@ -24,7 +24,7 @@ class AncestorExp extends Expression {
|
||||
const ancestorNote = becca.notes[this.ancestorNoteId];
|
||||
|
||||
if (!ancestorNote) {
|
||||
log.error(`Subtree note '${this.ancestorNoteId}' was not not found.`);
|
||||
getLog().error(`Subtree note '${this.ancestorNoteId}' was not not found.`);
|
||||
|
||||
return new NoteSet([]);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class AncestorExp extends Expression {
|
||||
} else if (depthCondition.startsWith("lt")) {
|
||||
return (depth) => depth < comparedDepth;
|
||||
} else {
|
||||
log.error(`Unrecognized depth condition value ${depthCondition}`);
|
||||
getLog().error(`Unrecognized depth condition value ${depthCondition}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { NoteRow } from "@triliumnext/commons";
|
||||
|
||||
import becca from "../../../becca/becca.js";
|
||||
import log from "../../log.js";
|
||||
import { getLog } from "../../log.js";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import sql from "../../sql.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
validateFuzzySearchTokens} from "../utils/text_utils.js";
|
||||
import Expression from "./expression.js";
|
||||
import preprocessContent from "./note_content_fulltext_preprocessor.js";
|
||||
import { getSql } from "../../../services/sql/index.js";
|
||||
|
||||
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
||||
|
||||
@@ -80,7 +80,7 @@ class NoteContentFulltextExp extends Expression {
|
||||
const resultNoteSet = new NoteSet();
|
||||
|
||||
// Search through notes with content
|
||||
for (const row of sql.iterateRows<SearchRow>(`
|
||||
for (const row of getSql().iterateRows<SearchRow>(`
|
||||
SELECT noteId, type, mime, content, isProtected
|
||||
FROM notes JOIN blobs USING (blobId)
|
||||
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
@@ -204,7 +204,7 @@ class NoteContentFulltextExp extends Expression {
|
||||
try {
|
||||
content = protectedSessionService.decryptString(content) || undefined;
|
||||
} catch (e) {
|
||||
log.info(`Cannot decrypt content of note ${noteId}`);
|
||||
getLog().info(`Cannot decrypt content of note ${noteId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +327,7 @@ class NoteContentFulltextExp extends Expression {
|
||||
return this.fuzzyMatchToken(token, normalizedContent) ||
|
||||
(this.flatText && this.fuzzyMatchToken(token, flatText));
|
||||
} catch (error) {
|
||||
log.error(`Error in fuzzy matching for note ${noteId}: ${error}`);
|
||||
getLog().error(`Error in fuzzy matching for note ${noteId}: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import { normalize } from "../../utils.js";
|
||||
import { normalizeSearchText } from "../utils/text_utils";
|
||||
import { normalize } from "../../utils/index";
|
||||
|
||||
export default function preprocessContent(rawContent: string | Uint8Array, type: string, mime: string, raw?: boolean) {
|
||||
let content = normalize(rawContent.toString());
|
||||
@@ -77,7 +77,7 @@ function processMindmapContent(content: string) {
|
||||
// Combine topics into a single string
|
||||
const topicsString = topicsArray.join(", ");
|
||||
|
||||
return normalize(topicsString.toString());
|
||||
return normalizeSearchText(topicsString.toString());
|
||||
}
|
||||
|
||||
function processCanvasContent(content: string) {
|
||||
@@ -1,8 +1,6 @@
|
||||
import { becca_service } from "@triliumnext/core";
|
||||
|
||||
import becca_service from "../../../becca/becca_service.js";
|
||||
import becca from "../../../becca/becca.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import { normalize } from "../../utils.js";
|
||||
import NoteSet from "../note_set.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
import { fuzzyMatchWord, fuzzyMatchWordWithResult,normalizeSearchText } from "../utils/text_utils.js";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { becca_service } from "@triliumnext/core";
|
||||
import becca_service from "../../becca/becca_service";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import OrderByAndLimitExp from "../expressions/order_by_and_limit.js";
|
||||
import AncestorExp from "../expressions/ancestor.js";
|
||||
import buildComparator from "./build_comparator.js";
|
||||
import ValueExtractor from "../value_extractor.js";
|
||||
import { removeDiacritic } from "../../utils.js";
|
||||
import { removeDiacritic } from "../../utils/index.js";
|
||||
import TrueExp from "../expressions/true.js";
|
||||
import IsHiddenExp from "../expressions/is_hidden.js";
|
||||
import type SearchContext from "../search_context.js";
|
||||
@@ -1,12 +1,796 @@
|
||||
import BNote from "src/becca/entities/bnote";
|
||||
import normalizeString from "normalize-strings";
|
||||
import striptags from "striptags";
|
||||
|
||||
export default {
|
||||
searchFromNote(note: BNote) {
|
||||
console.warn("Ignore search ", note.title);
|
||||
},
|
||||
import becca from "../../../becca/becca.js";
|
||||
import becca_service from "../../../becca/becca_service.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import hoistedNoteService from "../../hoisted_note.js";
|
||||
import { getLog } from "../../log.js";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import scriptService from "../../script.js";
|
||||
import { escapeHtml, escapeRegExp } from "../../utils/index.js";
|
||||
import type Expression from "../expressions/expression.js";
|
||||
import SearchContext from "../search_context.js";
|
||||
import SearchResult from "../search_result.js";
|
||||
import handleParens from "./handle_parens.js";
|
||||
import lex from "./lex.js";
|
||||
import parse from "./parse.js";
|
||||
import type { SearchParams, TokenStructure } from "./types.js";
|
||||
import { getSql } from "../../sql/index.js";
|
||||
|
||||
export interface SearchNoteResult {
|
||||
searchResultNoteIds: string[];
|
||||
highlightedTokens: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_RESULT: SearchNoteResult = {
|
||||
searchResultNoteIds: [],
|
||||
highlightedTokens: [],
|
||||
error: null
|
||||
};
|
||||
|
||||
function searchFromNote(note: BNote): SearchNoteResult {
|
||||
let searchResultNoteIds;
|
||||
let highlightedTokens: string[];
|
||||
|
||||
const searchScript = note.getRelationValue("searchScript");
|
||||
const searchString = note.getLabelValue("searchString") || "";
|
||||
let error: string | null = null;
|
||||
|
||||
if (searchScript) {
|
||||
searchResultNoteIds = searchFromRelation(note, "searchScript");
|
||||
highlightedTokens = [];
|
||||
} else {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch: note.hasLabel("fastSearch"),
|
||||
ancestorNoteId: note.getRelationValue("ancestor") || undefined,
|
||||
ancestorDepth: note.getLabelValue("ancestorDepth") || undefined,
|
||||
includeArchivedNotes: note.hasLabel("includeArchivedNotes"),
|
||||
orderBy: note.getLabelValue("orderBy") || undefined,
|
||||
orderDirection: note.getLabelValue("orderDirection") || undefined,
|
||||
limit: parseInt(note.getLabelValue("limit") || "0", 10),
|
||||
debug: note.hasLabel("debug"),
|
||||
fuzzyAttributeSearch: false
|
||||
});
|
||||
|
||||
searchResultNoteIds = findResultsWithQuery(searchString, searchContext).map((sr) => sr.noteId);
|
||||
|
||||
highlightedTokens = searchContext.highlightedTokens;
|
||||
error = searchContext.getError();
|
||||
}
|
||||
|
||||
// we won't return search note's own noteId
|
||||
// also don't allow root since that would force infinite cycle
|
||||
return {
|
||||
searchResultNoteIds: searchResultNoteIds.filter((resultNoteId) => !["root", note.noteId].includes(resultNoteId)),
|
||||
highlightedTokens,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
function searchFromRelation(note: BNote, relationName: string) {
|
||||
const scriptNote = note.getRelationTarget(relationName);
|
||||
|
||||
const log = getLog();
|
||||
if (!scriptNote) {
|
||||
log.info(`Search note's relation ${relationName} has not been found.`);
|
||||
|
||||
searchNotes(searchString: string, opts?: {}): BNote[] {
|
||||
console.warn("Ignore search", searchString);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!scriptNote.isJavaScript() || scriptNote.getScriptEnv() !== "backend") {
|
||||
log.info(`Note ${scriptNote.noteId} is not executable.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!note.isContentAvailable()) {
|
||||
log.info(`Note ${scriptNote.noteId} is not available outside of protected session.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = scriptService.executeNote(scriptNote, { originEntity: note });
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
log.info(`Result from ${scriptNote.noteId} is not an array.`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// we expect either array of noteIds (strings) or notes, in that case we extract noteIds ourselves
|
||||
return typeof result[0] === "string" ? result : result.map((item) => item.noteId);
|
||||
}
|
||||
|
||||
function loadNeededInfoFromDatabase() {
|
||||
/**
|
||||
* This complex structure is needed to calculate total occupied space by a note. Several object instances
|
||||
* (note, revisions, attachments) can point to a single blobId, and thus the blob size should count towards the total
|
||||
* only once.
|
||||
*
|
||||
* noteId => { blobId => blobSize }
|
||||
*/
|
||||
const noteBlobs: Record<string, Record<string, number>> = {};
|
||||
|
||||
type NoteContentLengthsRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
};
|
||||
const log = getLog();
|
||||
const noteContentLengths = getSql().getRows<NoteContentLengthsRow>(`
|
||||
SELECT
|
||||
noteId,
|
||||
blobId,
|
||||
LENGTH(content) AS length
|
||||
FROM notes
|
||||
JOIN blobs USING(blobId)
|
||||
WHERE notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length } of noteContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
becca.notes[noteId].contentSize = length;
|
||||
becca.notes[noteId].revisionCount = 0;
|
||||
|
||||
noteBlobs[noteId] = { [blobId]: length };
|
||||
}
|
||||
|
||||
type AttachmentContentLengthsRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
};
|
||||
const attachmentContentLengths = getSql().getRows<AttachmentContentLengthsRow>(`
|
||||
SELECT
|
||||
ownerId AS noteId,
|
||||
attachments.blobId,
|
||||
LENGTH(content) AS length
|
||||
FROM attachments
|
||||
JOIN notes ON attachments.ownerId = notes.noteId
|
||||
JOIN blobs ON attachments.blobId = blobs.blobId
|
||||
WHERE attachments.isDeleted = 0
|
||||
AND notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length } of attachmentContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(noteId in noteBlobs)) {
|
||||
log.error(`Did not find a '${noteId}' in the noteBlobs.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
noteBlobs[noteId][blobId] = length;
|
||||
}
|
||||
|
||||
for (const noteId in noteBlobs) {
|
||||
becca.notes[noteId].contentAndAttachmentsSize = Object.values(noteBlobs[noteId]).reduce((acc, size) => acc + size, 0);
|
||||
}
|
||||
|
||||
type RevisionRow = {
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
length: number;
|
||||
isNoteRevision: true;
|
||||
};
|
||||
const revisionContentLengths = getSql().getRows<RevisionRow>(`
|
||||
SELECT
|
||||
noteId,
|
||||
revisions.blobId,
|
||||
LENGTH(content) AS length,
|
||||
1 AS isNoteRevision
|
||||
FROM notes
|
||||
JOIN revisions USING(noteId)
|
||||
JOIN blobs ON revisions.blobId = blobs.blobId
|
||||
WHERE notes.isDeleted = 0
|
||||
UNION ALL
|
||||
SELECT
|
||||
noteId,
|
||||
revisions.blobId,
|
||||
LENGTH(content) AS length,
|
||||
0 AS isNoteRevision -- it's attachment not counting towards revision count
|
||||
FROM notes
|
||||
JOIN revisions USING(noteId)
|
||||
JOIN attachments ON attachments.ownerId = revisions.revisionId
|
||||
JOIN blobs ON attachments.blobId = blobs.blobId
|
||||
WHERE notes.isDeleted = 0`);
|
||||
|
||||
for (const { noteId, blobId, length, isNoteRevision } of revisionContentLengths) {
|
||||
if (!(noteId in becca.notes)) {
|
||||
log.error(`Note '${noteId}' not found in becca.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(noteId in noteBlobs)) {
|
||||
log.error(`Did not find a '${noteId}' in the noteBlobs.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
noteBlobs[noteId][blobId] = length;
|
||||
|
||||
if (isNoteRevision) {
|
||||
const noteRevision = becca.notes[noteId];
|
||||
if (noteRevision && noteRevision.revisionCount) {
|
||||
noteRevision.revisionCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const noteId in noteBlobs) {
|
||||
becca.notes[noteId].contentAndAttachmentsAndRevisionsSize = Object.values(noteBlobs[noteId]).reduce((acc, size) => acc + size, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function findResultsWithExpression(expression: Expression, searchContext: SearchContext): SearchResult[] {
|
||||
if (searchContext.dbLoadNeeded) {
|
||||
loadNeededInfoFromDatabase();
|
||||
}
|
||||
|
||||
// If there's an explicit orderBy clause, skip progressive search
|
||||
// as it would interfere with the ordering
|
||||
if (searchContext.orderBy) {
|
||||
// For ordered queries, don't use progressive search but respect
|
||||
// the original fuzzy matching setting
|
||||
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||
}
|
||||
|
||||
// If fuzzy matching is explicitly disabled, skip progressive search
|
||||
if (!searchContext.enableFuzzyMatching) {
|
||||
return performSearch(expression, searchContext, false);
|
||||
}
|
||||
|
||||
// Phase 1: Try exact matches first (without fuzzy matching)
|
||||
const exactResults = performSearch(expression, searchContext, false);
|
||||
|
||||
// Check if we have sufficient high-quality results
|
||||
const minResultThreshold = 5;
|
||||
const minScoreForQuality = 10; // Minimum score to consider a result "high quality"
|
||||
|
||||
const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality);
|
||||
|
||||
// If we have enough high-quality exact matches, return them
|
||||
if (highQualityResults.length >= minResultThreshold) {
|
||||
return exactResults;
|
||||
}
|
||||
|
||||
// Phase 2: Add fuzzy matching as fallback when exact matches are insufficient
|
||||
const fuzzyResults = performSearch(expression, searchContext, true);
|
||||
|
||||
// Merge results, ensuring exact matches always rank higher than fuzzy matches
|
||||
return mergeExactAndFuzzyResults(exactResults, fuzzyResults);
|
||||
}
|
||||
|
||||
function performSearch(expression: Expression, searchContext: SearchContext, enableFuzzyMatching: boolean): SearchResult[] {
|
||||
const allNoteSet = becca.getAllNoteSet();
|
||||
|
||||
const noteIdToNotePath: Record<string, string[]> = {};
|
||||
const executionContext = {
|
||||
noteIdToNotePath
|
||||
};
|
||||
|
||||
// Store original fuzzy setting and temporarily override it
|
||||
const originalFuzzyMatching = searchContext.enableFuzzyMatching;
|
||||
searchContext.enableFuzzyMatching = enableFuzzyMatching;
|
||||
|
||||
const noteSet = expression.execute(allNoteSet, executionContext, searchContext);
|
||||
|
||||
const searchResults = noteSet.notes.map((note) => {
|
||||
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
|
||||
|
||||
if (!notePathArray) {
|
||||
throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`);
|
||||
}
|
||||
|
||||
return new SearchResult(notePathArray);
|
||||
});
|
||||
|
||||
for (const res of searchResults) {
|
||||
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching);
|
||||
}
|
||||
|
||||
// Restore original fuzzy setting
|
||||
searchContext.enableFuzzyMatching = originalFuzzyMatching;
|
||||
|
||||
if (!noteSet.sorted) {
|
||||
searchResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
// This is based on the assumption that more important results are closer to the note root.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] {
|
||||
// Create a map of exact result note IDs for deduplication
|
||||
const exactNoteIds = new Set(exactResults.map(result => result.noteId));
|
||||
|
||||
// Add fuzzy results that aren't already in exact results
|
||||
const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId));
|
||||
|
||||
// Sort exact results by score (best exact matches first)
|
||||
exactResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
|
||||
// Sort fuzzy results by score (best fuzzy matches first)
|
||||
additionalFuzzyResults.sort((a, b) => {
|
||||
if (a.score > b.score) {
|
||||
return -1;
|
||||
} else if (a.score < b.score) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if score does not decide then sort results by depth of the note.
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
|
||||
// CRITICAL: Always put exact matches before fuzzy matches, regardless of scores
|
||||
return [...exactResults, ...additionalFuzzyResults];
|
||||
}
|
||||
|
||||
function parseQueryToExpression(query: string, searchContext: SearchContext) {
|
||||
const { fulltextQuery, fulltextTokens, expressionTokens, leadingOperator } = lex(query);
|
||||
searchContext.fulltextQuery = fulltextQuery;
|
||||
|
||||
let structuredExpressionTokens: TokenStructure;
|
||||
|
||||
try {
|
||||
structuredExpressionTokens = handleParens(expressionTokens);
|
||||
} catch (e: any) {
|
||||
structuredExpressionTokens = [];
|
||||
searchContext.addError(e.message);
|
||||
}
|
||||
|
||||
const expression = parse({
|
||||
fulltextTokens,
|
||||
expressionTokens: structuredExpressionTokens,
|
||||
searchContext,
|
||||
originalQuery: query,
|
||||
leadingOperator
|
||||
});
|
||||
|
||||
if (searchContext.debug) {
|
||||
searchContext.debugInfo = {
|
||||
fulltextTokens,
|
||||
structuredExpressionTokens,
|
||||
expression
|
||||
};
|
||||
|
||||
getLog().info(`Search debug: ${JSON.stringify(searchContext.debugInfo, null, 4)}`);
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
|
||||
function searchNotes(query: string, params: SearchParams = {}): BNote[] {
|
||||
const searchResults = findResultsWithQuery(query, new SearchContext(params));
|
||||
|
||||
return searchResults.map((sr) => becca.notes[sr.noteId]);
|
||||
}
|
||||
|
||||
function findResultsWithQuery(query: string, searchContext: SearchContext): SearchResult[] {
|
||||
query = query || "";
|
||||
searchContext.originalQuery = query;
|
||||
|
||||
const expression = parseQueryToExpression(query, searchContext);
|
||||
|
||||
if (!expression) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If the query starts with '#', it's a pure expression query.
|
||||
// Don't use progressive search for these as they may have complex
|
||||
// ordering or other logic that shouldn't be interfered with.
|
||||
const isPureExpressionQuery = query.trim().startsWith('#');
|
||||
|
||||
if (isPureExpressionQuery) {
|
||||
// For pure expression queries, use standard search without progressive phases
|
||||
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
|
||||
}
|
||||
|
||||
return findResultsWithExpression(expression, searchContext);
|
||||
}
|
||||
|
||||
function findFirstNoteWithQuery(query: string, searchContext: SearchContext): BNote | null {
|
||||
const searchResults = findResultsWithQuery(query, searchContext);
|
||||
|
||||
return searchResults.length > 0 ? becca.notes[searchResults[0].noteId] : null;
|
||||
}
|
||||
|
||||
function extractContentSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Only extract content for text-based notes
|
||||
if (!["text", "code", "mermaid", "canvas", "mindMap"].includes(note.type)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
let content = note.getContent();
|
||||
|
||||
if (!content || typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle protected notes
|
||||
if (note.isProtected && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
try {
|
||||
content = protectedSessionService.decryptString(content) || "";
|
||||
} catch (e) {
|
||||
return ""; // Can't decrypt, don't show content
|
||||
}
|
||||
} else if (note.isProtected) {
|
||||
return ""; // Protected but no session available
|
||||
}
|
||||
|
||||
// Strip HTML tags for text notes
|
||||
if (note.type === "text") {
|
||||
content = striptags(content);
|
||||
}
|
||||
|
||||
// Normalize whitespace while preserving paragraph breaks
|
||||
// First, normalize multiple newlines to double newlines (paragraph breaks)
|
||||
content = content.replace(/\n\s*\n/g, "\n\n");
|
||||
// Then normalize spaces within lines
|
||||
content = content.split('\n').map(line => line.replace(/\s+/g, " ").trim()).join('\n');
|
||||
// Finally trim the whole content
|
||||
content = content.trim();
|
||||
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try to find a snippet around the first matching token
|
||||
const normalizedContent = normalizeString(content.toLowerCase());
|
||||
let snippetStart = 0;
|
||||
let matchFound = false;
|
||||
|
||||
for (const token of searchTokens) {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
const matchIndex = normalizedContent.indexOf(normalizedToken);
|
||||
|
||||
if (matchIndex !== -1) {
|
||||
// Center the snippet around the match
|
||||
snippetStart = Math.max(0, matchIndex - maxLength / 2);
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet
|
||||
let snippet = content.substring(snippetStart, snippetStart + maxLength);
|
||||
|
||||
// If snippet contains linebreaks, limit to max 4 lines and override character limit
|
||||
const lines = snippet.split('\n');
|
||||
if (lines.length > 4) {
|
||||
// Find which lines contain the search tokens to ensure they're included
|
||||
const normalizedLines = lines.map(line => normalizeString(line.toLowerCase()));
|
||||
const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase()));
|
||||
|
||||
// Find the first line that contains a search token
|
||||
let firstMatchLine = -1;
|
||||
for (let i = 0; i < normalizedLines.length; i++) {
|
||||
if (normalizedTokens.some(token => normalizedLines[i].includes(token))) {
|
||||
firstMatchLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstMatchLine !== -1) {
|
||||
// Center the 4-line window around the first match
|
||||
// Try to show 1 line before and 2 lines after the match
|
||||
const startLine = Math.max(0, firstMatchLine - 1);
|
||||
const endLine = Math.min(lines.length, startLine + 4);
|
||||
snippet = lines.slice(startLine, endLine).join('\n');
|
||||
} else {
|
||||
// No match found in lines (shouldn't happen), just take first 4
|
||||
snippet = lines.slice(0, 4).join('\n');
|
||||
}
|
||||
// Add ellipsis if we truncated lines
|
||||
snippet = `${snippet }...`;
|
||||
} else if (lines.length > 1) {
|
||||
// For multi-line snippets that are 4 or fewer lines, keep them as-is
|
||||
// No need to truncate
|
||||
} else {
|
||||
// Single line content - apply original word boundary logic
|
||||
// Try to start/end at word boundaries
|
||||
if (snippetStart > 0) {
|
||||
const firstSpace = snippet.search(/\s/);
|
||||
if (firstSpace > 0 && firstSpace < 20) {
|
||||
snippet = snippet.substring(firstSpace + 1);
|
||||
}
|
||||
snippet = `...${ snippet}`;
|
||||
}
|
||||
|
||||
if (snippetStart + maxLength < content.length) {
|
||||
const lastSpace = snippet.search(/\s[^\s]*$/);
|
||||
if (lastSpace > snippet.length - 20 && lastSpace > 0) {
|
||||
snippet = snippet.substring(0, lastSpace);
|
||||
}
|
||||
snippet = `${snippet }...`;
|
||||
}
|
||||
}
|
||||
|
||||
return snippet;
|
||||
} catch (e) {
|
||||
getLog().error(`Error extracting content snippet for note ${noteId}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLength: number = 200): string {
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all attributes for this note
|
||||
const attributes = note.getAttributes();
|
||||
if (!attributes || attributes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const matchingAttributes: Array<{name: string, value: string, type: string}> = [];
|
||||
|
||||
// Look for attributes that match the search tokens
|
||||
for (const attr of attributes) {
|
||||
const attrName = attr.name?.toLowerCase() || "";
|
||||
const attrValue = attr.value?.toLowerCase() || "";
|
||||
const attrType = attr.type || "";
|
||||
|
||||
// Check if any search token matches the attribute name or value
|
||||
const hasMatch = searchTokens.some(token => {
|
||||
const normalizedToken = normalizeString(token.toLowerCase());
|
||||
return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken);
|
||||
});
|
||||
|
||||
if (hasMatch) {
|
||||
matchingAttributes.push({
|
||||
name: attr.name || "",
|
||||
value: attr.value || "",
|
||||
type: attrType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingAttributes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Limit to 4 lines maximum, similar to content snippet logic
|
||||
const lines: string[] = [];
|
||||
for (const attr of matchingAttributes.slice(0, 4)) {
|
||||
let line = "";
|
||||
if (attr.type === "label") {
|
||||
line = attr.value ? `#${attr.name}="${attr.value}"` : `#${attr.name}`;
|
||||
} else if (attr.type === "relation") {
|
||||
// For relations, show the target note title if possible
|
||||
const targetNote = attr.value ? becca.notes[attr.value] : null;
|
||||
const targetTitle = targetNote ? targetNote.title : attr.value;
|
||||
line = `~${attr.name}="${targetTitle}"`;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
let snippet = lines.join('\n');
|
||||
|
||||
// Apply length limit while preserving line structure
|
||||
if (snippet.length > maxLength) {
|
||||
// Try to truncate at word boundaries but keep lines intact
|
||||
const truncated = snippet.substring(0, maxLength);
|
||||
const lastNewline = truncated.lastIndexOf('\n');
|
||||
|
||||
if (lastNewline > maxLength / 2) {
|
||||
// If we can keep most content by truncating to last complete line
|
||||
snippet = truncated.substring(0, lastNewline);
|
||||
} else {
|
||||
// Otherwise just truncate and add ellipsis
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3);
|
||||
snippet = `${snippet }...`;
|
||||
}
|
||||
}
|
||||
|
||||
return snippet;
|
||||
} catch (e) {
|
||||
getLog().error(`Error extracting attribute snippet for note ${noteId}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
|
||||
const searchContext = new SearchContext({
|
||||
fastSearch,
|
||||
includeArchivedNotes: false,
|
||||
includeHiddenNotes: true,
|
||||
fuzzyAttributeSearch: true,
|
||||
ignoreInternalAttributes: true,
|
||||
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId()
|
||||
});
|
||||
|
||||
const allSearchResults = findResultsWithQuery(query, searchContext);
|
||||
|
||||
const trimmed = allSearchResults.slice(0, 200);
|
||||
|
||||
// Extract content and attribute snippets
|
||||
for (const result of trimmed) {
|
||||
result.contentSnippet = extractContentSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
result.attributeSnippet = extractAttributeSnippet(result.noteId, searchContext.highlightedTokens);
|
||||
}
|
||||
|
||||
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
|
||||
|
||||
return trimmed.map((result) => {
|
||||
const { title, icon } = becca_service.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 ?? "bx bx-note"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ignoreInternalAttributes whether to ignore certain attributes from the search such as ~internalLink.
|
||||
*/
|
||||
function highlightSearchResults(searchResults: SearchResult[], highlightedTokens: string[], ignoreInternalAttributes = false) {
|
||||
highlightedTokens = Array.from(new Set(highlightedTokens));
|
||||
|
||||
// we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks
|
||||
// which would make the resulting HTML string invalid.
|
||||
// { and } are used for marking <b> and </b> tag (to avoid matches on single 'b' character)
|
||||
// < and > are used for marking <small> and </small>
|
||||
highlightedTokens = highlightedTokens.map((token) => token.replace("/[<\{\}]/g", "")).filter((token) => !!token?.trim());
|
||||
|
||||
// sort by the longest, so we first highlight the longest matches
|
||||
highlightedTokens.sort((a, b) => (a.length > b.length ? -1 : 1));
|
||||
|
||||
for (const result of searchResults) {
|
||||
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
|
||||
|
||||
// Initialize highlighted content snippet
|
||||
if (result.contentSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
result.highlightedContentSnippet = escapeHtml(result.contentSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, "");
|
||||
}
|
||||
|
||||
// Initialize highlighted attribute snippet
|
||||
if (result.attributeSnippet) {
|
||||
// Escape HTML but preserve newlines for later conversion to <br>
|
||||
result.highlightedAttributeSnippet = escapeHtml(result.attributeSnippet);
|
||||
// Remove any stray < { } that might interfere with our highlighting markers
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/[<{}]/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function wrapText(text: string, start: number, length: number, prefix: string, suffix: string) {
|
||||
return text.substring(0, start) + prefix + text.substr(start, length) + suffix + text.substring(start + length);
|
||||
}
|
||||
|
||||
for (const token of highlightedTokens) {
|
||||
if (!token) {
|
||||
// Avoid empty tokens, which might cause an infinite loop.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const result of searchResults) {
|
||||
// Reset token
|
||||
const tokenRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
let match;
|
||||
|
||||
// Highlight in note path title
|
||||
if (result.highlightedNotePathTitle) {
|
||||
const titleRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = titleRegex.exec(normalizeString(result.highlightedNotePathTitle))) !== null) {
|
||||
result.highlightedNotePathTitle = wrapText(result.highlightedNotePathTitle, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
titleRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight in content snippet
|
||||
if (result.highlightedContentSnippet) {
|
||||
const contentRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = contentRegex.exec(normalizeString(result.highlightedContentSnippet))) !== null) {
|
||||
result.highlightedContentSnippet = wrapText(result.highlightedContentSnippet, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
contentRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight in attribute snippet
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
const attributeRegex = new RegExp(escapeRegExp(token), "gi");
|
||||
while ((match = attributeRegex.exec(normalizeString(result.highlightedAttributeSnippet))) !== null) {
|
||||
result.highlightedAttributeSnippet = wrapText(result.highlightedAttributeSnippet, match.index, token.length, "{", "}");
|
||||
// 2 characters are added, so we need to adjust the index
|
||||
attributeRegex.lastIndex += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of searchResults) {
|
||||
if (result.highlightedNotePathTitle) {
|
||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
}
|
||||
|
||||
if (result.highlightedContentSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
// Replace highlighting markers with HTML tags
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>");
|
||||
// Convert newlines to <br> tags for HTML display
|
||||
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/\n/g, "<br>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
searchFromNote,
|
||||
searchNotesForAutocomplete,
|
||||
findResultsWithQuery,
|
||||
findFirstNoteWithQuery,
|
||||
searchNotes,
|
||||
extractContentSnippet,
|
||||
extractAttributeSnippet,
|
||||
highlightSearchResults
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
import { normalize } from "../../utils.js";
|
||||
import { normalize } from "../../utils/index";
|
||||
|
||||
/**
|
||||
* Shared text processing utilities for search functionality
|
||||
@@ -31,12 +31,12 @@ export const FUZZY_SEARCH_CONFIG = {
|
||||
* Normalizes text by removing diacritics and converting to lowercase.
|
||||
* This is the centralized text normalization function used across all search components.
|
||||
* Uses the shared normalize function from utils for consistency.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* Examples:
|
||||
* - "café" -> "cafe"
|
||||
* - "naïve" -> "naive"
|
||||
* - "HELLO WORLD" -> "hello world"
|
||||
*
|
||||
*
|
||||
* @param text The text to normalize
|
||||
* @returns The normalized text
|
||||
*/
|
||||
@@ -44,7 +44,7 @@ export function normalizeSearchText(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// Use shared normalize function for consistency across the codebase
|
||||
return normalize(text);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function normalizeSearchText(text: string): string {
|
||||
* Optimized edit distance calculation using single array and early termination.
|
||||
* This is significantly more memory efficient than the 2D matrix approach and includes
|
||||
* early termination optimizations for better performance.
|
||||
*
|
||||
*
|
||||
* @param str1 First string
|
||||
* @param str2 Second string
|
||||
* @param maxDistance Maximum allowed distance (for early termination)
|
||||
@@ -64,7 +64,7 @@ export function calculateOptimizedEditDistance(str1: string, str2: string, maxDi
|
||||
if (typeof str1 !== 'string' || typeof str2 !== 'string') {
|
||||
throw new Error('Both arguments must be strings');
|
||||
}
|
||||
|
||||
|
||||
if (maxDistance < 0 || !Number.isInteger(maxDistance)) {
|
||||
throw new Error('maxDistance must be a non-negative integer');
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function calculateOptimizedEditDistance(str1: string, str2: string, maxDi
|
||||
currentRow[j - 1] + 1, // insertion
|
||||
previousRow[j - 1] + cost // substitution
|
||||
);
|
||||
|
||||
|
||||
// Track minimum value in current row for early termination
|
||||
if (currentRow[j] < minInRow) {
|
||||
minInRow = currentRow[j];
|
||||
@@ -125,7 +125,7 @@ export function calculateOptimizedEditDistance(str1: string, str2: string, maxDi
|
||||
|
||||
/**
|
||||
* Validates that tokens meet minimum requirements for fuzzy operators.
|
||||
*
|
||||
*
|
||||
* @param tokens Array of search tokens
|
||||
* @param operator The search operator being used
|
||||
* @returns Validation result with success status and error message
|
||||
@@ -153,10 +153,10 @@ export function validateFuzzySearchTokens(tokens: string[], operator: string): {
|
||||
}
|
||||
|
||||
// Check for null, undefined, or non-string tokens
|
||||
const invalidTypeTokens = tokens.filter(token =>
|
||||
const invalidTypeTokens = tokens.filter(token =>
|
||||
token == null || typeof token !== 'string'
|
||||
);
|
||||
|
||||
|
||||
if (invalidTypeTokens.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -166,7 +166,7 @@ export function validateFuzzySearchTokens(tokens: string[], operator: string): {
|
||||
|
||||
// Check for empty string tokens
|
||||
const emptyTokens = tokens.filter(token => token.trim().length === 0);
|
||||
|
||||
|
||||
if (emptyTokens.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -180,7 +180,7 @@ export function validateFuzzySearchTokens(tokens: string[], operator: string): {
|
||||
|
||||
// Check minimum token length for fuzzy operators
|
||||
const shortTokens = tokens.filter(token => token.length < FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH);
|
||||
|
||||
|
||||
if (shortTokens.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -191,7 +191,7 @@ export function validateFuzzySearchTokens(tokens: string[], operator: string): {
|
||||
// Check for excessively long tokens that could cause performance issues
|
||||
const maxTokenLength = 100; // Reasonable limit for search tokens
|
||||
const longTokens = tokens.filter(token => token.length > maxTokenLength);
|
||||
|
||||
|
||||
if (longTokens.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -205,7 +205,7 @@ export function validateFuzzySearchTokens(tokens: string[], operator: string): {
|
||||
/**
|
||||
* Validates and preprocesses content for search operations.
|
||||
* Philosophy: Try to search everything! Only block truly extreme cases that could crash the system.
|
||||
*
|
||||
*
|
||||
* @param content The content to validate and preprocess
|
||||
* @param noteId The note ID (for logging purposes)
|
||||
* @returns Processed content, only null for truly extreme cases that could cause system instability
|
||||
@@ -258,9 +258,9 @@ function escapeRegExp(string: string): string {
|
||||
/**
|
||||
* Checks if a word matches a token with fuzzy matching and returns the matched word.
|
||||
* Optimized for common case where distances are small.
|
||||
*
|
||||
*
|
||||
* @param token The search token (should be normalized)
|
||||
* @param text The text to match against (should be normalized)
|
||||
* @param text The text to match against (should be normalized)
|
||||
* @param maxDistance Maximum allowed edit distance
|
||||
* @returns The matched word if found, null otherwise
|
||||
*/
|
||||
@@ -269,49 +269,49 @@ export function fuzzyMatchWordWithResult(token: string, text: string, maxDistanc
|
||||
if (typeof token !== 'string' || typeof text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (token.length === 0 || text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Normalize both strings for comparison
|
||||
const normalizedToken = token.toLowerCase();
|
||||
const normalizedText = text.toLowerCase();
|
||||
|
||||
|
||||
// Exact match check first (most common case)
|
||||
if (normalizedText.includes(normalizedToken)) {
|
||||
// Find the exact match in the original text to preserve case
|
||||
const exactMatch = text.match(new RegExp(escapeRegExp(token), 'i'));
|
||||
return exactMatch ? exactMatch[0] : token;
|
||||
}
|
||||
|
||||
|
||||
// For fuzzy matching, we need to check individual words in the text
|
||||
// Split the text into words and check each word against the token
|
||||
const words = normalizedText.split(/\s+/).filter(word => word.length > 0);
|
||||
const originalWords = text.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const originalWord = originalWords[i];
|
||||
|
||||
|
||||
// Skip if word is too different in length for fuzzy matching
|
||||
if (Math.abs(word.length - normalizedToken.length) > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// For very short tokens or very different lengths, be more strict
|
||||
if (normalizedToken.length < 4 || Math.abs(word.length - normalizedToken.length) > 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Use optimized edit distance calculation
|
||||
const distance = calculateOptimizedEditDistance(normalizedToken, word, maxDistance);
|
||||
if (distance <= maxDistance) {
|
||||
return originalWord; // Return the original word with case preserved
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Log error and return null for safety
|
||||
@@ -323,7 +323,7 @@ export function fuzzyMatchWordWithResult(token: string, text: string, maxDistanc
|
||||
/**
|
||||
* Checks if a word matches a token with fuzzy matching.
|
||||
* Optimized for common case where distances are small.
|
||||
*
|
||||
*
|
||||
* @param token The search token (should be normalized)
|
||||
* @param word The word to match against (should be normalized)
|
||||
* @param maxDistance Maximum allowed edit distance
|
||||
@@ -331,4 +331,4 @@ export function fuzzyMatchWordWithResult(token: string, text: string, maxDistanc
|
||||
*/
|
||||
export function fuzzyMatchWord(token: string, text: string, maxDistance: number = FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE): boolean {
|
||||
return fuzzyMatchWordWithResult(token, text, maxDistance) !== null;
|
||||
}
|
||||
}
|
||||
288
packages/trilium-core/src/services/special_notes.ts
Normal file
288
packages/trilium-core/src/services/special_notes.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import attributeService from "./attributes.js";
|
||||
import dateNoteService from "./date_notes.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import noteService from "./notes.js";
|
||||
import dateUtils from "./utils/date.js";
|
||||
import { getLog } from "./log.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import searchService from "./search/services/search.js";
|
||||
import SearchContext from "./search/search_context.js";
|
||||
import { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
|
||||
import { t } from "i18next";
|
||||
import BNote from '../becca/entities/bnote.js';
|
||||
import { SaveSearchNoteResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
|
||||
|
||||
function getInboxNote(date: string) {
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
if (!workspaceNote) {
|
||||
throw new Error("Unable to find workspace note");
|
||||
}
|
||||
|
||||
let inbox: BNote;
|
||||
|
||||
if (!workspaceNote.isRoot()) {
|
||||
inbox = workspaceNote.searchNoteInSubtree("#workspaceInbox");
|
||||
|
||||
if (!inbox) {
|
||||
inbox = workspaceNote.searchNoteInSubtree("#inbox");
|
||||
}
|
||||
|
||||
if (!inbox) {
|
||||
inbox = workspaceNote;
|
||||
}
|
||||
} else {
|
||||
inbox = attributeService.getNoteWithLabel("inbox") || dateNoteService.getDayNote(date);
|
||||
}
|
||||
|
||||
return inbox;
|
||||
}
|
||||
|
||||
function createSqlConsole() {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: getMonthlyParentNoteId("_sqlConsole", "sqlConsole"),
|
||||
title: "SQL Console - " + dateUtils.localNowDate(),
|
||||
content: "SELECT title, isDeleted, isProtected FROM notes WHERE noteId = ''\n\n\n\n",
|
||||
type: "code",
|
||||
mime: "text/x-sqlite;schema=trilium"
|
||||
});
|
||||
|
||||
note.setLabel("iconClass", "bx bx-data");
|
||||
note.setLabel("keepCurrentHoisting");
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async function saveSqlConsole(sqlConsoleNoteId: string) {
|
||||
const sqlConsoleNote = becca.getNote(sqlConsoleNoteId);
|
||||
if (!sqlConsoleNote) throw new Error(`Unable to find SQL console note ID: ${sqlConsoleNoteId}`);
|
||||
const today = dateUtils.localNowDate();
|
||||
|
||||
const sqlConsoleHome = attributeService.getNoteWithLabel("sqlConsoleHome") || await dateNoteService.getDayNote(today);
|
||||
|
||||
const result = sqlConsoleNote.cloneTo(sqlConsoleHome.noteId);
|
||||
|
||||
for (const parentBranch of sqlConsoleNote.getParentBranches()) {
|
||||
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
|
||||
parentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
return result satisfies SaveSqlConsoleResponse;
|
||||
}
|
||||
|
||||
function createSearchNote(searchString: string, ancestorNoteId: string) {
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: getMonthlyParentNoteId("_search", "search"),
|
||||
title: `${t("special_notes.search_prefix")} ${searchString}`,
|
||||
content: "",
|
||||
type: "search",
|
||||
mime: "application/json"
|
||||
});
|
||||
|
||||
note.setLabel("searchString", searchString);
|
||||
note.setLabel("keepCurrentHoisting");
|
||||
|
||||
if (ancestorNoteId) {
|
||||
note.setRelation("ancestor", ancestorNoteId);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function getSearchHome() {
|
||||
const workspaceNote = hoistedNoteService.getWorkspaceNote();
|
||||
if (!workspaceNote) {
|
||||
throw new Error("Unable to find workspace note");
|
||||
}
|
||||
|
||||
if (!workspaceNote.isRoot()) {
|
||||
return workspaceNote.searchNoteInSubtree("#workspaceSearchHome") || workspaceNote.searchNoteInSubtree("#searchHome") || workspaceNote;
|
||||
} else {
|
||||
const today = dateUtils.localNowDate();
|
||||
|
||||
return workspaceNote.searchNoteInSubtree("#searchHome") || dateNoteService.getDayNote(today);
|
||||
}
|
||||
}
|
||||
|
||||
function saveSearchNote(searchNoteId: string) {
|
||||
const searchNote = becca.getNote(searchNoteId);
|
||||
if (!searchNote) {
|
||||
throw new Error("Unable to find search note");
|
||||
}
|
||||
|
||||
const searchHome = getSearchHome();
|
||||
|
||||
const result = searchNote.cloneTo(searchHome.noteId);
|
||||
|
||||
for (const parentBranch of searchNote.getParentBranches()) {
|
||||
if (parentBranch.parentNote?.hasAncestor("_hidden")) {
|
||||
parentBranch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
return result satisfies SaveSearchNoteResponse;
|
||||
}
|
||||
|
||||
function getMonthlyParentNoteId(rootNoteId: string, prefix: string) {
|
||||
const month = dateUtils.localNowDate().substring(0, 7);
|
||||
const labelName = `${prefix}MonthNote`;
|
||||
|
||||
let monthNote = searchService.findFirstNoteWithQuery(`#${labelName}="${month}"`, new SearchContext({ ancestorNoteId: rootNoteId }));
|
||||
|
||||
if (!monthNote) {
|
||||
monthNote = noteService.createNewNote({
|
||||
parentNoteId: rootNoteId,
|
||||
title: month,
|
||||
content: "",
|
||||
isProtected: false,
|
||||
type: "book"
|
||||
}).note;
|
||||
|
||||
monthNote.addLabel(labelName, month);
|
||||
}
|
||||
|
||||
return monthNote.noteId;
|
||||
}
|
||||
|
||||
function createScriptLauncher(parentNoteId: string, forceNoteId?: string) {
|
||||
const note = noteService.createNewNote({
|
||||
noteId: forceNoteId,
|
||||
title: "Script Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_SCRIPT);
|
||||
return note;
|
||||
}
|
||||
|
||||
export type LauncherType = "launcher" | "note" | "script" | "customWidget" | "spacer";
|
||||
|
||||
interface LauncherConfig {
|
||||
parentNoteId: string;
|
||||
launcherType: LauncherType;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
function createLauncher({ parentNoteId, launcherType, noteId }: LauncherConfig) {
|
||||
let note;
|
||||
|
||||
if (launcherType === "note") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
title: "Note Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_NOTE_LAUNCHER);
|
||||
} else if (launcherType === "script") {
|
||||
note = createScriptLauncher(parentNoteId, noteId);
|
||||
} else if (launcherType === "customWidget") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
title: "Widget Launcher",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_CUSTOM_WIDGET);
|
||||
} else if (launcherType === "spacer") {
|
||||
note = noteService.createNewNote({
|
||||
noteId: noteId,
|
||||
branchId: noteId,
|
||||
title: "Spacer",
|
||||
type: "launcher",
|
||||
content: "",
|
||||
parentNoteId: parentNoteId
|
||||
}).note;
|
||||
|
||||
note.addRelation("template", LBTPL_SPACER);
|
||||
} else {
|
||||
throw new Error(`Unrecognized launcher type '${launcherType}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
note
|
||||
};
|
||||
}
|
||||
|
||||
function resetLauncher(noteId: string) {
|
||||
const note = becca.getNote(noteId);
|
||||
|
||||
if (note?.isLaunchBarConfig()) {
|
||||
if (note) {
|
||||
if (noteId === "_lbRoot" || noteId === "_lbMobileRoot") {
|
||||
// deleting hoisted notes are not allowed, so we just reset the children
|
||||
for (const childNote of note.getChildNotes()) {
|
||||
childNote.deleteNote();
|
||||
}
|
||||
} else {
|
||||
note.deleteNote();
|
||||
}
|
||||
} else {
|
||||
getLog().info(`Note ${noteId} has not been found and cannot be reset.`);
|
||||
}
|
||||
} else {
|
||||
getLog().info(`Note ${noteId} is not a resettable launcher note.`);
|
||||
}
|
||||
|
||||
// the re-building deleted launchers will be done in handlers
|
||||
}
|
||||
|
||||
/**
|
||||
* This exists to ease transition into the new launchbar, but it's not meant to be a permanent functionality.
|
||||
* Previously, the launchbar was fixed and the only way to add buttons was through this API, so a lot of buttons have been
|
||||
* created just to fill this user hole.
|
||||
*
|
||||
* Another use case was for script-packages (e.g. demo Task manager) which could this way register automatically/easily
|
||||
* into the launchbar - for this it's recommended to use backend API's createOrUpdateLauncher()
|
||||
*/
|
||||
function createOrUpdateScriptLauncherFromApi(opts: { id: string; title: string; action: string; icon?: string; shortcut?: string }) {
|
||||
if (opts.id && !/^[a-z0-9]+$/i.test(opts.id)) {
|
||||
throw new Error(`Launcher ID can be alphanumeric only, '${opts.id}' given`);
|
||||
}
|
||||
|
||||
const launcherId = opts.id || `tb_${opts.title.toLowerCase().replace(/[^[a-z0-9]/gi, "")}`;
|
||||
|
||||
if (!opts.title) {
|
||||
throw new Error("Title is mandatory property to create or update a launcher.");
|
||||
}
|
||||
|
||||
const launcherNote = becca.getNote(launcherId) || createScriptLauncher("_lbVisibleLaunchers", launcherId);
|
||||
|
||||
launcherNote.title = opts.title;
|
||||
launcherNote.setContent(`(${opts.action})()`);
|
||||
launcherNote.setLabel("scriptInLauncherContent"); // there's no target note, the script is in the launcher's content
|
||||
launcherNote.mime = "application/javascript;env=frontend";
|
||||
launcherNote.save();
|
||||
|
||||
if (opts.shortcut) {
|
||||
launcherNote.setLabel("keyboardShortcut", opts.shortcut);
|
||||
} else {
|
||||
launcherNote.removeLabel("keyboardShortcut");
|
||||
}
|
||||
|
||||
if (opts.icon) {
|
||||
launcherNote.setLabel("iconClass", `bx bx-${opts.icon}`);
|
||||
} else {
|
||||
launcherNote.removeLabel("iconClass");
|
||||
}
|
||||
|
||||
return launcherNote;
|
||||
}
|
||||
|
||||
export default {
|
||||
getInboxNote,
|
||||
createSqlConsole,
|
||||
saveSqlConsole,
|
||||
createSearchNote,
|
||||
saveSearchNote,
|
||||
createLauncher,
|
||||
resetLauncher,
|
||||
createOrUpdateScriptLauncherFromApi
|
||||
};
|
||||
@@ -135,3 +135,7 @@ export function isEmptyOrWhitespace(str: string | null | undefined) {
|
||||
if (!str) return true;
|
||||
return str.match(/^ *$/) !== null;
|
||||
}
|
||||
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user