Files
Trilium/apps/server/src/services/date_notes.ts

502 lines
17 KiB
TypeScript
Raw Normal View History

2025-04-04 13:55:13 +02:00
import type BNote from "../becca/entities/bnote.js";
import type { Dayjs } from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat.js";
import attributeService from "./attributes.js";
2025-04-04 13:55:13 +02:00
import cloningService from "./cloning.js";
import dayjs from "dayjs";
import hoistedNoteService from "./hoisted_note.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
2025-04-04 13:55:13 +02:00
import noteService from "./notes.js";
import optionService from "./options.js";
import protectedSessionService from "./protected_session.js";
import quarterOfYear from "dayjs/plugin/quarterOfYear.js";
2025-04-04 13:55:13 +02:00
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";
dayjs.extend(isSameOrAfter);
dayjs.extend(quarterOfYear);
dayjs.extend(advancedFormat);
2025-01-09 18:07:02 +02:00
const CALENDAR_ROOT_LABEL = "calendarRoot";
const YEAR_LABEL = "yearNote";
2025-04-01 18:45:09 +02:00
const QUARTER_LABEL = "quarterNote";
2025-01-09 18:07:02 +02:00
const MONTH_LABEL = "monthNote";
const WEEK_LABEL = "weekNote";
2025-01-09 18:07:02 +02:00
const DATE_LABEL = "dateNote";
2025-04-04 13:59:01 +02:00
const WEEKDAY_TRANSLATION_IDS = [
"weekdays.sunday", "weekdays.monday", "weekdays.tuesday",
"weekdays.wednesday", "weekdays.thursday", "weekdays.friday",
"weekdays.saturday", "weekdays.sunday"
];
const MONTH_TRANSLATION_IDS = [
2025-01-09 18:07:02 +02:00
"months.january",
"months.february",
"months.march",
"months.april",
"months.may",
"months.june",
"months.july",
"months.august",
"months.september",
"months.october",
"months.november",
"months.december"
];
2025-04-04 13:55:13 +02:00
type TimeUnit = "year" | "quarter" | "month" | "week" | "day";
2025-04-01 18:56:33 +02:00
2025-04-01 19:25:58 +02:00
const baseReplacements = {
2025-04-04 13:55:13 +02:00
year: [ "year" ],
quarter: [ "quarterNumber", "shortQuarter" ],
2025-04-04 13:59:01 +02:00
month: [ "isoMonth", "monthNumber", "monthNumberPadded",
"month", "shortMonth3", "shortMonth4" ],
2025-04-04 13:55:13 +02:00
week: [ "weekNumber", "weekNumberPadded", "shortWeek", "shortWeek3" ],
2025-04-04 13:59:01 +02:00
day: [ "isoDate", "dateNumber", "dateNumberPadded",
"ordinal", "weekDay", "weekDay3", "weekDay2" ]
2025-04-01 19:25:58 +02:00
};
function getTimeUnitReplacements(timeUnit: TimeUnit): string[] {
2025-04-04 13:55:13 +02:00
const units: TimeUnit[] = [ "year", "quarter", "month", "week", "day" ];
2025-04-01 19:25:58 +02:00
const index = units.indexOf(timeUnit);
return units.slice(0, index + 1).flatMap(unit => baseReplacements[unit]);
}
function getJournalNoteTitle(
2025-04-04 13:59:01 +02:00
rootNote: BNote,
timeUnit: TimeUnit,
dateObj: Dayjs,
number: number
) {
2025-04-01 18:56:33 +02:00
const patterns = {
year: rootNote.getOwnedLabelValue("yearPattern") || "{year}",
2025-04-04 13:55:13 +02:00
quarter: rootNote.getOwnedLabelValue("quarterPattern") || t("quarterNumber"),
2025-04-01 18:56:33 +02:00
month: rootNote.getOwnedLabelValue("monthPattern") || "{monthNumberPadded} - {month}",
2025-04-04 13:55:13 +02:00
week: rootNote.getOwnedLabelValue("weekPattern") || t("weekdayNumber"),
day: rootNote.getOwnedLabelValue("datePattern") || "{dateNumberPadded} - {weekDay}"
2025-04-01 18:56:33 +02:00
};
const pattern = patterns[timeUnit];
const monthName = t(MONTH_TRANSLATION_IDS[dateObj.month()]);
const weekDay = t(WEEKDAY_TRANSLATION_IDS[dateObj.day()]);
2025-04-02 12:36:39 +02:00
const numberStr = number.toString();
const ordinalStr = ordinal(dateObj);
2025-04-01 18:56:33 +02:00
2025-04-01 19:25:58 +02:00
const allReplacements: Record<string, string> = {
2025-04-01 18:56:33 +02:00
// Common date formats
2025-04-04 13:55:13 +02:00
"{year}": dateObj.format("YYYY"),
2025-04-01 18:56:33 +02:00
// Month related
2025-04-04 13:55:13 +02:00
"{isoMonth}": dateObj.format("YYYY-MM"),
"{monthNumber}": numberStr,
"{monthNumberPadded}": numberStr.padStart(2, "0"),
"{month}": monthName,
"{shortMonth3}": monthName.slice(0, 3),
"{shortMonth4}": monthName.slice(0, 4),
2025-04-01 18:56:33 +02:00
// Quarter related
2025-04-04 13:55:13 +02:00
"{quarterNumber}": numberStr,
"{shortQuarter}": `Q${numberStr}`,
2025-04-01 18:56:33 +02:00
// Week related
2025-04-04 13:55:13 +02:00
"{weekNumber}": numberStr,
"{weekNumberPadded}": numberStr.padStart(2, "0"),
"{shortWeek}": `W${numberStr}`,
"{shortWeek3}": `W${numberStr.padStart(2, "0")}`,
2025-04-01 18:56:33 +02:00
// Day related
2025-04-04 13:55:13 +02:00
"{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)
2025-04-01 18:56:33 +02:00
};
2025-04-04 13:55:13 +02:00
const allowedReplacements = Object.entries(allReplacements).reduce((acc, [ key, value ]) => {
2025-04-01 19:25:58 +02:00
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(
2025-04-04 13:55:13 +02:00
(title, [ key, value ]) => title.replace(new RegExp(key, "g"), value),
2025-04-01 18:56:33 +02:00
pattern
);
}
2024-02-18 13:42:05 +02:00
function createNote(parentNote: BNote, noteTitle: string) {
return noteService.createNewNote({
parentNoteId: parentNote.noteId,
2018-01-28 19:30:14 -05:00
title: noteTitle,
2025-01-09 18:07:02 +02:00
content: "",
2025-04-04 13:59:01 +02:00
isProtected: parentNote.isProtected &&
protectedSessionService.isProtectedSessionAvailable(),
2025-01-09 18:07:02 +02:00
type: "text"
}).note;
}
2024-02-18 13:42:05 +02:00
function getRootCalendarNote(): BNote {
let rootNote;
2022-12-23 15:07:48 +01:00
const workspaceNote = hoistedNoteService.getWorkspaceNote();
2024-02-18 13:42:05 +02:00
if (!workspaceNote || !workspaceNote.isRoot()) {
2025-04-04 13:59:01 +02:00
rootNote = searchService.findFirstNoteWithQuery(
"#workspaceCalendarRoot", new searchContext({ ignoreHoistedNote: false })
);
}
if (!rootNote) {
rootNote = attributeService.getNoteWithLabel(CALENDAR_ROOT_LABEL);
}
if (!rootNote) {
2020-08-18 21:32:45 +02:00
sql.transactional(() => {
rootNote = noteService.createNewNote({
2025-01-09 18:07:02 +02:00
parentNoteId: "root",
title: "Calendar",
target: "into",
2020-08-18 21:32:45 +02:00
isProtected: false,
2025-01-09 18:07:02 +02:00
type: "text",
content: ""
2020-08-18 21:32:45 +02:00
}).note;
attributeService.createLabel(rootNote.noteId, CALENDAR_ROOT_LABEL);
2025-01-09 18:07:02 +02:00
attributeService.createLabel(rootNote.noteId, "sorted");
2020-08-18 21:32:45 +02:00
});
}
2024-02-18 13:42:05 +02:00
return rootNote as BNote;
}
2024-02-18 13:42:05 +02:00
function getYearNote(dateStr: string, _rootNote: BNote | null = null): BNote {
const rootNote = _rootNote || getRootCalendarNote();
2025-04-01 00:08:48 +02:00
const yearStr = dateStr.trim().substring(0, 4);
2025-04-04 13:59:01 +02:00
let yearNote = searchService.findFirstNoteWithQuery(
`#${YEAR_LABEL}="${yearStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
);
2021-07-24 11:28:47 +02:00
if (yearNote) {
return yearNote;
}
2021-07-24 11:28:47 +02:00
sql.transactional(() => {
yearNote = createNote(rootNote, yearStr);
2021-07-24 11:28:47 +02:00
attributeService.createLabel(yearNote.noteId, YEAR_LABEL, yearStr);
2025-01-09 18:07:02 +02:00
attributeService.createLabel(yearNote.noteId, "sorted");
2025-01-09 18:07:02 +02:00
const yearTemplateAttr = rootNote.getOwnedAttribute("relation", "yearTemplate");
2021-07-24 11:28:47 +02:00
if (yearTemplateAttr) {
2025-01-09 18:07:02 +02:00
attributeService.createRelation(yearNote.noteId, "template", yearTemplateAttr.value);
}
2021-07-24 11:28:47 +02:00
});
2024-02-18 13:42:05 +02:00
return yearNote as unknown as BNote;
}
2025-04-01 18:45:09 +02:00
function getQuarterNumberStr(date: Dayjs) {
return `${date.year()}-Q${date.quarter()}`;
2025-04-01 18:45:09 +02:00
}
function getQuarterNote(quarterStr: string, _rootNote: BNote | null = null): BNote {
2025-04-01 18:45:09 +02:00
const rootNote = _rootNote || getRootCalendarNote();
2025-04-01 19:20:08 +02:00
quarterStr = quarterStr.trim().substring(0, 7);
2025-04-01 18:45:09 +02:00
2025-04-04 13:59:01 +02:00
let quarterNote = searchService.findFirstNoteWithQuery(
`#${QUARTER_LABEL}="${quarterStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
);
2025-04-01 18:45:09 +02:00
if (quarterNote) {
return quarterNote;
}
2025-04-04 13:55:13 +02:00
const [ yearStr, quarterNumberStr ] = quarterStr.trim().split("-Q");
2025-04-01 19:20:08 +02:00
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(
2025-04-04 13:59:01 +02:00
rootNote, "quarter", quarterStartDate, quarterNumber
);
2025-04-01 18:45:09 +02:00
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) {
2025-04-04 13:59:01 +02:00
attributeService.createRelation(
quarterNote.noteId, "template", quarterTemplateAttr.value
);
2025-04-01 18:45:09 +02:00
}
});
return quarterNote as unknown as BNote;
}
function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
2024-02-18 13:42:05 +02:00
const rootNote = _rootNote || getRootCalendarNote();
2025-04-01 00:08:48 +02:00
const monthStr = dateStr.substring(0, 7);
2025-04-01 15:33:10 +02:00
const monthNumber = dateStr.substring(5, 7);
2025-04-04 13:59:01 +02:00
let monthNote = searchService.findFirstNoteWithQuery(
`#${MONTH_LABEL}="${monthStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
);
2021-07-24 11:28:47 +02:00
if (monthNote) {
return monthNote;
}
2025-06-04 10:33:40 +02:00
let monthParentNote: BNote | null;
2025-04-04 13:55:13 +02:00
if (rootNote.hasLabel("enableQuarterNote")) {
monthParentNote = getQuarterNote(getQuarterNumberStr(dayjs(dateStr)), rootNote);
2025-04-01 18:45:09 +02:00
} else {
monthParentNote = getYearNote(dateStr, rootNote);
}
const noteTitle = getJournalNoteTitle(
2025-04-04 13:59:01 +02:00
rootNote, "month", dayjs(dateStr), parseInt(monthNumber)
);
2021-12-11 14:15:38 +01:00
2021-07-24 11:28:47 +02:00
sql.transactional(() => {
2025-04-01 18:45:09 +02:00
monthNote = createNote(monthParentNote, noteTitle);
2021-07-24 11:28:47 +02:00
attributeService.createLabel(monthNote.noteId, MONTH_LABEL, monthStr);
2025-01-09 18:07:02 +02:00
attributeService.createLabel(monthNote.noteId, "sorted");
2025-01-09 18:07:02 +02:00
const monthTemplateAttr = rootNote.getOwnedAttribute("relation", "monthTemplate");
2021-07-24 11:28:47 +02:00
if (monthTemplateAttr) {
2025-01-09 18:07:02 +02:00
attributeService.createRelation(monthNote.noteId, "template", monthTemplateAttr.value);
}
2021-07-24 11:28:47 +02:00
});
2024-02-18 13:42:05 +02:00
return monthNote as unknown as BNote;
}
function getWeekStartDate(date: Dayjs): Dayjs {
const day = date.day();
2025-06-04 10:33:40 +02:00
let diff: number;
2025-04-01 16:57:52 +02:00
if (optionService.getOption("firstDayOfWeek") === "0") { // Sunday
diff = date.date() - day + (day === 0 ? -6 : 1); // adjust when day is sunday
} else { // Monday
diff = date.date() - day;
2025-04-01 16:57:52 +02:00
}
const startDate = date.clone().date(diff);
2025-04-01 16:57:52 +02:00
return startDate;
}
// TODO: Duplicated with getWeekNumber in src/public/app/widgets/buttons/calendar.ts
// Maybe can be merged later in monorepo setup
function getWeekNumberStr(date: Dayjs): string {
const year = date.year();
2025-04-04 13:59:01 +02:00
const dayOfWeek = (day: number) =>
(day - parseInt(optionService.getOption("firstDayOfWeek")) + 7) % 7;
// Get first day of the year and adjust to first week start
const jan1 = date.clone().year(year).month(0).date(1);
const jan1Weekday = jan1.day();
const dayOffset = dayOfWeek(jan1Weekday);
2025-04-04 13:55:13 +02:00
let firstWeekStart = jan1.clone().subtract(dayOffset, "day");
// Adjust based on week rule
switch (parseInt(optionService.getOption("firstWeekOfYear"))) {
case 1: { // ISO 8601: first week contains Thursday
2025-04-04 13:55:13 +02:00
const thursday = firstWeekStart.clone().add(3, "day"); // Monday + 3 = Thursday
if (thursday.year() < year) {
2025-04-04 13:55:13 +02:00
firstWeekStart = firstWeekStart.add(7, "day");
}
break;
}
case 2: { // minDaysInFirstWeek rule
const daysInFirstWeek = 7 - dayOffset;
if (daysInFirstWeek < parseInt(optionService.getOption("minDaysInFirstWeek"))) {
2025-04-04 13:55:13 +02:00
firstWeekStart = firstWeekStart.add(7, "day");
}
break;
}
// default case 0: week containing Jan 1 → already handled
}
2025-04-04 13:55:13 +02:00
const diffDays = date.startOf("day").diff(firstWeekStart.startOf("day"), "day");
const weekNumber = Math.floor(diffDays / 7) + 1;
// Handle case when date is before first week start → belongs to last week of previous year
if (weekNumber <= 0) {
2025-04-04 13:55:13 +02:00
return getWeekNumberStr(date.subtract(1, "day"));
}
// Handle case when date belongs to first week of next year
const nextYear = year + 1;
const jan1Next = date.clone().year(nextYear).month(0).date(1);
const jan1WeekdayNext = jan1Next.day();
const offsetNext = dayOfWeek(jan1WeekdayNext);
2025-04-04 13:55:13 +02:00
let nextYearWeekStart = jan1Next.clone().subtract(offsetNext, "day");
switch (parseInt(optionService.getOption("firstWeekOfYear"))) {
case 1: {
2025-04-04 13:55:13 +02:00
const thursday = nextYearWeekStart.clone().add(3, "day");
if (thursday.year() < nextYear) {
2025-04-04 13:55:13 +02:00
nextYearWeekStart = nextYearWeekStart.add(7, "day");
}
break;
}
case 2: {
const daysInFirstWeek = 7 - offsetNext;
if (daysInFirstWeek < parseInt(optionService.getOption("minDaysInFirstWeek"))) {
2025-04-04 13:55:13 +02:00
nextYearWeekStart = nextYearWeekStart.add(7, "day");
}
break;
}
}
if (date.isSameOrAfter(nextYearWeekStart)) {
return `${nextYear}-W01`;
}
2025-04-04 13:55:13 +02:00
return `${year}-W${weekNumber.toString().padStart(2, "0")}`;
}
function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) {
const weekStartDate = getWeekStartDate(dayjs(dateStr));
2025-04-04 13:55:13 +02:00
return getDayNote(weekStartDate.format("YYYY-MM-DD"), rootNote);
}
2025-04-14 18:36:54 +03:00
/**
* 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 {
2025-04-01 16:57:52 +02:00
const rootNote = _rootNote || getRootCalendarNote();
2025-04-04 13:55:13 +02:00
if (!rootNote.hasLabel("enableWeekNote")) {
2025-04-01 16:57:52 +02:00
return null;
}
weekStr = weekStr.trim().substring(0, 8);
2025-04-04 13:59:01 +02:00
let weekNote = searchService.findFirstNoteWithQuery(
`#${WEEK_LABEL}="${weekStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
);
2025-04-01 16:57:52 +02:00
if (weekNote) {
return weekNote;
}
2025-04-04 13:55:13 +02:00
const [ yearStr, weekNumStr ] = weekStr.trim().split("-W");
2025-04-01 16:57:52 +02:00
const weekNumber = parseInt(weekNumStr);
const firstDayOfYear = dayjs().year(parseInt(yearStr)).month(0).date(1);
2025-04-04 13:55:13 +02:00
const weekStartDate = firstDayOfYear.add(weekNumber - 1, "week");
const startDate = getWeekStartDate(weekStartDate);
2025-04-04 13:55:13 +02:00
const endDate = dayjs(startDate).add(6, "day");
const startMonth = startDate.month();
const endMonth = endDate.month();
2025-04-01 16:57:52 +02:00
const monthNote = getMonthNote(startDate.format("YYYY-MM-DD"), rootNote);
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber);
2025-04-01 16:57:52 +02:00
sql.transactional(() => {
2025-04-01 16:57:52 +02:00
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);
}
2025-04-01 16:57:52 +02:00
});
return weekNote as unknown as BNote;
}
function getDayNote(dateStr: string, _rootNote: BNote | null = null): BNote {
2025-04-01 18:56:33 +02:00
const rootNote = _rootNote || getRootCalendarNote();
dateStr = dateStr.trim().substring(0, 10);
2025-04-04 13:59:01 +02:00
let dateNote = searchService.findFirstNoteWithQuery(
`#${DATE_LABEL}="${dateStr}"`, new searchContext({ ancestorNoteId: rootNote.noteId })
);
2025-04-01 18:56:33 +02:00
if (dateNote) {
return dateNote;
}
2025-06-04 10:33:40 +02:00
let dateParentNote: BNote | null;
2025-04-01 18:56:33 +02:00
2025-04-04 13:55:13 +02:00
if (rootNote.hasLabel("enableWeekNote")) {
dateParentNote = getWeekNote(getWeekNumberStr(dayjs(dateStr)), rootNote);
2025-04-01 18:56:33 +02:00
} else {
dateParentNote = getMonthNote(dateStr, rootNote);
2025-04-01 18:56:33 +02:00
}
const dayNumber = dateStr.substring(8, 10);
const noteTitle = getJournalNoteTitle(
2025-04-04 13:59:01 +02:00
rootNote, "day", dayjs(dateStr), parseInt(dayNumber)
);
2025-04-01 18:56:33 +02:00
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) {
2025-04-04 13:55:13 +02:00
return getDayNote(dayjs().format("YYYY-MM-DD"), rootNote);
2025-04-01 18:56:33 +02:00
}
export default {
getRootCalendarNote,
getYearNote,
2025-04-01 19:05:12 +02:00
getQuarterNote,
getMonthNote,
2025-04-01 16:57:52 +02:00
getWeekNote,
getWeekFirstDayNote,
2022-01-10 17:09:20 +01:00
getDayNote,
2025-04-03 01:02:05 +02:00
getTodayNote,
2025-04-03 19:51:01 +02:00
getJournalNoteTitle
2020-06-20 12:31:38 +02:00
};