Compare commits

..

5 Commits

Author SHA1 Message Date
Jin
bd7e47d8f0 refactor: clear calendar code 2026-01-19 21:15:18 +00:00
Jin
457a7a03fb test: add test for week notes utils 2026-01-19 21:01:07 +00:00
Jin
6e486c64f1 fix: move calendar to use common week note utils 2026-01-19 20:59:15 +00:00
Jin
4f3575d765 fix: move date_notes to use common week note utils 2026-01-19 20:56:52 +00:00
Jin
f5f1b27754 fix: add common week note utils 2026-01-19 20:56:12 +00:00
26 changed files with 617 additions and 1199 deletions

View File

@@ -9,7 +9,7 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.28.1",
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"@redocly/cli": "2.14.5",
"archiver": "7.0.1",

View File

@@ -27,14 +27,14 @@
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.6.0",
"@preact/signals": "2.5.1",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.2",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -44,7 +44,7 @@
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.0.0",
"i18next": "25.8.0",
"i18next": "25.7.4",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
@@ -78,9 +78,9 @@
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.3.4",
"lightningcss": "1.31.1",
"happy-dom": "20.3.3",
"lightningcss": "1.30.2",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.5"
"vite-plugin-static-copy": "3.1.4"
}
}

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import shortcuts, { isIMEComposing, keyMatches, matchesShortcut } from "./shortcuts.js";
import shortcuts, { isIMEComposing,keyMatches, matchesShortcut } from "./shortcuts.js";
// Mock utils module
vi.mock("./utils.js", () => ({
@@ -62,10 +62,9 @@ describe("shortcuts", () => {
});
describe("keyMatches", () => {
const createKeyboardEvent = (key: string, code?: string, extraProps: Partial<KeyboardEvent> = {}) => ({
const createKeyboardEvent = (key: string, code?: string) => ({
key,
code: code || `Key${key.toUpperCase()}`,
...extraProps
code: code || `Key${key.toUpperCase()}`
} as KeyboardEvent);
it("should match regular letter keys using key code", () => {
@@ -103,23 +102,17 @@ describe("shortcuts", () => {
consoleSpy.mockRestore();
});
it("should match azerty keys", () => {
const event = createKeyboardEvent("A", "KeyQ");
expect(keyMatches(event, "a")).toBe(true);
expect(keyMatches(event, "q")).toBe(false);
});
it("should match letter keys using code when key is a special character (macOS Alt behavior)", () => {
// On macOS, pressing Option/Alt + A produces 'å' as the key, but code is still 'KeyA'
const macOSAltAEvent = createKeyboardEvent("å", "KeyA", { altKey: true });
const macOSAltAEvent = createKeyboardEvent("å", "KeyA");
expect(keyMatches(macOSAltAEvent, "a")).toBe(true);
// Option + H produces '˙'
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH", { altKey: true });
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH");
expect(keyMatches(macOSAltHEvent, "h")).toBe(true);
// Option + S produces 'ß'
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS", { altKey: true });
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS");
expect(keyMatches(macOSAltSEvent, "s")).toBe(true);
});
});
@@ -223,15 +216,6 @@ describe("shortcuts", () => {
consoleSpy.mockRestore();
});
it("matches azerty", () => {
const event = createKeyboardEvent({
key: "a",
code: "KeyQ",
ctrlKey: true
});
expect(matchesShortcut(event, "Ctrl+A")).toBe(true);
});
it("should match Alt+letter shortcuts on macOS where key is a special character", () => {
// On macOS, pressing Option/Alt + A produces 'å' but code remains 'KeyA'
const macOSAltAEvent = createKeyboardEvent({
@@ -265,7 +249,7 @@ describe("shortcuts", () => {
const handler = vi.fn();
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
});
it("should not bind shortcuts when handler is null", () => {
@@ -296,7 +280,7 @@ describe("shortcuts", () => {
const handler = vi.fn();
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
});
it("should fall back to document when element is empty", () => {
@@ -306,7 +290,7 @@ describe("shortcuts", () => {
const handler = vi.fn();
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
});
});
@@ -317,7 +301,7 @@ describe("shortcuts", () => {
shortcuts.removeGlobalShortcut("test-namespace");
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
});
});

View File

@@ -110,8 +110,9 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
}
};
// Add the event listener
element.addEventListener('keydown', listener);
// Add the event listener in capture phase to intercept events before they reach
// child elements like CodeMirror
element.addEventListener('keydown', listener, true);
// Store the binding for later cleanup
const binding: ShortcutBinding = {
@@ -138,15 +139,16 @@ export function removeIndividualBinding(binding: ShortcutBinding) {
if (activeBindingsInNamespace) {
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
}
binding.element.removeEventListener("keydown", binding.listener);
// Remove listener with capture phase to match how it was added
binding.element.removeEventListener("keydown", binding.listener, true);
}
function removeNamespaceBindings(namespace: string) {
const bindings = activeBindings.get(namespace);
if (bindings) {
// Remove all event listeners for this namespace
bindings.forEach(binding => {
binding.element.removeEventListener('keydown', binding.listener);
// Remove listener with capture phase to match how it was added
binding.element.removeEventListener('keydown', binding.listener, true);
});
activeBindings.delete(namespace);
}
@@ -215,12 +217,9 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
// For letter keys, use the physical key code for consistency
// On macOS, Option/Alt key produces special characters, so we must use e.code
if (key.length === 1 && key >= 'a' && key <= 'z') {
if (e.altKey) {
// e.code is like "KeyA", "KeyB", etc.
const expectedCode = `Key${key.toUpperCase()}`;
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
}
return e.key.toLowerCase() === key.toLowerCase();
// e.code is like "KeyA", "KeyB", etc.
const expectedCode = `Key${key.toUpperCase()}`;
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
}
// For regular keys, check both key and code as fallback

View File

@@ -3,7 +3,7 @@ import clsx from "clsx";
import server from "../../services/server";
import { TargetedMouseEvent, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Dayjs } from "@triliumnext/commons";
import { Dayjs, getWeekInfo, WeekSettings } from "@triliumnext/commons";
import { t } from "../../services/i18n";
interface DateNotesForMonth {
@@ -22,6 +22,7 @@ const DAYS_OF_WEEK = [
interface DateRangeInfo {
weekNumbers: number[];
weekYears: number[];
dates: Dayjs[];
}
@@ -36,19 +37,27 @@ export interface CalendarArgs {
export default function Calendar(args: CalendarArgs) {
const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
const [ firstWeekOfYear ] = useTriliumOptionInt("firstWeekOfYear");
const [ minDaysInFirstWeek ] = useTriliumOptionInt("minDaysInFirstWeek");
const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek);
const weekSettings = {
firstDayOfWeek: firstDayOfWeekISO,
firstWeekOfYear: firstWeekOfYear ?? 0,
minDaysInFirstWeek: minDaysInFirstWeek ?? 4
};
const date = args.date;
const firstDay = date.startOf('month');
const firstDayISO = firstDay.isoWeekday();
const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO);
const monthInfo = getMonthInformation(date, firstDayISO, weekSettings);
return (
<>
<CalendarWeekHeader rawFirstDayOfWeek={rawFirstDayOfWeek} />
<div className="calendar-body" data-calendar-area="month">
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} {...args} />}
<CurrentMonthDays firstDayOfWeekISO={firstDayOfWeekISO} {...args} />
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} weekSettings={weekSettings} {...args} />}
<CurrentMonthDays weekSettings={weekSettings} {...args} />
<NextMonthDays dates={monthInfo.nextMonth.dates} {...args} />
</div>
</>
@@ -67,7 +76,7 @@ function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }
)
}
function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) {
function PreviousMonthDays({ date, info: { dates, weekNumbers, weekYears }, weekSettings, ...args }: { date: Dayjs, info: DateRangeInfo, weekSettings: WeekSettings } & CalendarArgs) {
const prevMonth = date.subtract(1, 'month').format('YYYY-MM');
const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState<DateNotesForMonth>();
@@ -77,27 +86,28 @@ function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { da
return (
<>
<CalendarWeek date={date} weekNumber={weekNumbers[0]} {...args} />
<CalendarWeek date={date} weekNumber={weekNumbers[0]} weekYear={weekYears[0]} {...args} />
{dates.map(date => <CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForPrevMonth} className="calendar-date-prev-month" {...args} />)}
</>
)
}
function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) {
function CurrentMonthDays({ date, weekSettings, ...args }: { date: Dayjs, weekSettings: WeekSettings } & CalendarArgs) {
let dateCursor = date;
const currentMonth = date.month();
const items: VNode[] = [];
const curMonthString = date.format('YYYY-MM');
const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState<DateNotesForMonth>();
const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = weekSettings;
useEffect(() => {
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth);
}, [ date ]);
while (dateCursor.month() === currentMonth) {
const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO);
if (dateCursor.isoWeekday() === firstDayOfWeekISO) {
items.push(<CalendarWeek key={`${dateCursor.year()}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} {...args}/>)
const { weekYear, weekNumber } = getWeekInfo(dateCursor, weekSettings);
if (dateCursor.isoWeekday() === firstDayOfWeek) {
items.push(<CalendarWeek key={`${weekYear}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} weekYear={weekYear} {...args}/>)
}
items.push(<CalendarDay key={dateCursor.toISOString()} date={dateCursor} dateNotesForMonth={dateNotesForCurMonth} {...args} />)
@@ -141,14 +151,8 @@ function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDat
);
}
function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
const localDate = date.local();
// Handle case where week is in between years.
let year = localDate.year();
if (localDate.month() === 11 && weekNumber === 1) year++;
const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`;
function CalendarWeek({ date, weekNumber, weekYear, weekNotes, onWeekClicked }: { weekNumber: number, weekYear: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
const weekString = `${weekYear}-W${String(weekNumber).padStart(2, '0')}`;
if (onWeekClicked) {
return (
@@ -169,33 +173,33 @@ function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumb
>{weekNumber}</span>);
}
export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) {
export function getMonthInformation(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings) {
return {
prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO),
nextMonth: getNextMonthDays(date, firstDayOfWeekISO)
prevMonth: getPrevMonthDays(date, firstDayISO, weekSettings),
nextMonth: getNextMonthDays(date, weekSettings.firstDayOfWeek)
}
}
function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo {
function getPrevMonthDays(date: Dayjs, firstDayISO: number, weekSettings: WeekSettings): DateRangeInfo {
const prevMonthLastDay = date.subtract(1, 'month').endOf('month');
const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7;
const daysToAdd = (firstDayISO - weekSettings.firstDayOfWeek + 7) % 7;
const dates: Dayjs[] = [];
const firstDay = date.startOf('month');
const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO);
const { weekYear, weekNumber } = getWeekInfo(firstDay, weekSettings);
// Get dates from previous month
for (let i = daysToAdd - 1; i >= 0; i--) {
dates.push(prevMonthLastDay.subtract(i, 'day'));
}
return { weekNumbers: [ weekNumber ], dates };
return { weekNumbers: [ weekNumber ], weekYears: [ weekYear ], dates };
}
function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo {
function getNextMonthDays(date: Dayjs, firstDayOfWeek: number): DateRangeInfo {
const lastDayOfMonth = date.endOf('month');
const lastDayISO = lastDayOfMonth.isoWeekday();
const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1;
const lastDayOfUserWeek = ((firstDayOfWeek + 6 - 1) % 7) + 1;
const nextMonthFirstDay = date.add(1, 'month').startOf('month');
const dates: Dayjs[] = [];
@@ -206,16 +210,5 @@ function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo
dates.push(nextMonthFirstDay.add(i, 'day'));
}
}
return { weekNumbers: [], dates };
}
export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number {
const weekStart = getWeekStartDate(date, firstDayOfWeekISO);
return weekStart.isoWeek();
}
function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs {
const currentISO = date.isoWeekday();
const diff = (currentISO - firstDayOfWeekISO + 7) % 7;
return date.clone().subtract(diff, "day").startOf("day");
return { weekNumbers: [], weekYears: [], dates };
}

View File

@@ -93,15 +93,7 @@ export default defineConfig(() => ({
print: join(__dirname, "src", "print.tsx")
},
output: {
entryFileNames: (chunk) => {
// We enforce a hash in the main index file to avoid caching issues, this only works because we have the HTML entry point.
if (chunk.name === "index") {
return "src/[name]-[hash].js";
}
// For EJS-rendered pages (e.g. login) we need to have a stable name.
return "src/[name].js";
},
entryFileNames: "src/[name].js",
chunkFileNames: "src/[name]-[hash].js",
assetFileNames: "src/[name]-[hash].[ext]",
manualChunks: {

View File

@@ -337,130 +337,6 @@ paths:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/revisions:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns all revisions for a note identified by its ID
operationId: getNoteRevisions
responses:
"200":
description: list of revisions
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/{noteId}/undelete:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
post:
description: Restore a deleted note. The note must be deleted and must have at least one undeleted parent.
operationId: undeleteNote
responses:
"200":
description: note restored successfully
content:
application/json; charset=utf-8:
schema:
type: object
properties:
success:
type: boolean
example: true
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/notes/history:
get:
description: Returns recent changes including note creations, modifications, and deletions
operationId: getNoteHistory
parameters:
- name: ancestorNoteId
in: query
required: false
description: Limit changes to a subtree identified by this note ID. Defaults to "root" (all notes).
schema:
$ref: "#/components/schemas/EntityId"
responses:
"200":
description: list of recent changes
content:
application/json; charset=utf-8:
schema:
type: array
items:
$ref: "#/components/schemas/RecentChange"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns a revision identified by its ID
operationId: getRevisionById
responses:
"200":
description: revision response
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Revision"
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/revisions/{revisionId}/content:
parameters:
- name: revisionId
in: path
required: true
schema:
$ref: "#/components/schemas/EntityId"
get:
description: Returns revision content identified by its ID
operationId: getRevisionContent
responses:
"200":
description: revision content response
content:
text/html:
schema:
type: string
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: "#/components/schemas/Error"
/branches:
post:
description: >
@@ -1310,93 +1186,3 @@ components:
type: string
description: Human readable error, potentially with more details,
example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI
Revision:
type: object
description: Revision represents a snapshot of note's title and content at some point in the past.
properties:
revisionId:
$ref: "#/components/schemas/EntityId"
readOnly: true
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
type:
type: string
enum:
[
text,
code,
render,
file,
image,
search,
relationMap,
book,
noteMap,
mermaid,
webView,
shortcut,
doc,
contentWidget,
launcher,
]
mime:
type: string
isProtected:
type: boolean
readOnly: true
title:
type: string
blobId:
type: string
description: ID of the blob object which effectively serves as a content hash
dateLastEdited:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
dateCreated:
$ref: "#/components/schemas/LocalDateTime"
readOnly: true
utcDateLastEdited:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateCreated:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
utcDateModified:
$ref: "#/components/schemas/UtcDateTime"
readOnly: true
contentLength:
type: integer
format: int32
readOnly: true
RecentChange:
type: object
description: Represents a recent change event (creation, modification, or deletion).
properties:
noteId:
$ref: "#/components/schemas/EntityId"
readOnly: true
title:
type: string
description: Title at the time of the change (may be "[protected]" for protected notes)
current_title:
type: string
description: Current title of the note (may be "[protected]" for protected notes)
current_isDeleted:
type: boolean
description: Whether the note is currently deleted
current_deleteId:
type: string
description: Delete ID if the note is deleted
current_isProtected:
type: boolean
description: Whether the note is protected
utcDate:
$ref: "#/components/schemas/UtcDateTime"
description: UTC timestamp of the change
date:
$ref: "#/components/schemas/LocalDateTime"
description: Local timestamp of the change
canBeUndeleted:
type: boolean
description: Whether the note can be undeleted (only present for deleted notes)

View File

@@ -99,7 +99,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"i18next": "25.8.0",
"i18next": "25.7.4",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"ini": "6.0.0",
@@ -126,7 +126,7 @@
"swagger-jsdoc": "6.2.8",
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turnish": "1.8.0",
"turnish": "1.7.1",
"unescape": "1.0.1",
"vite": "7.3.1",
"ws": "8.19.0",

View File

@@ -1,77 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/get-note-revisions", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token);
// Create a revision by updating the note content
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content for revision")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets revisions for a note", async () => {
const response = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const revision = response.body[0];
expect(revision).toHaveProperty("revisionId");
expect(revision).toHaveProperty("noteId", createdNoteId);
expect(revision).toHaveProperty("type");
expect(revision).toHaveProperty("mime");
expect(revision).toHaveProperty("title");
expect(revision).toHaveProperty("isProtected");
expect(revision).toHaveProperty("blobId");
expect(revision).toHaveProperty("utcDateCreated");
});
it("returns empty array for note with no revisions", async () => {
// Create a new note without any revisions
const newNoteId = await createNote(app, token, "Brand new content");
const response = await supertest(app)
.get(`/etapi/notes/${newNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// New notes may or may not have revisions depending on settings
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.get("/etapi/notes/nonexistentnote/revisions")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
});

View File

@@ -1,71 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/get-revision", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial content");
// Update content to create a revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Updated content")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision metadata by ID", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("revisionId", revisionId);
expect(response.body).toHaveProperty("noteId", createdNoteId);
expect(response.body).toHaveProperty("type", "text");
expect(response.body).toHaveProperty("mime", "text/html");
expect(response.body).toHaveProperty("title", "Hello");
expect(response.body).toHaveProperty("isProtected", false);
expect(response.body).toHaveProperty("blobId");
expect(response.body).toHaveProperty("utcDateCreated");
expect(response.body).toHaveProperty("utcDateModified");
});
it("returns 404 for non-existent revision", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@@ -1,94 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
describe("etapi/note-history", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
// Create a note to ensure there's some history
createdNoteId = await createNote(app, token, "History test content");
// Create a revision to ensure history has entries
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
});
it("gets recent changes history", async () => {
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
// Check that history entries have expected properties
const entry = response.body[0];
expect(entry).toHaveProperty("noteId");
expect(entry).toHaveProperty("title");
expect(entry).toHaveProperty("utcDate");
expect(entry).toHaveProperty("date");
expect(entry).toHaveProperty("current_isDeleted");
expect(entry).toHaveProperty("current_isProtected");
});
it("filters history by ancestor note", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=root")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// All results should be descendants of root (which is everything)
});
it("returns empty array for non-existent ancestor", async () => {
const response = await supertest(app)
.get("/etapi/notes/history?ancestorNoteId=nonexistentancestor")
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// Should be empty since no notes are descendants of a non-existent note
expect(response.body.length).toBe(0);
});
it("includes canBeUndeleted for deleted notes", async () => {
// Create and delete a note
const noteToDeleteId = await createNote(app, token, "Note to delete for history test");
await supertest(app)
.delete(`/etapi/notes/${noteToDeleteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Check history - deleted note should appear with canBeUndeleted property
const response = await supertest(app)
.get("/etapi/notes/history")
.auth(USER, token, { "type": "basic" })
.expect(200);
const deletedEntry = response.body.find(
(entry: any) => entry.noteId === noteToDeleteId && entry.current_isDeleted === true
);
// Deleted entries should have canBeUndeleted property
if (deletedEntry) {
expect(deletedEntry).toHaveProperty("canBeUndeleted");
}
});
});

View File

@@ -1,64 +0,0 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
let app: Application;
let token: string;
const USER = "etapi";
let createdNoteId: string;
let revisionId: string;
describe("etapi/revision-content", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
createdNoteId = await createNote(app, token, "Initial revision content");
// Update content to ensure we have content in the revision
await supertest(app)
.put(`/etapi/notes/${createdNoteId}/content`)
.auth(USER, token, { "type": "basic" })
.set("Content-Type", "text/plain")
.send("Content after first update")
.expect(204);
// Force create a revision
await supertest(app)
.post(`/etapi/notes/${createdNoteId}/revision`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Get the revision ID
const revisionsResponse = await supertest(app)
.get(`/etapi/notes/${createdNoteId}/revisions`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(revisionsResponse.body.length).toBeGreaterThan(0);
revisionId = revisionsResponse.body[0].revisionId;
});
it("gets revision content", async () => {
const response = await supertest(app)
.get(`/etapi/revisions/${revisionId}/content`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toBeTruthy();
});
it("returns 404 for non-existent revision content", async () => {
const response = await supertest(app)
.get("/etapi/revisions/nonexistentrevision/content")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND");
});
});

View File

@@ -1,103 +0,0 @@
import { Application } from "express";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import supertest from "supertest";
import { login } from "./utils.js";
import config from "../../src/services/config.js";
import { randomInt } from "crypto";
let app: Application;
let token: string;
const USER = "etapi";
describe("etapi/undelete-note", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
it("undeletes a deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note to delete and restore",
"type": "text",
"content": "Content to restore"
})
.expect(201);
// Verify note exists
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
// Delete the note
await supertest(app)
.delete(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(204);
// Verify note is deleted (should return 404)
await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(404);
// Undelete the note
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(response.body).toHaveProperty("success", true);
// Verify note is restored
const restoredResponse = await supertest(app)
.get(`/etapi/notes/${noteId}`)
.auth(USER, token, { "type": "basic" })
.expect(200);
expect(restoredResponse.body.title).toStrictEqual("Note to delete and restore");
});
it("returns 404 for non-existent note", async () => {
const response = await supertest(app)
.post("/etapi/notes/nonexistentnote/undelete")
.auth(USER, token, { "type": "basic" })
.expect(404);
expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND");
});
it("returns 400 when trying to undelete a non-deleted note", async () => {
// Create a note
const noteId = `testNote${randomInt(10000)}`;
await supertest(app)
.post("/etapi/create-note")
.auth(USER, token, { "type": "basic" })
.send({
"noteId": noteId,
"parentNoteId": "root",
"title": "Note not deleted",
"type": "text",
"content": "Content"
})
.expect(201);
// Try to undelete a note that isn't deleted
const response = await supertest(app)
.post(`/etapi/notes/${noteId}/undelete`)
.auth(USER, token, { "type": "basic" })
.expect(400);
expect(response.body.code).toStrictEqual("NOTE_NOT_DELETED");
});
});

View File

@@ -121,16 +121,6 @@ function getAndCheckAttribute(attributeId: string) {
}
}
function getAndCheckRevision(revisionId: string) {
const revision = becca.getRevision(revisionId);
if (revision) {
return revision;
} else {
throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`);
}
}
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
for (const key of Object.keys(source)) {
if (!(key in allowedProperties)) {
@@ -162,6 +152,5 @@ export default {
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute,
getAndCheckAttachment,
getAndCheckRevision
getAndCheckAttachment
};

View File

@@ -2,7 +2,6 @@ import type BAttachment from "../becca/entities/battachment.js";
import type BAttribute from "../becca/entities/battribute.js";
import type BBranch from "../becca/entities/bbranch.js";
import type BNote from "../becca/entities/bnote.js";
import type BRevision from "../becca/entities/brevision.js";
function mapNoteToPojo(note: BNote) {
return {
@@ -65,28 +64,9 @@ function mapAttachmentToPojo(attachment: BAttachment) {
};
}
function mapRevisionToPojo(revision: BRevision) {
return {
revisionId: revision.revisionId,
noteId: revision.noteId,
type: revision.type,
mime: revision.mime,
isProtected: revision.isProtected,
title: revision.title,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,
utcDateLastEdited: revision.utcDateLastEdited,
utcDateCreated: revision.utcDateCreated,
utcDateModified: revision.utcDateModified,
contentLength: revision.contentLength
};
}
export default {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo,
mapAttachmentToPojo,
mapRevisionToPojo
mapAttachmentToPojo
};

View File

@@ -1,205 +0,0 @@
import becca from "../becca/becca.js";
import sql from "../services/sql.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import noteService from "../services/notes.js";
import TaskContext from "../services/task_context.js";
import protectedSessionService from "../services/protected_session.js";
import utils from "../services/utils.js";
import type { Router } from "express";
import type { NoteRow, RecentChangeRow } from "@triliumnext/commons";
function register(router: Router) {
// GET /etapi/notes/history - must be registered before /etapi/notes/:noteId routes
eu.route(router, "get", "/etapi/notes/history", (req, res, next) => {
const ancestorNoteId = (req.query.ancestorNoteId as string) || "root";
let recentChanges: RecentChangeRow[];
if (ancestorNoteId === "root") {
// Optimized path: no ancestor filtering needed, fetch directly from DB
recentChanges = sql.getRows<RecentChangeRow>(`
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1
ORDER BY utcDate DESC
LIMIT 500`);
} else {
// Use recursive CTE to find all descendants, then filter at DB level
// This pushes filtering to the database for much better performance
recentChanges = sql.getRows<RecentChangeRow>(`
WITH RECURSIVE descendants(noteId) AS (
SELECT ?
UNION
SELECT branches.noteId
FROM branches
JOIN descendants ON branches.parentNoteId = descendants.noteId
)
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
revisions.title,
revisions.utcDateCreated AS utcDate,
revisions.dateCreated AS date
FROM revisions
JOIN notes USING(noteId)
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateCreated AS utcDate,
notes.dateCreated AS date
FROM notes
WHERE notes.noteId IN (SELECT noteId FROM descendants)
UNION ALL
SELECT
notes.noteId,
notes.isDeleted AS current_isDeleted,
notes.deleteId AS current_deleteId,
notes.title AS current_title,
notes.isProtected AS current_isProtected,
notes.title,
notes.utcDateModified AS utcDate,
notes.dateModified AS date
FROM notes
WHERE notes.isDeleted = 1 AND notes.noteId IN (SELECT noteId FROM descendants)
ORDER BY utcDate DESC
LIMIT 500`, [ancestorNoteId]);
}
for (const change of recentChanges) {
if (change.current_isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
change.title = protectedSessionService.decryptString(change.title) || "[protected]";
change.current_title = protectedSessionService.decryptString(change.current_title) || "[protected]";
} else {
change.title = change.current_title = "[protected]";
}
}
if (change.current_isDeleted) {
const deleteId = change.current_deleteId;
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(change.noteId, deleteId);
// note (and the subtree) can be undeleted if there's at least one undeleted parent (whose branch would be undeleted by this op)
change.canBeUndeleted = undeletedParentBranchIds.length > 0;
}
}
res.json(recentChanges);
});
// GET /etapi/notes/:noteId/revisions - List all revisions for a note
eu.route(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const revisions = becca.getRevisionsFromQuery(
`SELECT revisions.*, LENGTH(blobs.content) AS contentLength
FROM revisions
JOIN blobs USING (blobId)
WHERE noteId = ?
ORDER BY utcDateCreated DESC`,
[note.noteId]
);
res.json(revisions.map((revision) => mappers.mapRevisionToPojo(revision)));
});
// POST /etapi/notes/:noteId/undelete - Restore a deleted note
eu.route(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => {
const { noteId } = req.params;
const noteRow = sql.getRow<NoteRow | null>("SELECT * FROM notes WHERE noteId = ?", [noteId]);
if (!noteRow) {
throw new eu.EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
}
if (!noteRow.isDeleted || !noteRow.deleteId) {
throw new eu.EtapiError(400, "NOTE_NOT_DELETED", `Note '${noteId}' is not deleted.`);
}
const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(noteId, noteRow.deleteId);
if (undeletedParentBranchIds.length === 0) {
throw new eu.EtapiError(400, "CANNOT_UNDELETE", `Cannot undelete note '${noteId}' - no undeleted parent found.`);
}
const taskContext = new TaskContext("no-progress-reporting", "undeleteNotes", null);
noteService.undeleteNote(noteId, taskContext);
res.json({ success: true });
});
// GET /etapi/revisions/:revisionId - Get revision metadata
eu.route(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and cannot be read through ETAPI.`);
}
res.json(mappers.mapRevisionToPojo(revision));
});
// GET /etapi/revisions/:revisionId/content - Get revision content
eu.route(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => {
const revision = eu.getAndCheckRevision(req.params.revisionId);
if (revision.isProtected) {
throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and content cannot be read through ETAPI.`);
}
const filename = utils.formatDownloadTitle(revision.title, revision.type, revision.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", revision.mime);
res.send(revision.getContent());
});
}
export default {
register
};

View File

@@ -12,7 +12,6 @@ import etapiMetricsRoute from "../etapi/metrics.js";
import etapiNoteRoutes from "../etapi/notes.js";
import etapiSpecRoute from "../etapi/spec.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiRevisionsRoutes from "../etapi/revisions.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
@@ -362,8 +361,6 @@ function register(app: express.Application) {
etapiAttachmentRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBranchRoutes.register(router);
// Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId
etapiRevisionsRoutes.register(router);
etapiNoteRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
etapiSpecRoute.register(router);

View File

@@ -2,7 +2,7 @@ import type BNote from "../becca/entities/bnote.js";
import attributeService from "./attributes.js";
import cloningService from "./cloning.js";
import { dayjs, Dayjs } from "@triliumnext/commons";
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";
@@ -63,7 +63,8 @@ function getJournalNoteTitle(
rootNote: BNote,
timeUnit: TimeUnit,
dateObj: Dayjs,
number: number
number: number,
weekYear?: number // Optional: the week year for cross-year weeks
) {
const patterns = {
year: rootNote.getOwnedLabelValue("yearPattern") || "{year}",
@@ -79,9 +80,14 @@ function getJournalNoteTitle(
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}": dateObj.format("YYYY"),
"{year}": yearForDisplay,
// Month related
"{isoMonth}": dateObj.format("YYYY-MM"),
@@ -286,6 +292,14 @@ function getMonthNote(dateStr: string, _rootNote: BNote | null = null): BNote {
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();
@@ -294,9 +308,8 @@ function getWeekStartDate(date: Dayjs): Dayjs {
}
function getWeekNumberStr(date: Dayjs): string {
const isoYear = date.isoWeekYear();
const isoWeekNum = date.isoWeek();
return `${isoYear}-W${isoWeekNum.toString().padStart(2, "0")}`;
const { weekYear, weekNumber } = getWeekInfo(date, getWeekSettings());
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
}
function getWeekFirstDayNote(dateStr: string, rootNote: BNote | null = null) {
@@ -329,17 +342,19 @@ function getWeekNote(weekStr: string, _rootNote: BNote | null = null): BNote | n
const [ yearStr, weekNumStr ] = weekStr.trim().split("-W");
const weekNumber = parseInt(weekNumStr);
const weekYear = parseInt(yearStr);
const firstDayOfYear = dayjs().year(parseInt(yearStr)).month(0).date(1);
const weekStartDate = firstDayOfYear.add(weekNumber - 1, "week");
const startDate = getWeekStartDate(weekStartDate);
const endDate = dayjs(startDate).add(6, "day");
// 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);
const noteTitle = getJournalNoteTitle(rootNote, "week", startDate, weekNumber, weekYear);
sql.transactional(() => {
weekNote = createNote(monthNote, noteTitle);

View File

@@ -9,7 +9,7 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.8.0",
"i18next": "25.7.4",
"i18next-http-backend": "3.0.2",
"preact": "10.28.2",
"preact-iso": "2.11.1",

View File

@@ -63,7 +63,7 @@
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.5.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"happy-dom": "20.3.4",
"happy-dom": "20.3.3",
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
@@ -93,7 +93,7 @@
"url": "https://github.com/TriliumNext/Trilium/issues"
},
"homepage": "https://triliumnotes.org",
"packageManager": "pnpm@10.28.1",
"packageManager": "pnpm@10.28.0",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
@@ -115,7 +115,7 @@
"on-headers@<1.1.0": ">=1.1.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
"form-data@>=3.0.0 <3.0.4": ">=3.0.4",
"node-abi": "4.26.0"
"node-abi": "4.25.0"
},
"ignoredBuiltDependencies": [
"sqlite3"

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.4.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.26",
"@smithy/middleware-retry": "4.4.24",
"@types/jquery": "3.5.33"
}
}

View File

@@ -1,5 +1,5 @@
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig, KeyBinding } from "@codemirror/view";
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { highlightSelectionMatches } from "@codemirror/search";
@@ -12,17 +12,6 @@ import { createSearchHighlighter, SearchHighlighter, searchMatchHighlightTheme }
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
// Custom keymap to prevent Ctrl+Enter from inserting a newline
// This allows the parent application to handle the shortcut (e.g., for "Run Active Note")
const preventCtrlEnterKeymap: readonly KeyBinding[] = [
{
key: "Ctrl-Enter",
mac: "Cmd-Enter",
run: () => true, // Return true to mark event as handled, preventing default newline insertion
preventDefault: true
}
];
type ContentChangedListener = () => void;
export interface EditorConfig {
@@ -70,7 +59,6 @@ export default class CodeMirror extends EditorView {
lineNumbers(),
indentUnit.of(" ".repeat(4)),
keymap.of([
...preventCtrlEnterKeymap,
...defaultKeymap,
...historyKeymap,
...smartIndentWithTab

View File

@@ -13,3 +13,4 @@ export * from "./lib/attribute_names.js";
export * from "./lib/utils.js";
export * from "./lib/dayjs.js";
export * from "./lib/notes.js";
export * from "./lib/week_utils.js";

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import { dayjs } from "./dayjs.js";
import { getWeekInfo, getFirstDayOfWeek1, getWeekString, WeekSettings, DEFAULT_WEEK_SETTINGS } from "./week_utils.js";
describe("week_utils", () => {
describe("getWeekInfo", () => {
describe("with firstWeekOfYear=0 (first week contains first day of year)", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 0,
minDaysInFirstWeek: 4
};
it("2025-12-29 should be 2026-W01 (cross-year week)", () => {
// 2026-01-01 is Thursday, so the week containing it starts on 2025-12-29 (Monday)
// This week should be 2026-W01 because it contains 2026-01-01
const result = getWeekInfo(dayjs("2025-12-29"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(1);
});
it("2026-01-01 should be 2026-W01", () => {
const result = getWeekInfo(dayjs("2026-01-01"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(1);
});
it("2025-12-28 should be 2025-W52", () => {
// 2025-12-28 is Sunday, which is the last day of the week starting 2025-12-22
const result = getWeekInfo(dayjs("2025-12-28"), settings);
expect(result.weekYear).toBe(2025);
expect(result.weekNumber).toBe(52);
});
it("2026-01-05 should be 2026-W02", () => {
// 2026-01-05 is Monday, start of second week
const result = getWeekInfo(dayjs("2026-01-05"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(2);
});
});
describe("with firstWeekOfYear=1 (ISO standard, first week contains first Thursday)", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 1,
minDaysInFirstWeek: 4
};
it("2023-01-01 should be 2022-W52 (Jan 1 is Sunday)", () => {
// 2023-01-01 is Sunday, so the week starts on 2022-12-26
// Since this week doesn't contain Jan 4, it's 2022-W52
const result = getWeekInfo(dayjs("2023-01-01"), settings);
expect(result.weekYear).toBe(2022);
expect(result.weekNumber).toBe(52);
});
it("2023-01-02 should be 2023-W01 (first Monday)", () => {
const result = getWeekInfo(dayjs("2023-01-02"), settings);
expect(result.weekYear).toBe(2023);
expect(result.weekNumber).toBe(1);
});
});
describe("with firstWeekOfYear=2 (minimum days in first week)", () => {
// 2026-01-01 is Thursday
// The week containing Jan 1 starts on 2025-12-29 (Monday)
// This week has 4 days in 2026 (Thu, Fri, Sat, Sun = Jan 1-4)
describe("with minDaysInFirstWeek=1", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 2,
minDaysInFirstWeek: 1
};
it("2025-12-29 should be 2026-W01 (4 days >= 1 minimum)", () => {
// Week has 4 days in 2026, which is >= 1
const result = getWeekInfo(dayjs("2025-12-29"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(1);
});
it("2026-01-01 should be 2026-W01", () => {
const result = getWeekInfo(dayjs("2026-01-01"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(1);
});
});
describe("with minDaysInFirstWeek=7", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 2,
minDaysInFirstWeek: 7
};
it("2025-12-29 should be 2025-W52 (4 days < 7 minimum, so this is last week of 2025)", () => {
// Week has only 4 days in 2026, which is < 7
// So this week belongs to 2025
const result = getWeekInfo(dayjs("2025-12-29"), settings);
expect(result.weekYear).toBe(2025);
expect(result.weekNumber).toBe(52);
});
it("2026-01-01 should be 2025-W52 (still last week of 2025)", () => {
const result = getWeekInfo(dayjs("2026-01-01"), settings);
expect(result.weekYear).toBe(2025);
expect(result.weekNumber).toBe(52);
});
it("2026-01-05 should be 2026-W01 (first full week of 2026)", () => {
// 2026-01-05 is Monday, start of the first full week
const result = getWeekInfo(dayjs("2026-01-05"), settings);
expect(result.weekYear).toBe(2026);
expect(result.weekNumber).toBe(1);
});
});
});
});
describe("getFirstDayOfWeek1", () => {
it("with firstWeekOfYear=0, returns the first day of the week containing Jan 1", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 0,
minDaysInFirstWeek: 4
};
// 2026-01-01 is Thursday, so week starts on 2025-12-29
const result = getFirstDayOfWeek1(2026, settings);
expect(result.format("YYYY-MM-DD")).toBe("2025-12-29");
});
it("with firstWeekOfYear=1, returns the first day of the week containing Jan 4", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 1,
minDaysInFirstWeek: 4
};
// 2023-01-04 is Wednesday, so week starts on 2023-01-02
const result = getFirstDayOfWeek1(2023, settings);
expect(result.format("YYYY-MM-DD")).toBe("2023-01-02");
});
});
describe("getWeekString", () => {
it("generates correct week string for cross-year week", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 0,
minDaysInFirstWeek: 4
};
expect(getWeekString(dayjs("2025-12-29"), settings)).toBe("2026-W01");
});
it("generates correct week string with padded week number", () => {
const settings: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 0,
minDaysInFirstWeek: 4
};
expect(getWeekString(dayjs("2026-01-05"), settings)).toBe("2026-W02");
});
});
});

View File

@@ -0,0 +1,143 @@
import { dayjs, Dayjs } from "./dayjs.js";
/**
* Week settings for calculating week numbers.
*/
export interface WeekSettings {
/** First day of the week (1=Monday to 7=Sunday) */
firstDayOfWeek: number;
/**
* How to determine the first week of the year:
* - 0: First week contains first day of the year
* - 1: First week contains first Thursday (ISO 8601 standard)
* - 2: First week has minimum days
*/
firstWeekOfYear: number;
/** Minimum days in first week (used when firstWeekOfYear=2) */
minDaysInFirstWeek: number;
}
/**
* Default week settings (first week contains first day of year, week starts on Monday).
*/
export const DEFAULT_WEEK_SETTINGS: WeekSettings = {
firstDayOfWeek: 1,
firstWeekOfYear: 0,
minDaysInFirstWeek: 4
};
/**
* Gets the first day of week 1 for a given year, based on user settings.
*
* @param year The year to calculate for
* @param settings Week calculation settings
* @returns The first day of week 1
*/
export function getFirstDayOfWeek1(year: number, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs {
const { firstDayOfWeek, firstWeekOfYear, minDaysInFirstWeek } = settings;
const jan1 = dayjs(`${year}-01-01`);
const jan1Weekday = jan1.isoWeekday(); // 1=Monday, 7=Sunday
// Calculate the first day of the week containing Jan 1
const daysToSubtract = (jan1Weekday - firstDayOfWeek + 7) % 7;
const weekContainingJan1Start = jan1.subtract(daysToSubtract, "day");
if (firstWeekOfYear === 0) {
// First week contains first day of the year
return weekContainingJan1Start;
} else if (firstWeekOfYear === 1) {
// First week contains first Thursday (ISO 8601 standard)
const jan4 = dayjs(`${year}-01-04`);
const jan4Weekday = jan4.isoWeekday();
const daysToSubtractFromJan4 = (jan4Weekday - firstDayOfWeek + 7) % 7;
return jan4.subtract(daysToSubtractFromJan4, "day");
} else {
// First week has minimum days
const daysInFirstWeek = 7 - daysToSubtract;
if (daysInFirstWeek >= minDaysInFirstWeek) {
return weekContainingJan1Start;
} else {
return weekContainingJan1Start.add(1, "week");
}
}
}
/**
* Gets the week year and week number for a given date based on user settings.
*
* @param date The date to calculate week info for
* @param settings Week calculation settings
* @returns Object with weekYear and weekNumber
*/
export function getWeekInfo(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): { weekYear: number; weekNumber: number } {
const { firstDayOfWeek } = settings;
// Get the start of the week containing this date
const dateWeekday = date.isoWeekday();
const daysToSubtract = (dateWeekday - firstDayOfWeek + 7) % 7;
const weekStart = date.subtract(daysToSubtract, "day");
// Try current year first
let year = date.year();
let firstDayOfWeek1 = getFirstDayOfWeek1(year, settings);
// If the week start is before week 1 of current year, it belongs to previous year
if (weekStart.isBefore(firstDayOfWeek1)) {
year--;
firstDayOfWeek1 = getFirstDayOfWeek1(year, settings);
} else {
// Check if this might belong to next year's week 1
const nextYearFirstDayOfWeek1 = getFirstDayOfWeek1(year + 1, settings);
if (!weekStart.isBefore(nextYearFirstDayOfWeek1)) {
year++;
firstDayOfWeek1 = nextYearFirstDayOfWeek1;
}
}
// Calculate week number
const weekNumber = weekStart.diff(firstDayOfWeek1, "week") + 1;
return { weekYear: year, weekNumber };
}
/**
* Generates a week string in the format "YYYY-Www" (e.g., "2026-W01").
*
* @param date The date to generate the week string for
* @param settings Week calculation settings
* @returns Week string in format "YYYY-Www"
*/
export function getWeekString(date: Dayjs, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): string {
const { weekYear, weekNumber } = getWeekInfo(date, settings);
return `${weekYear}-W${weekNumber.toString().padStart(2, "0")}`;
}
/**
* Gets the start date of the week containing the given date.
*
* @param date The date to find the week start for
* @param firstDayOfWeek First day of the week (1=Monday to 7=Sunday)
* @returns The start of the week
*/
export function getWeekStartDate(date: Dayjs, firstDayOfWeek: number = 1): Dayjs {
const dateWeekday = date.isoWeekday();
const diff = (dateWeekday - firstDayOfWeek + 7) % 7;
return date.clone().subtract(diff, "day").startOf("day");
}
/**
* Parses a week string and returns the start date of that week.
*
* @param weekStr Week string in format "YYYY-Www" (e.g., "2026-W01")
* @param settings Week calculation settings
* @returns The start date of the week
*/
export function parseWeekString(weekStr: string, settings: WeekSettings = DEFAULT_WEEK_SETTINGS): Dayjs {
const [yearStr, weekNumStr] = weekStr.trim().split("-W");
const weekNumber = parseInt(weekNumStr);
const weekYear = parseInt(yearStr);
const firstDayOfWeek1 = getFirstDayOfWeek1(weekYear, settings);
return firstDayOfWeek1.add(weekNumber - 1, "week");
}

424
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff