Compare commits

..

3 Commits

Author SHA1 Message Date
Elian Doran
0b5ce95093 fix(standalone): some sql queries not executing properly 2026-03-22 15:48:40 +02:00
Elian Doran
77971a10d1 feat(core): integrate special notes with route 2026-03-22 14:30:33 +02:00
Elian Doran
28a56ff7bf feat(core): integrate search with route 2026-03-22 14:03:48 +02:00
56 changed files with 1694 additions and 1659 deletions

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
import { NoteSet } from "@triliumnext/core";
export default NoteSet;

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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, {

View File

@@ -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> = {};

View File

@@ -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);

View 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
};

View 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
};

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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";

View File

@@ -1,4 +1,4 @@
import { becca_service } from "@triliumnext/core";
import becca_service from "../../becca/becca_service";
import becca from "../../becca/becca.js";
import {

View File

@@ -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";

View File

@@ -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
};

View File

@@ -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;
}
}

View 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
};

View File

@@ -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");
}