Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e6ebfc4
edited notes: move edited-notes related code to own module
contributor Nov 10, 2025
dd7c556
edited notes: recognize dateNote label value TODAY, MONTH, YEAR
contributor Nov 11, 2025
8e989cf
edited notes: handle timezone differences between client and server
contributor Nov 11, 2025
8e09ed8
edited notes: force tests to run in UTC timezone
contributor Nov 11, 2025
674528b
edited notes: better docstring for resolveDateParams
contributor Nov 12, 2025
5557cd4
lint format
contributor Nov 12, 2025
3379a8f
edited notes: extract sqlquery var for eslint to recognize indentation
contributor Nov 12, 2025
d167caa
edited notes: move formatMap on module level
contributor Nov 12, 2025
7aff5d1
edited notes: extendable EditedNotesResponse
contributor Nov 11, 2025
c389a12
edited notes: return limit in response
contributor Nov 16, 2025
3ba6d14
edited notes: add happy path tests
contributor Nov 16, 2025
b54411a
edited notes: more descriptive name dateNoteLabelKeywordToDateFilter
contributor Nov 16, 2025
ad9a058
edited notes: better names in tests
contributor Nov 16, 2025
bcff929
edited notes: recognize not valid dates/keywords, return 0 limit to c…
contributor Nov 16, 2025
25844d6
edited notes: add positive delta test
contributor Nov 16, 2025
405fe0e
edited notes: more restrictive check
contributor Nov 16, 2025
cbdca3c
edited notes: do not shadow variable
contributor Nov 16, 2025
2486d97
edited notes: use parameterized limit
contributor Nov 16, 2025
942fbe0
edited notes: allow spaces between keyword and delta in #dateNote
contributor Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/client/src/widgets/ribbon/EditedNotesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useEffect, useState } from "preact/hooks";
import { TabContext } from "./ribbon-interface";
import { EditedNotesResponse } from "@triliumnext/commons";
import { EditedNotesResponse, EditedNotes } from "@triliumnext/commons";
import server from "../../services/server";
import { t } from "../../services/i18n";
import froca from "../../services/froca";
import NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";

export default function EditedNotesTab({ note }: TabContext) {
const [ editedNotes, setEditedNotes ] = useState<EditedNotesResponse>();
const [ editedNotes, setEditedNotes ] = useState<EditedNotes>();

useEffect(() => {
if (!note) return;
server.get<EditedNotesResponse>(`edited-notes/${note.getLabelValue("dateNote")}`).then(async editedNotes => {
editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId);
server.get<EditedNotesResponse>(`edited-notes/${note.getLabelValue("dateNote")}`).then(async response => {
const editedNotes = response.notes.filter((n) => n.noteId !== note.noteId);
const noteIds = editedNotes.flatMap((n) => n.noteId);
await froca.getNotes(noteIds, true); // preload all at once
setEditedNotes(editedNotes);
Expand Down
113 changes: 113 additions & 0 deletions apps/server/src/routes/api/edited-notes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import cls from '../../services/cls.js';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { dateNoteLabelKeywordToDateFilter } from "./edited-notes.js";

// test date setup
// client: UTC+1
// server: UTC
// day/month/year is changed when server converts a client date to to UTC
const clientDate = "2025-01-01 00:11:11.000+0100";
const serverDate = "2024-12-31 23:11:11.000Z";

// expected values - from client's point of view
const expectedToday = "2025-01-01";
const expectedTodayMinus1 = "2024-12-31";
const expectedMonth = "2025-01";
const expectedMonthMinus2 = "2024-11";
const expectedYear = "2025";
const expectedYearMinus1 = "2024";

function keywordResolvesToDate(dateStrOrKeyword: string, expectedDate: string) {
cls.init(() => {
cls.set("localNowDateTime", clientDate);
const dateFilter = dateNoteLabelKeywordToDateFilter(dateStrOrKeyword);
expect(dateFilter.date).toBe(expectedDate);
});
}

function keywordDoesNotResolve(dateStrOrKeyword: string) {
cls.init(() => {
cls.set("localNowDateTime", clientDate);
const dateFilter = dateNoteLabelKeywordToDateFilter(dateStrOrKeyword);
expect(dateFilter.date).toBe(null);
});
}

describe("edited-notes::dateNoteLabelKeywordToDateFilter", () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
vi.useFakeTimers();
vi.setSystemTime(new Date(serverDate));
});

afterEach(() => {
vi.unstubAllEnvs();
// Restore real timers after each test
vi.useRealTimers();
});

it("resolves 'TODAY' to today's date", () => {
keywordResolvesToDate("TODAY", expectedToday);
});

it("resolves 'MONTH' to current month", () => {
keywordResolvesToDate("MONTH", expectedMonth);
});

it("resolves 'YEAR' to current year", () => {
keywordResolvesToDate("YEAR", expectedYear);
});

it("resolves 'TODAY-1' to yesterday's date", () => {
keywordResolvesToDate("TODAY-1", expectedTodayMinus1);
});

it("resolves 'MONTH-2' to 2 months ago", () => {
keywordResolvesToDate("MONTH-2", expectedMonthMinus2);
});

it("resolves 'YEAR-1' to last year", () => {
keywordResolvesToDate("YEAR-1", expectedYearMinus1);
});

it("returns original string for day", () => {
keywordResolvesToDate("2020-12-31", "2020-12-31");
});

it("returns original string for month", () => {
keywordResolvesToDate("2020-12", "2020-12");
});

it("returns original string for partial month", () => {
keywordResolvesToDate("2020-1", "2020-1");
});

it("returns original string for partial month with trailing dash", () => {
keywordResolvesToDate("2020-", "2020-");
});

it("returns original string for year", () => {
keywordResolvesToDate("2020", "2020");
});

it("returns original string for potentially partial day", () => {
keywordResolvesToDate("2020-12-1", "2020-12-1");
});

it("returns null for partial year", () => {
keywordDoesNotResolve("202");
});

it("returns null for arbitrary string", () => {
keywordDoesNotResolve("FOO");
});

it("returns null for missing delta", () => {
keywordDoesNotResolve("TODAY-");
});

it("resolves 'today' (lowercase) to today's date", () => {
keywordResolvesToDate("today", expectedToday);
});

});
196 changes: 196 additions & 0 deletions apps/server/src/routes/api/edited-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import dayjs from "dayjs";
import beccaService from "../../becca/becca_service.js";
import sql from "../../services/sql.js";
import cls from "../../services/cls.js";
import becca from "../../becca/becca.js";
import type { Request } from "express";
import { NotePojo } from "../../becca/becca-interface.js";
import type BNote from "../../becca/entities/bnote.js";
import { EditedNotes, EditedNotesResponse } from "@triliumnext/commons";
import dateUtils from "../../services/date_utils.js";

interface NotePath {
noteId: string;
branchId?: string;
title: string;
notePath: string[];
path: string;
}

interface NotePojoWithNotePath extends NotePojo {
notePath?: string[] | null;
}

function getEditedNotesOnDate(req: Request) {
const dateFilter = dateNoteLabelKeywordToDateFilter(req.params.date);

if (!dateFilter.date) {
return {
notes: [],
limit: 0,
} satisfies EditedNotesResponse;
}

const sqlParams = { date: dateFilter.date + "%" };
const limit = 50;
const sqlQuery = /*sql*/`\
SELECT notes.*
FROM notes
WHERE noteId IN (
SELECT noteId FROM notes
WHERE
(notes.dateCreated LIKE :date OR notes.dateModified LIKE :date)
AND (notes.noteId NOT LIKE '\\_%' ESCAPE '\\')
UNION ALL
SELECT noteId FROM revisions
WHERE revisions.dateCreated LIKE :date
)
ORDER BY isDeleted
LIMIT ${limit}`;

const noteIds = sql.getColumn<string>(
sqlQuery,
sqlParams
);

let notes = becca.getNotes(noteIds, true);

// Narrow down the results if a note is hoisted, similar to "Jump to note".
const hoistedNoteId = cls.getHoistedNoteId();
if (hoistedNoteId !== "root") {
notes = notes.filter((note) => note.hasAncestor(hoistedNoteId));
}

const editedNotes = notes.map((note) => {
const notePath = getNotePathData(note);

const notePojo: NotePojoWithNotePath = note.getPojo();
notePojo.notePath = notePath ? notePath.notePath : null;

return notePojo;
});

return {
notes: editedNotes,
limit: limit,
} satisfies EditedNotesResponse;
}

function getNotePathData(note: BNote): NotePath | undefined {
const retPath = note.getBestNotePath();

if (retPath) {
const noteTitle = beccaService.getNoteTitleForPath(retPath);

let branchId;

if (note.isRoot()) {
branchId = "none_root";
} else {
const parentNote = note.parents[0];
branchId = becca.getBranchFromChildAndParent(note.noteId, parentNote.noteId)?.branchId;
}

return {
noteId: note.noteId,
branchId: branchId,
title: noteTitle,
notePath: retPath,
path: retPath.join("/")
};
}
}

const formatMap = new Map<string, { format: string, addUnit: dayjs.UnitType }>([
["today", { format: "YYYY-MM-DD", addUnit: "day" }],
["month", { format: "YYYY-MM", addUnit: "month" }],
["year", { format: "YYYY", addUnit: "year" }]
]);

function formatDateFromKeywordAndDelta(
startingDate: dayjs.Dayjs,
keyword: string,
delta: number
): string {
const handler = formatMap.get(keyword);

if (!handler) {
throw new Error(`Unrecognized keyword: ${keyword}`);
}

const date = startingDate.add(delta, handler.addUnit);
return date.format(handler.format);
}

interface DateValue {
// kind: "date",
date: string | null,
}

type DateFilter = DateValue;

/**
* Resolves a date string into a concrete date representation.
* The date string can be a keyword with an optional delta, or a standard date format.
*
* Supported keywords are:
* - `today`: Resolves to the current date in `YYYY-MM-DD` format.
* - `month`: Resolves to the current month in `YYYY-MM` format.
* - `year`: Resolves to the current year in `YYYY` format.
*
* An optional delta can be appended to the keyword to specify an offset.
* For example:
* - `today-1` resolves to yesterday.
* - `month+2` resolves to the month after next.
* - `year-10` resolves to 10 years ago.
*
* If the `dateStr` does not match a keyword pattern, it is returned as is.
* This is to support standard date formats like `YYYY-MM-DD`, `YYYY-MM`, or `YYYY`.
*
* @param dateStr A string representing the date. This can be a keyword
* (e.g., "today", "month-1", "year+5") or a date string
* (e.g., "2023-10-27", "2023-10", "2023").
* @returns A `DateFilter` object containing the resolved date string.
*/
export function dateNoteLabelKeywordToDateFilter(dateStr: string): DateFilter {
const keywordAndDelta = dateStr.match(/^(today|month|year)([+-]\d+)?$/i);

if (keywordAndDelta) {
const keyword = keywordAndDelta[1].toLowerCase();
const delta = keywordAndDelta[2] ? parseInt(keywordAndDelta[2]) : 0;

const clientDate = dayjs(dateUtils.localNowDate());
const date = formatDateFromKeywordAndDelta(clientDate, keyword, delta);
return {
date: date
};
}

// Check if it's a valid date format (YYYY-MM-DD, YYYY-MM, or YYYY)
const isDatePrefix = isValidDatePrefix(dateStr);

if (isDatePrefix) {
return {
date: dateStr
};
} else {
// Not a keyword and not a valid date prefix
return {
date: null
}
}
}

function isValidDatePrefix(dateStr: string): boolean {
// Check if it starts with YYYY format
if (/^\d{4}/.test(dateStr)) {
const year = parseInt(dateStr.substring(0, 4));
return !isNaN(year) && year > 0 && year < 10000;
}

return false;
}

export default {
getEditedNotesOnDate,
};
Loading