Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/trilium-core/src/services/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,42 @@ describe("Tree", () => {
}
});

it("sorts notes by multiple attributes in order", () => {
const note = buildNote({
children: [
{title: "first", "#priority": "1", "#dueDate": "2026-05-20"},
{title: "third", "#priority": "2", "#dueDate": "2026-05-01"},
{title: "second", "#priority": "1", "#dueDate": "2026-05-10"}
],
"#sorted": "priority,dueDate"
});

getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});

const orderedTitles = note.children.map((child) => child.title);
expect(orderedTitles).toStrictEqual(["second", "first", "third"]);
});

it("supports explicit sort direction for each sorted attribute", () => {
const note = buildNote({
children: [
{title: "low-priority", "#priority": "1", "#dueDate": "2026-05-01"},
{title: "next-date", "#priority": "2", "#dueDate": "2026-05-02"},
{title: "high-priority", "#priority": "3", "#dueDate": "2026-05-01"}
],
"#sorted": "dueDate:asc,priority:desc"
});

getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});

const orderedTitles = note.children.map((child) => child.title);
expect(orderedTitles).toStrictEqual(["high-priority", "low-priority", "next-date"]);
});
Comment on lines +97 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing "malformed sort direction fallback" test

The PR description explicitly lists "malformed sort direction fallback" as one of three new spec cases, but only two tests were added. The missing test would cover the case where #sorted = "priority:typo" (invalid direction) — with the current logic, rawDirection = "typo" fails the hasExplicitDirection check, so the entire string "priority:typo" becomes the attribute key, not "priority". Since that attribute doesn't exist, sorting silently falls back to title. A test case would make this documented, intentional behaviour explicit and guard against future regressions.

Fix in Claude Code


it("pins to the top and bottom", () => {
const note = buildNote({
children: [
Expand Down
73 changes: 56 additions & 17 deletions packages/trilium-core/src/services/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface ValidationResponse {
message?: string;
}

interface SortCriterion {
key: string;
reverse: boolean;
hasExplicitDirection: boolean;
}

function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse {
if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) {
return { branch: null, success: false, message: `Cannot change this note's location.` };
Expand Down Expand Up @@ -77,11 +83,44 @@ function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string)
return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId));
}

function parseSortCriteria(customSortBy: string, reverse: boolean): SortCriterion[] {
const criteria = customSortBy
.split(",")
.map((sortBy) => {
const trimmedSortBy = sortBy.trim();

if (!trimmedSortBy) {
return null;
}

const directionSeparatorIndex = trimmedSortBy.lastIndexOf(":");
const rawDirection = directionSeparatorIndex >= 0
? trimmedSortBy.substring(directionSeparatorIndex + 1).trim().toLowerCase()
: null;
const hasExplicitDirection = rawDirection === "asc" || rawDirection === "desc";
const key = hasExplicitDirection
? trimmedSortBy.substring(0, directionSeparatorIndex).trim()
: trimmedSortBy;

return {
key: key || "title",
reverse: hasExplicitDirection ? rawDirection === "desc" : reverse,
hasExplicitDirection
};
})
.filter((criterion): criterion is SortCriterion => !!criterion);

return criteria.length > 0 ? criteria : [{ key: "title", reverse, hasExplicitDirection: false }];
}

function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) {
if (!customSortBy) {
customSortBy = "title";
}

const sortCriteria = parseSortCriteria(customSortBy, reverse);
const hasExplicitDirection = sortCriteria.some((criterion) => criterion.hasExplicitDirection);
Comment on lines 119 to +122

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Title fallback direction is surprising when explicit and non-explicit criteria are mixed

hasExplicitDirection is true if any criterion carries :asc/:desc, and controls the post-loop title fallback: compareSortValues(titleAEl, titleBEl, hasExplicitDirection ? false : reverse). When a note has #sorted = "dueDate:asc" (one explicit) and #sortDirection = "desc" (global reverse), the final title-based tiebreaker is sorted ascending — the user's #sortDirection is silently ignored for the fallback. Users who previously relied on #sorted = "dueDate" + #sortDirection = "desc" to get a descending-dueDate, descending-title-fallback order and switch to the new "dueDate:asc" spelling will get an ascending title fallback instead.

Fix in Claude Code


// sortLocale can not be empty string or null value, default value must be set to undefined.
const sortLocale = _sortLocale || undefined;

Expand Down Expand Up @@ -124,26 +163,28 @@ function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse
}
}

function compareSortValues(a: string, b: string, reverse = false) {
return compare(a, b) * (reverse ? -1 : 1);
}
Comment on lines +166 to +168

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When sortNatural is false, the underlying compare function returns 1 for equal values (since a < b is false). When sorting by the fallback title, if two notes have the exact same title, compareSortValues will be called with equal values and return a non-zero result (either 1 or -1 depending on reverse). This violates the comparator contract (which requires returning 0 for equal elements) and can lead to unstable sorting or unpredictable behavior in some JavaScript engines. Adding an explicit equality check to return 0 prevents this issue.

Suggested change
function compareSortValues(a: string, b: string, reverse = false) {
return compare(a, b) * (reverse ? -1 : 1);
}
function compareSortValues(a: string, b: string, reverse = false) {
if (a === b) return 0;
return compare(a, b) * (reverse ? -1 : 1);
}


const topAEl = fetchValue(a, "top");
const topBEl = fetchValue(b, "top");

if (topAEl !== topBEl) {
if (topAEl === null) return reverse ? -1 : 1;
if (topBEl === null) return reverse ? 1 : -1;
if (topAEl === null) return 1;
if (topBEl === null) return -1;

// since "top" should not be reversible, we'll reverse it once more to nullify this effect
return compare(topAEl, topBEl) * (reverse ? -1 : 1);
return compare(topAEl, topBEl);
}

const bottomAEl = fetchValue(a, "bottom");
const bottomBEl = fetchValue(b, "bottom");

if (bottomAEl !== bottomBEl) {
if (bottomAEl === null) return reverse ? 1 : -1;
if (bottomBEl === null) return reverse ? -1 : 1;
if (bottomAEl === null) return -1;
if (bottomBEl === null) return 1;

// since "bottom" should not be reversible, we'll reverse it once more to nullify this effect
return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1);
return compare(bottomBEl, bottomAEl);
}

if (foldersFirst) {
Expand All @@ -156,23 +197,21 @@ function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse
}
}

const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string;
const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string;
for (const criterion of sortCriteria) {
const customAEl = fetchValue(a, criterion.key) ?? fetchValue(a, "title") as string;
const customBEl = fetchValue(b, criterion.key) ?? fetchValue(b, "title") as string;

if (customAEl !== customBEl) {
return compare(customAEl, customBEl);
if (customAEl !== customBEl) {
return compareSortValues(customAEl, customBEl, criterion.reverse);
}
}

const titleAEl = fetchValue(a, "title") as string;
const titleBEl = fetchValue(b, "title") as string;

return compare(titleAEl, titleBEl);
return compareSortValues(titleAEl, titleBEl, hasExplicitDirection ? false : reverse);
});

if (reverse) {
notes.reverse();
}

let position = 10;
let someBranchUpdated = false;

Expand Down