diff --git a/apps/client/src/widgets/ribbon/EditedNotesTab.tsx b/apps/client/src/widgets/ribbon/EditedNotesTab.tsx index 4bdae4126b..5e0f2095eb 100644 --- a/apps/client/src/widgets/ribbon/EditedNotesTab.tsx +++ b/apps/client/src/widgets/ribbon/EditedNotesTab.tsx @@ -1,6 +1,6 @@ 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"; @@ -8,15 +8,15 @@ import NoteLink from "../react/NoteLink"; import { joinElements } from "../react/react_utils"; export default function EditedNotesTab({ note }: TabContext) { - const [ editedNotes, setEditedNotes ] = useState(); + const [ editedNotes, setEditedNotes ] = useState(); useEffect(() => { if (!note) return; - server.get(`edited-notes/${note.getLabelValue("dateNote")}`).then(async editedNotes => { - editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId); - const noteIds = editedNotes.flatMap((n) => n.noteId); + server.get(`edited-notes/${note.getLabelValue("dateNote")}`).then(async response => { + const filteredNotes = response.notes.filter((n) => n.noteId !== note.noteId); + const noteIds = filteredNotes.flatMap((n) => n.noteId); await froca.getNotes(noteIds, true); // preload all at once - setEditedNotes(editedNotes); + setEditedNotes(filteredNotes); }); }, [ note?.noteId ]); diff --git a/apps/server/src/routes/api/edited-notes.spec.ts b/apps/server/src/routes/api/edited-notes.spec.ts new file mode 100644 index 0000000000..2626af8bcf --- /dev/null +++ b/apps/server/src/routes/api/edited-notes.spec.ts @@ -0,0 +1,124 @@ +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 expectedTodayPlus1 = "2025-01-02"; +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 'TODAY+1' to tomorrow's date", () => { + keywordResolvesToDate("TODAY+1", expectedTodayPlus1); + }); + + 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 'TODAY - 1' (with spaces) to yesterday's date", () => { + keywordResolvesToDate("TODAY - 1", expectedTodayMinus1); + keywordResolvesToDate("TODAY- 1", expectedTodayMinus1); + 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); + }); + +}); diff --git a/apps/server/src/routes/api/edited-notes.ts b/apps/server/src/routes/api/edited-notes.ts new file mode 100644 index 0000000000..884b554c07 --- /dev/null +++ b/apps/server/src/routes/api/edited-notes.ts @@ -0,0 +1,198 @@ +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 + "%", + 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( + 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: sqlParams.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([ + ["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)\s*([+-]\s*\d+)?$/i); + + if (keywordAndDelta) { + const keyword = keywordAndDelta[1].toLowerCase(); + const delta = parseInt(keywordAndDelta[2]?.replace(/\s/g, "") ?? "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 and only contains numbers and dashes afterwards + if (/^\d{4}[-\d]*$/.test(dateStr)) { + const year = parseInt(dateStr.substring(0, 4)); + return !isNaN(year) && year > 0 && year < 10000; + } + + return false; +} + +export default { + getEditedNotesOnDate, +}; diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index 9700e7f782..7780605dad 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,30 +1,14 @@ "use strict"; -import beccaService from "../../becca/becca_service.js"; import utils from "../../services/utils.js"; import sql from "../../services/sql.js"; -import cls from "../../services/cls.js"; import path from "path"; import becca from "../../becca/becca.js"; import blobService from "../../services/blob.js"; import eraseService from "../../services/erase.js"; import type { Request, Response } from "express"; import type BRevision from "../../becca/entities/brevision.js"; -import type BNote from "../../becca/entities/bnote.js"; -import type { NotePojo } from "../../becca/becca-interface.js"; -import { EditedNotesResponse, RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons"; - -interface NotePath { - noteId: string; - branchId?: string; - title: string; - notePath: string[]; - path: string; -} - -interface NotePojoWithNotePath extends NotePojo { - notePath?: string[] | null; -} +import { RevisionItem, RevisionPojo } from "@triliumnext/commons"; function getRevisionBlob(req: Request) { const preview = req.query.preview === "true"; @@ -151,73 +135,11 @@ function restoreRevision(req: Request) { } } -function getEditedNotesOnDate(req: Request) { - const noteIds = sql.getColumn(/*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 50`, - { date: `${req.params.date}%` } - ); - - 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)); - } - - return notes.map((note) => { - const notePath = getNotePathData(note); - - const notePojo: NotePojoWithNotePath = note.getPojo(); - notePojo.notePath = notePath ? notePath.notePath : null; - - return notePojo; - }) 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("/") - }; - } -} - export default { getRevisionBlob, getRevisions, getRevision, downloadRevision, - getEditedNotesOnDate, eraseAllRevisions, eraseAllExcessRevisions, eraseRevision, diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 78a1380b73..5b4dae29ac 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -22,6 +22,7 @@ import attachmentsApiRoute from "./api/attachments.js"; import autocompleteApiRoute from "./api/autocomplete.js"; import cloningApiRoute from "./api/cloning.js"; import revisionsApiRoute from "./api/revisions.js"; +import editedNotesApiRoute from "./api/edited-notes.js"; import recentChangesApiRoute from "./api/recent_changes.js"; import optionsApiRoute from "./api/options.js"; import passwordApiRoute from "./api/password.js"; @@ -349,7 +350,7 @@ function register(app: express.Application) { apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage); apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown); apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges); - apiRoute(GET, "/api/edited-notes/:date", revisionsApiRoute.getEditedNotesOnDate); + apiRoute(GET, "/api/edited-notes/:date", editedNotesApiRoute.getEditedNotesOnDate); apiRoute(PST, "/api/note-map/:noteId/tree", noteMapRoute.getTreeMap); apiRoute(PST, "/api/note-map/:noteId/link", noteMapRoute.getLinkMap); diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index dc6dc4d412..7f28fa395f 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -164,11 +164,18 @@ export type ToggleInParentResponse = { } export type EditedNotesResponse = { + notes: EditedNotes, + limit: number +} + +export type EditedNote = { noteId: string; isDeleted: boolean; title?: string; notePath?: string[] | null; -}[]; +}; + +export type EditedNotes = EditedNote[]; export interface MetadataResponse { dateCreated: string | undefined;