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
45 changed files with 1057 additions and 918 deletions

View File

@@ -9,9 +9,9 @@
"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.7",
"@redocly/cli": "2.14.5",
"archiver": "7.0.1",
"fs-extra": "11.3.3",
"react": "19.2.3",

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.1",
"@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.7",
"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

@@ -42,7 +42,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
it("removes boolean normally without inheritance", async () => {
@@ -91,7 +91,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "false",
isInheritable: false
}, undefined);
});
});
it("overrides boolean with inherited false", async () => {
@@ -112,7 +112,7 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
it("deletes override boolean with inherited false with already existing value", async () => {
@@ -134,6 +134,6 @@ describe("Set boolean with inheritance", () => {
name: "foo",
value: "",
isInheritable: false
}, undefined);
});
});
});

View File

@@ -14,13 +14,13 @@ async function addLabel(noteId: string, name: string, value: string = "", isInhe
});
}
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false, componentId?: string) {
export async function setLabel(noteId: string, name: string, value: string = "", isInheritable = false) {
await server.put(`notes/${noteId}/set-attribute`, {
type: "label",
name,
value,
isInheritable,
}, componentId);
isInheritable
});
}
export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) {
@@ -117,15 +117,15 @@ function removeOwnedRelationByName(note: FNote, relationName: string) {
* @param name the name of the attribute to set.
* @param value the value of the attribute to set.
*/
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined, componentId?: string) {
export async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
if (value !== null && value !== undefined) {
// Create or update the attribute.
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }, componentId);
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
} else {
// Remove the attribute if it exists on the server but we don't define a value for it.
const attributeId = note.getAttribute(type, name)?.attributeId;
if (attributeId) {
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId);
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`);
}
}
}

View File

@@ -103,7 +103,7 @@ async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: st
* @param moveToParent whether to automatically go to the parent note path after a succesful delete. Usually makes sense if deleting the active note(s).
* @returns promise that returns false if the operation was cancelled or there was nothing to delete, true if the operation succeeded.
*/
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true, componentId?: string) {
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false, moveToParent = true) {
branchIdsToDelete = filterRootNote(branchIdsToDelete);
if (branchIdsToDelete.length === 0) {
@@ -139,9 +139,9 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
const branch = froca.getBranch(branchIdToDelete);
if (deleteAllClones && branch) {
await server.remove(`notes/${branch.noteId}${query}`, componentId);
await server.remove(`notes/${branch.noteId}${query}`);
} else {
await server.remove(`branches/${branchIdToDelete}${query}`, componentId);
await server.remove(`branches/${branchIdToDelete}${query}`);
}
}

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

@@ -1,11 +1,10 @@
import { MimeType } from "@triliumnext/commons";
import { type AutoHighlightResult, ensureMimeTypes, highlight, highlightAuto, type HighlightResult, loadTheme, type Theme,Themes } from "@triliumnext/highlightjs";
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
import { t } from "./i18n.js";
import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type AutoHighlightResult, type HighlightResult, type Theme } from "@triliumnext/highlightjs";
import mime_types from "./mime_types.js";
import options from "./options.js";
import { t } from "./i18n.js";
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
import { isShare } from "./utils.js";
import { MimeType } from "@triliumnext/commons";
let highlightingLoaded = false;
@@ -77,15 +76,13 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
}
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
if (!mimeTypeHint && highlightingLoaded) {
if (highlightingLoaded) {
return;
}
// Load theme.
if (!highlightingLoaded) {
const currentThemeName = String(options.get("codeBlockTheme"));
await loadHighlightingTheme(currentThemeName);
}
const currentThemeName = String(options.get("codeBlockTheme"));
await loadHighlightingTheme(currentThemeName);
// Load mime types.
let mimeTypes: MimeType[];
@@ -97,7 +94,7 @@ export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
enabled: true,
mime: mimeTypeHint.replace("-", "/")
}
];
]
} else {
mimeTypes = mime_types.getMimeTypes();
}
@@ -127,9 +124,9 @@ export function isSyntaxHighlightEnabled() {
if (!isShare) {
const theme = options.get("codeBlockTheme");
return !!theme && theme !== "none";
} else {
return true;
}
return true;
}
/**

View File

@@ -217,7 +217,6 @@ function LabelInput({ inputId, ...props }: CellProps & { inputId: string }) {
id={inputId}
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
value={valueAttr.value}
checked={definition.labelType === "boolean" ? valueAttr.value === "true" : undefined}
placeholder={t("promoted_attributes.unset-field-placeholder")}
data-attribute-id={valueAttr.attributeId}
data-attribute-type={valueAttr.type}

View File

@@ -1,8 +1,8 @@
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
import { CreateChildrenResponse } from "@triliumnext/commons";
import server from "../../../services/server";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import server from "../../../services/server";
import froca from "../../../services/froca";
interface NewEventOpts {
title: string;
@@ -10,7 +10,6 @@ interface NewEventOpts {
endDate?: string | null;
startTime?: string | null;
endTime?: string | null;
componentId?: string;
}
interface ChangeEventOpts {
@@ -18,48 +17,30 @@ interface ChangeEventOpts {
endDate?: string | null;
startTime?: string | null;
endTime?: string | null;
componentId?: string;
}
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime, componentId }: NewEventOpts) {
const attributes: Omit<AttributeRow, "noteId" | "attributeId">[] = [];
attributes.push({
type: "label",
name: "startDate",
value: startDate
});
if (endDate) {
attributes.push({
type: "label",
name: "endDate",
value: endDate
});
}
if (startTime) {
attributes.push({
type: "label",
name: "startTime",
value: startTime
});
}
if (endTime) {
attributes.push({
type: "label",
name: "endTime",
value: endTime
});
}
export async function newEvent(parentNote: FNote, { title, startDate, endDate, startTime, endTime }: NewEventOpts) {
// Create the note.
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
const { note } = await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
title,
content: "",
type: "text",
attributes
}, componentId);
type: "text"
});
// Set the attributes.
setLabel(note.noteId, "startDate", startDate);
if (endDate) {
setLabel(note.noteId, "endDate", endDate);
}
if (startTime) {
setLabel(note.noteId, "startTime", startTime);
}
if (endTime) {
setLabel(note.noteId, "endTime", endTime);
}
}
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime, componentId }: ChangeEventOpts) {
export async function changeEvent(note: FNote, { startDate, endDate, startTime, endTime }: ChangeEventOpts) {
// Don't store the end date if it's empty.
if (endDate === startDate) {
endDate = undefined;
@@ -71,12 +52,12 @@ export async function changeEvent(note: FNote, { startDate, endDate, startTime,
let endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate";
const noteId = note.noteId;
setLabel(noteId, startAttribute, startDate, false, componentId);
setAttribute(note, "label", endAttribute, endDate, componentId);
setLabel(noteId, startAttribute, startDate);
setAttribute(note, "label", endAttribute, endDate);
startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
setAttribute(note, "label", startAttribute, startTime, componentId);
setAttribute(note, "label", endAttribute, endTime, componentId);
setAttribute(note, "label", startAttribute, startTime);
setAttribute(note, "label", endAttribute, endTime);
}

View File

@@ -1,12 +1,12 @@
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker";
import FNote from "../../../entities/fnote";
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu";
import { getArchiveMenuItem } from "../../../menus/context_menu_utils";
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker";
import link_context_menu from "../../../menus/link_context_menu";
import branches from "../../../services/branches";
import { getArchiveMenuItem } from "../../../menus/context_menu_utils";
import { t } from "../../../services/i18n";
export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote, componentId?: string) {
export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote) {
e.preventDefault();
e.stopPropagation();
@@ -30,16 +30,16 @@ export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parent
}
if (branchIdToDelete) {
await branches.deleteNotes([ branchIdToDelete ], false, false, componentId);
await branches.deleteNotes([ branchIdToDelete ], false, false);
}
}
},
{ kind: "separator" },
{
kind: "custom",
componentFn: () => NoteColorPicker({note})
componentFn: () => NoteColorPicker({note: note})
}
],
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId),
});
})
}

View File

@@ -1,10 +1,9 @@
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
import clsx from "clsx";
import FNote from "../../../entities/fnote";
import froca from "../../../services/froca";
import server from "../../../services/server";
import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
import FNote from "../../../entities/fnote";
import server from "../../../services/server";
import clsx from "clsx";
interface Event {
startDate: string,
@@ -106,8 +105,7 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
const eventData: EventInput = {
id: note.noteId,
title,
title: title,
start: startDate,
url: `#${note.noteId}?popup`,
noteId: note.noteId,
@@ -150,12 +148,12 @@ async function parseCustomTitle(customTitlettributeName: string | null, note: FN
}
async function buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name));
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
const result: Array<[string, string]> = [];
for (const attribute of filteredDisplayedAttributes) {
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""]);
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
}
return result;

View File

@@ -5,7 +5,7 @@ import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, Local
import { DateClickArg } from "@fullcalendar/interaction";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { RefObject } from "preact";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
@@ -17,7 +17,6 @@ import { isMobile } from "../../../services/utils";
import ActionButton from "../../react/ActionButton";
import Button, { ButtonGroup } from "../../react/Button";
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
import { ParentComponent } from "../../react/react_utils";
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
import { ViewModeProps } from "../interface";
import { changeEvent, newEvent } from "./api";
@@ -88,7 +87,6 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
};
export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarViewData>) {
const parentComponent = useContext(ParentComponent);
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = useRef<FullCalendar>(null);
@@ -107,34 +105,26 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
const eventBuilder = useMemo(() => {
if (!isCalendarRoot) {
return async () => await buildEvents(noteIds);
}
}
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
}, [isCalendarRoot, noteIds]);
const plugins = usePlugins(isEditable, isCalendarRoot);
const locale = useLocale();
const { eventDidMount } = useEventDisplayCustomization(note, parentComponent?.componentId);
const editingProps = useEditing(note, isEditable, isCalendarRoot, parentComponent?.componentId);
const { eventDidMount } = useEventDisplayCustomization(note);
const editingProps = useEditing(note, isEditable, isCalendarRoot);
// React to changes.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
const api = calendarRef.current;
if (!api) return;
// Subnote attribute change.
if (loadResults.getAttributeRows(parentComponent?.componentId).some((a) => noteIds.includes(a.noteId ?? ""))) {
if (loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) // note title change.
|| loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change.
{
// Defer execution after the load results are processed so that the event builder has the updated data to work with.
setTimeout(() => api.refetchEvents(), 0);
return; // early return since we'll refresh the events anyway
}
// Title change.
for (const noteId of loadResults.getNoteIds().filter(noteId => noteIds.includes(noteId))) {
const event = api.getEventById(noteId);
const note = froca.getNoteFromCache(noteId);
if (!event || !note) continue;
event.setProp("title", note.title);
setTimeout(() => {
calendarRef.current?.refetchEvents();
}, 0);
}
});
@@ -232,7 +222,7 @@ function useLocale() {
return calendarLocale;
}
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, componentId: string | undefined) {
function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean) {
const onCalendarSelection = useCallback(async (e: DateSelectArg) => {
const { startDate, endDate } = parseStartEndDateFromEvent(e);
if (!startDate) return;
@@ -244,8 +234,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
return;
}
newEvent(note, { title, startDate, endDate, startTime, endTime, componentId });
}, [ note, componentId ]);
newEvent(note, { title, startDate, endDate, startTime, endTime });
}, [ note ]);
const onEventChange = useCallback(async (e: EventChangeArg) => {
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
@@ -254,8 +244,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
const { startTime, endTime } = parseStartEndTimeFromEvent(e.event);
const note = await froca.getNote(e.event.extendedProps.noteId);
if (!note) return;
changeEvent(note, { startDate, endDate, startTime, endTime, componentId });
}, [ componentId ]);
changeEvent(note, { startDate, endDate, startTime, endTime });
}, []);
// Called upon when clicking the day number in the calendar, opens or creates the day note but only if in a calendar root.
const onDateClick = useCallback(async (e: DateClickArg) => {
@@ -274,7 +264,7 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
};
}
function useEventDisplayCustomization(parentNote: FNote, componentId: string | undefined) {
function useEventDisplayCustomization(parentNote: FNote) {
const eventDidMount = useCallback((e: EventMountArg) => {
const { iconClass, promotedAttributes } = e.event.extendedProps;
@@ -331,7 +321,7 @@ function useEventDisplayCustomization(parentNote: FNote, componentId: string | u
const note = await froca.getNote(e.event.extendedProps.noteId);
if (!note) return;
openCalendarContextMenu(contextMenuEvent, note, parentNote, componentId);
openCalendarContextMenu(contextMenuEvent, note, parentNote);
}
if (isMobile()) {

View File

@@ -82,10 +82,6 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
align-items: flex-start;
}
.modal.popup-editor-dialog .note-detail {
width: 100%;
}
.modal.popup-editor-dialog .note-detail.full-height {
flex-grow: 0;
height: 100%;
@@ -110,4 +106,4 @@ body.mobile .modal.popup-editor-dialog .modal-dialog {
margin: 0;
border-radius: 0;
}
}
}

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

@@ -1,15 +1,12 @@
import "./Render.css";
import { useEffect, useRef, useState } from "preact/hooks";
import attributes from "../../services/attributes";
import { t } from "../../services/i18n";
import render from "../../services/render";
import Alert from "../react/Alert";
import { useTriliumEvent } from "../react/hooks";
import RawHtml from "../react/RawHtml";
import { refToJQuerySelector } from "../react/react_utils";
import { TypeWidgetProps } from "./type_widget";
import render from "../../services/render";
import { refToJQuerySelector } from "../react/react_utils";
import Alert from "../react/Alert";
import "./Render.css";
import { t } from "../../services/i18n";
import RawHtml from "../react/RawHtml";
import { useTriliumEvent } from "../react/hooks";
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
const contentRef = useRef<HTMLDivElement>(null);
@@ -34,13 +31,6 @@ export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
refresh();
});
// Refresh on attribute change.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().some(a => a.type === "relation" && a.name === "renderNote" && attributes.isAffecting(a, note))) {
refresh();
}
});
// Integration with search.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;

View File

@@ -20,9 +20,6 @@ export interface CanvasContent {
appState: Partial<AppState>;
}
/** Subset of the app state that should be persisted whenever they change. This explicitly excludes transient state like the current selection or zoom level. */
type ImportantAppState = Pick<AppState, "gridModeEnabled" | "viewBackgroundColor">;
export default function useCanvasPersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"], isReadOnly: boolean): Partial<ExcalidrawProps> {
const libraryChanged = useRef(false);
@@ -40,8 +37,6 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
const libraryCache = useRef<LibraryItem[]>([]);
const attachmentMetadata = useRef<AttachmentMetadata[]>([]);
const appStateToCompare = useRef<Partial<ImportantAppState>>({});
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
@@ -52,6 +47,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
libraryCache.current = [];
attachmentMetadata.current = [];
currentSceneVersion.current = -1;
// load saved content into excalidraw canvas
let content: CanvasContent = {
@@ -69,9 +65,6 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
loadData(api, content, theme);
// Initialize tracking state after loading to prevent redundant updates from initial onChange events
currentSceneVersion.current = getSceneVersion(api.getSceneElements());
// load the library state
loadLibrary(note).then(({ libraryItems, metadata }) => {
// Update the library and save to independent variables
@@ -85,7 +78,7 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
async getData() {
const api = apiRef.current;
if (!api) return;
const { content, svg } = await getData(api, appStateToCompare);
const { content, svg } = await getData(api);
const attachments: SavedData["attachments"] = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
// libraryChanged is unset in dataSaved()
@@ -156,47 +149,21 @@ export default function useCanvasPersistence(note: FNote, noteContext: NoteConte
const oldSceneVersion = currentSceneVersion.current;
const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements());
let hasChanges = (newSceneVersion !== oldSceneVersion);
// There are cases where the scene version does not change, but appState did.
if (!hasChanges) {
const importantAppState = appStateToCompare.current;
const currentAppState = apiRef.current.getAppState();
for (const key in importantAppState) {
if (importantAppState[key as keyof ImportantAppState] !== currentAppState[key as keyof ImportantAppState]) {
hasChanges = true;
break;
}
}
}
if (hasChanges) {
if (newSceneVersion !== oldSceneVersion) {
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
currentSceneVersion.current = newSceneVersion;
}
},
onLibraryChange: (libraryItems) => {
if (!apiRef.current || isReadOnly) return;
// Check if library actually changed by comparing with cached state
const hasChanges =
libraryItems.length !== libraryCache.current.length ||
libraryItems.some(item => {
const cachedItem = libraryCache.current.find(cached => cached.id === item.id);
return !cachedItem || cachedItem.name !== item.name;
});
if (hasChanges) {
libraryChanged.current = true;
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
}
onLibraryChange: () => {
libraryChanged.current = true;
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
}
};
}
async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObject<Partial<ImportantAppState>>) {
async function getData(api: ExcalidrawImperativeAPI) {
const elements = api.getSceneElements();
const appState = api.getAppState();
@@ -221,12 +188,6 @@ async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObjec
}
});
const importantAppState: ImportantAppState = {
gridModeEnabled: appState.gridModeEnabled,
viewBackgroundColor: appState.viewBackgroundColor
};
appStateToCompare.current = importantAppState;
const content = {
type: "excalidraw",
version: 2,
@@ -236,7 +197,7 @@ async function getData(api: ExcalidrawImperativeAPI, appStateToCompare: RefObjec
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
...importantAppState
gridModeEnabled: appState.gridModeEnabled
}
};

View File

@@ -8,7 +8,7 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import utils, { isMobile } from "../../../services/utils";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
import Admonition from "../../react/Admonition";
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps } from "../code/Code";
export interface SplitEditorProps extends EditableCodeProps {
@@ -30,22 +30,12 @@ export interface SplitEditorProps extends EditableCodeProps {
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default function SplitEditor(props: SplitEditorProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
if (readOnly) {
return <ReadOnlyView {...props} />;
}
return <EditorWithSplit {...props} />;
}
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
const splitEditorOrientation = useSplitOrientation(forceOrientation);
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
const containerRef = useRef<HTMLDivElement>(null);
const editor = (
const editor = (!readOnly &&
<div className="note-detail-split-editor-col">
{editorBefore}
<div className="note-detail-split-editor">
@@ -63,14 +53,19 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
</div>
);
const preview = <PreviewContainer
error={error}
previewContent={previewContent}
previewButtons={previewButtons}
/>;
const preview = (
<div className="note-detail-split-preview-col">
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
{previewContent}
</div>
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
{previewButtons}
</div>
</div>
);
useEffect(() => {
if (!utils.isDesktop() || !containerRef.current) return;
if (!utils.isDesktop() || !containerRef.current || readOnly) return;
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
const splitInstance = Split(elements, {
rtl: glob.isRtl,
@@ -81,10 +76,10 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
});
return () => splitInstance.destroy();
}, [ splitEditorOrientation ]);
}, [ readOnly, splitEditorOrientation ]);
return (
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${readOnly ? "split-read-only" : ""} ${className ?? ""}`}>
{splitEditorOrientation === "horizontal"
? <>{editor}{preview}</>
: <>{preview}{editor}</>}
@@ -92,43 +87,6 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
);
}
function ReadOnlyView({ ...props }: SplitEditorProps) {
const { note, onContentChanged } = props;
const content = useNoteBlob(note);
const onContentChangedRef = useRef(onContentChanged);
useEffect(() => {
onContentChangedRef.current = onContentChanged;
});
useEffect(() => {
onContentChangedRef.current?.(content?.content ?? "");
}, [ content ]);
return (
<div className={`note-detail-split note-detail-printable ${props.className} split-read-only`}>
<PreviewContainer {...props} />
</div>
);
}
function PreviewContainer({ error, previewContent, previewButtons }: {
error?: string | null;
previewContent: ComponentChildren;
previewButtons?: ComponentChildren;
}) {
return (
<div className="note-detail-split-preview-col">
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
{previewContent}
</div>
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
{previewButtons}
</div>
</div>
);
}
export function PreviewButton(props: Omit<ActionButtonProps, "titlePosition">) {
return <ActionButton
{...props}

View File

@@ -1,14 +1,13 @@
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import svgPanZoom from "svg-pan-zoom";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import utils from "../../../services/utils";
import { useElementSize, useTriliumEvent } from "../../react/hooks";
import { RawHtmlBlock } from "../../react/RawHtml";
import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor";
import { RawHtmlBlock } from "../../react/RawHtml";
import server from "../../../services/server";
import svgPanZoom from "svg-pan-zoom";
import { RefObject } from "preact";
import { useElementSize, useTriliumEvent } from "../../react/hooks";
import utils from "../../../services/utils";
import toast from "../../../services/toast";
interface SvgSplitEditorProps extends Omit<SplitEditorProps, "previewContent"> {
/**
@@ -145,7 +144,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
}
{...props}
/>
);
)
}
function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg: string | undefined) {
@@ -182,7 +181,7 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
lastPanZoom.current = {
pan: zoomInstance.getPan(),
zoom: zoomInstance.getZoom()
};
}
zoomRef.current = undefined;
zoomInstance.destroy();
};

View File

@@ -191,6 +191,7 @@ function ExperimentalOptions() {
values={filteredExperimentalFeatures}
keyProperty="id"
titleProperty="name"
descriptionProperty="description"
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
/>
</OptionsSection>

View File

@@ -1,14 +1,17 @@
import FormCheckbox from "../../../react/FormCheckbox";
interface CheckboxListProps<T> {
values: T[];
keyProperty: keyof T;
titleProperty?: keyof T;
disabledProperty?: keyof T;
descriptionProperty?: keyof T;
currentValue: string[];
onChange: (newValues: string[]) => void;
columnWidth?: string;
}
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, descriptionProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
function toggleValue(value: string) {
if (currentValue.includes(value)) {
// Already there, needs removing.
@@ -22,20 +25,17 @@ export default function CheckboxList<T>({ values, keyProperty, titleProperty, di
return (
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
{values.map(value => (
<li>
<label className="tn-checkbox">
<input
type="checkbox"
className="form-check-input"
value={String(value[keyProperty])}
checked={currentValue.includes(String(value[keyProperty]))}
disabled={!!(disabledProperty && value[disabledProperty])}
onChange={e => toggleValue((e.target as HTMLInputElement).value)}
/>
{String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
</label>
<li key={String(value[keyProperty])}>
<FormCheckbox
label={String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
name={String(value[keyProperty])}
currentValue={currentValue.includes(String(value[keyProperty]))}
disabled={!!(disabledProperty && value[disabledProperty])}
hint={value && (descriptionProperty ? String(value[descriptionProperty]) : undefined)}
onChange={() => toggleValue(String(value[keyProperty]))}
/>
</li>
))}
</ul>
)
}
);
}

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

@@ -74,7 +74,7 @@
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
"cheerio": "1.2.0",
"cheerio": "1.1.2",
"chokidar": "5.0.0",
"cls-hooked": "4.2.2",
"compression": "1.8.1",
@@ -91,7 +91,7 @@
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.4",
"express-rate-limit": "8.2.1",
"express-session": "1.19.0",
"express-session": "1.18.2",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.3",
"helmet": "8.1.0",
@@ -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

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

@@ -1,4 +1,4 @@
import type { AttributeRow, NoteType } from "@triliumnext/commons";
import type { NoteType } from "@triliumnext/commons";
export interface NoteParams {
/** optionally can force specific noteId */
@@ -24,6 +24,4 @@ export interface NoteParams {
utcDateCreated?: string;
ignoreForbiddenParents?: boolean;
target?: "into";
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
}

View File

@@ -1,33 +1,33 @@
import { type AttachmentRow, type AttributeRow, type BranchRow, dayjs, type NoteRow } from "@triliumnext/commons";
import fs from "fs";
import html2plaintext from "html2plaintext";
import { t } from "i18next";
import path from "path";
import url from "url";
import becca from "../becca/becca.js";
import BAttachment from "../becca/entities/battachment.js";
import BAttribute from "../becca/entities/battribute.js";
import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import ValidationError from "../errors/validation_error.js";
import cls from "../services/cls.js";
import log from "../services/log.js";
import protectedSessionService from "../services/protected_session.js";
import { newEntityId, quoteRegex, toMap, unescapeHtml } from "../services/utils.js";
import sql from "./sql.js";
import optionService from "./options.js";
import dateUtils from "./date_utils.js";
import entityChangesService from "./entity_changes.js";
import eventService from "./events.js";
import htmlSanitizer from "./html_sanitizer.js";
import imageService from "./image.js";
import noteTypesService from "./note_types.js";
import type { NoteParams } from "./note-interface.js";
import optionService from "./options.js";
import request from "./request.js";
import cls from "../services/cls.js";
import protectedSessionService from "../services/protected_session.js";
import log from "../services/log.js";
import { newEntityId, unescapeHtml, quoteRegex, toMap } from "../services/utils.js";
import revisionService from "./revisions.js";
import sql from "./sql.js";
import type TaskContext from "./task_context.js";
import request from "./request.js";
import path from "path";
import url from "url";
import becca from "../becca/becca.js";
import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import BAttribute from "../becca/entities/battribute.js";
import BAttachment from "../becca/entities/battachment.js";
import { dayjs } from "@triliumnext/commons";
import htmlSanitizer from "./html_sanitizer.js";
import ValidationError from "../errors/validation_error.js";
import noteTypesService from "./note_types.js";
import fs from "fs";
import ws from "./ws.js";
import html2plaintext from "html2plaintext";
import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
import type TaskContext from "./task_context.js";
import type { NoteParams } from "./note-interface.js";
import imageService from "./image.js";
import { t } from "i18next";
interface FoundLink {
name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink";
@@ -47,13 +47,14 @@ function getNewNotePosition(parentNote: BNote) {
.reduce((min, note) => Math.min(min, note?.notePosition || 0), 0);
return minNotePos - 10;
}
const maxNotePos = parentNote
.getChildBranches()
.filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position
.reduce((max, note) => Math.max(max, note?.notePosition || 0), 0);
} else {
const maxNotePos = parentNote
.getChildBranches()
.filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position
.reduce((max, note) => Math.max(max, note?.notePosition || 0), 0);
return maxNotePos + 10;
return maxNotePos + 10;
}
}
function triggerNoteTitleChanged(note: BNote) {
@@ -87,7 +88,7 @@ function copyChildAttributes(parentNote: BNote, childNote: BNote) {
new BAttribute({
noteId: childNote.noteId,
type: attr.type,
name,
name: name,
value: attr.value,
position: attr.position,
isInheritable: attr.isInheritable
@@ -221,14 +222,6 @@ function createNewNote(params: NoteParams): {
utcDateCreated: params.utcDateCreated
}).save();
// Create attributes atomically.
for (const attribute of params.attributes || []) {
new BAttribute({
...attribute,
noteId: note.noteId
}).save();
}
note.setContent(params.content);
branch = new BBranch({
@@ -267,7 +260,7 @@ function createNewNote(params: NoteParams): {
eventService.emit(eventService.ENTITY_CHANGED, { entityName: "blobs", entity: note });
eventService.emit(eventService.ENTITY_CREATED, { entityName: "branches", entity: branch });
eventService.emit(eventService.ENTITY_CHANGED, { entityName: "branches", entity: branch });
eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote });
eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote: parentNote });
log.info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`);
@@ -315,8 +308,9 @@ function createNewNoteWithTarget(target: "into" | "after" | "before", targetBran
entityChangesService.putNoteReorderingEntityChange(params.parentNoteId);
return retObject;
} else {
throw new Error(`Unknown target '${target}'`);
}
throw new Error(`Unknown target '${target}'`);
}
function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) {
@@ -494,7 +488,7 @@ function findRelationMapLinks(content: string, foundLinks: FoundLink[]) {
});
}
} catch (e: any) {
log.error(`Could not scan for relation map links: ${e.message}`);
log.error("Could not scan for relation map links: " + e.message);
}
}
@@ -662,8 +656,8 @@ function saveAttachments(note: BNote, content: string) {
const attachment = note.saveAttachment({
role: "file",
mime,
title,
mime: mime,
title: title,
content: buffer
});
@@ -959,7 +953,7 @@ function duplicateSubtree(origNoteId: string, newParentNoteId: string) {
const duplicateNoteSuffix = t("notes.duplicate-note-suffix");
if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) {
res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix });
res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix: duplicateNoteSuffix });
}
res.note.save();
@@ -1056,12 +1050,13 @@ function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | und
note: existingNote,
branch: createDuplicatedBranch()
};
} else {
return {
// order here is important, note needs to be created first to not mess up the becca
note: createDuplicatedNote(),
branch: createDuplicatedBranch()
};
}
return {
// order here is important, note needs to be created first to not mess up the becca
note: createDuplicatedNote(),
branch: createDuplicatedBranch()
};
}
function getNoteIdMapping(origNote: BNote) {

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",
@@ -23,7 +23,7 @@
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "7.3.1",
"vitest": "4.0.18"
"vitest": "4.0.17"
},
"eslintConfig": {
"extends": "preact"

4
docs/README-hi.md vendored
View File

@@ -114,8 +114,8 @@ application with focus on building large personal knowledge bases.
location pins and GPX tracks
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced
showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
* ऑटोमेशन के लिए [REST
API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi)
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for
automation
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile
frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for

52
flake.lock generated
View File

@@ -18,26 +18,43 @@
"type": "github"
}
},
"nixpkgs": {
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1769184885,
"narHash": "sha256-wVX5Cqpz66SINNsmt3Bv/Ijzzfl8EPUISq5rKK129K0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "12689597ba7a6d776c3c979f393896be095269d4",
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "NixOS",
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765701828,
"narHash": "sha256-bUqeCi+mdqXt6Ag0n+9QAqyvFiQPZdSCzTI70Nn3HhA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "62996354316ef041b26c36e998a9ef193ede2864",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
},
"pnpm2nix": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
@@ -77,6 +94,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@@ -2,14 +2,11 @@
description = "Trilium Notes (experimental flake)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
nixpkgs.url = "github:nixos/nixpkgs/master";
flake-utils.url = "github:numtide/flake-utils";
pnpm2nix = {
url = "github:FliegendeWurst/pnpm2nix-nzbr";
inputs = {
flake-utils.follows = "flake-utils";
nixpkgs.follows = "nixpkgs";
};
inputs.nixpkgs.follows = "nixpkgs";
};
};

View File

@@ -51,9 +51,9 @@
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
"@types/node": "24.10.9",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vitest/ui": "4.0.18",
"@vitest/browser-webdriverio": "4.0.17",
"@vitest/coverage-v8": "4.0.17",
"@vitest/ui": "4.0.17",
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "3.14.0",
@@ -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.7",
"happy-dom": "20.3.3",
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
@@ -77,7 +77,7 @@
"upath": "2.0.1",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
"vitest": "4.0.17"
},
"license": "AGPL-3.0-only",
"author": {
@@ -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

@@ -21,13 +21,13 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",
"ckeditor5": "47.4.0",
"eslint": "9.39.2",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -38,7 +38,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.0.17",
"webdriverio": "9.23.2"
},
"peerDependencies": {

View File

@@ -22,13 +22,13 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",
"ckeditor5": "47.4.0",
"eslint": "9.39.2",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -39,7 +39,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.0.17",
"webdriverio": "9.23.2"
},
"peerDependencies": {

View File

@@ -24,13 +24,13 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",
"ckeditor5": "47.4.0",
"eslint": "9.39.2",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.0.17",
"webdriverio": "9.23.2"
},
"peerDependencies": {

View File

@@ -24,13 +24,13 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",
"ckeditor5": "47.4.0",
"eslint": "9.39.2",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.0.17",
"webdriverio": "9.23.2"
},
"peerDependencies": {

View File

@@ -24,13 +24,13 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "54.3.2",
"@ckeditor/ckeditor5-dev-build-tools": "54.3.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.0.17",
"@vitest/coverage-istanbul": "4.0.17",
"ckeditor5": "47.4.0",
"eslint": "9.39.2",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.0.17",
"webdriverio": "9.23.2"
},
"peerDependencies": {
@@ -71,6 +71,6 @@
},
"dependencies": {
"@types/lodash-es": "4.17.12",
"lodash-es": "4.17.23"
"lodash-es": "4.17.22"
}
}

View File

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

View File

@@ -39,8 +39,8 @@
"@fsegurai/codemirror-theme-tokyo-night-day": "6.2.3",
"@fsegurai/codemirror-theme-tokyo-night-storm": "6.2.3",
"@fsegurai/codemirror-theme-volcano": "6.2.3",
"@fsegurai/codemirror-theme-vscode-dark": "6.2.4",
"@fsegurai/codemirror-theme-vscode-light": "6.2.4",
"@fsegurai/codemirror-theme-vscode-dark": "6.2.3",
"@fsegurai/codemirror-theme-vscode-light": "6.2.3",
"@replit/codemirror-indentation-markers": "6.5.3",
"@replit/codemirror-lang-nix": "6.0.1",
"@replit/codemirror-vim": "6.3.0",

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

@@ -26,12 +26,6 @@ export const NOTE_TYPE_ICONS = {
const FILE_MIME_MAPPINGS = {
"application/pdf": "bx bxs-file-pdf",
"application/vnd.oasis.opendocument.text": "bx bxs-file-doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "bx bxs-file-doc",
};
const IMAGE_MIME_MAPPINGS = {
"image/gif": "bx bxs-file-gif",
};
export function getNoteIcon({ noteId, type, mime, iconClass, workspaceIconClass, isFolder }: {
@@ -61,9 +55,6 @@ export function getNoteIcon({ noteId, type, mime, iconClass, workspaceIconClass,
return correspondingMimeType?.icon ?? NOTE_TYPE_ICONS.code;
} else if (type === "file") {
return FILE_MIME_MAPPINGS[mime] ?? NOTE_TYPE_ICONS.file;
} else if (type === "image") {
return IMAGE_MIME_MAPPINGS[mime] ?? NOTE_TYPE_ICONS.image;
}
return NOTE_TYPE_ICONS[type];
}

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

View File

@@ -22,6 +22,7 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
continue;
}
registeredMimeTypes.add(mime);
const loader = syntaxDefinitions[mime];
if (!loader) {
unsupportedMimeTypes.add(mime);
@@ -30,7 +31,6 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
const language = (await loader()).default;
hljs.registerLanguage(mime, language);
registeredMimeTypes.add(mime);
}
}

808
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff