feat(memos): sort note lists from Z to A

Add a title Z-A option beside title A-Z on web, Android, iOS, the API, and the desktop sidecar. Pinned notes stay first, and same-title tie breaks stay as they were.
This commit is contained in:
tianma-if committed 2026-10-21 23:09:57 +08:00
1 parent 942dadc00e
commit 9f0d18ca54
26 files changed
+144 -22

No files matched your search

+45
View File
@@ -91,6 +91,51 @@ describe("memo list service", () => {
}
});
test("paginates title Z-A by reverse title and keeps the update-time tie break", async () => {
const titleExpression = "LOWER(COALESCE(NULLIF(m.title, ''), '无标题笔记'))";
const firstDatabase = createDatabase({
rows: [memoRow("memo_1", { title: "Zulu" }), memoRow("memo_2", { title: "Alpha" })],
totalCount: 2,
});
const first = await listMemos(firstDatabase, {
workspaceId: "ws_1",
sort: "title-desc",
limit: 1,
});
const orderQuery = firstDatabase.calls.find((call) => call.sql.includes("ORDER BY"));
expect(orderQuery.sql).toContain(`m.is_pinned DESC, ${titleExpression} DESC, m.updated_at DESC, m.id DESC`);
expect(orderQuery.sql).not.toContain(`${titleExpression} ASC`);
const secondDatabase = createDatabase({ rows: [] });
await listMemos(secondDatabase, {
workspaceId: "ws_1",
sort: "title-desc",
limit: 1,
cursor: first.nextCursor,
});
const pageQuery = secondDatabase.calls.find((call) => call.sql.includes("SELECT m.id"));
expect(pageQuery.sql).toContain(`${titleExpression} < ?`);
expect(pageQuery.sql).not.toContain(`${titleExpression} > ?`);
expect(pageQuery.sql).toContain("m.updated_at < ?");
expect(pageQuery.parameters).toContain("zulu");
expect(pageQuery.parameters).toContain("2026-08-01T01:00:00.000Z");
});
test("keeps trash ordered by deletion time when the title sort is reversed", async () => {
const database = createDatabase();
await listMemos(database, {
workspaceId: "ws_1",
includeTrash: true,
sort: "title-desc",
});
const pageQuery = database.calls.find((call) => call.sql.includes("ORDER BY"));
expect(pageQuery.sql).toContain("ORDER BY m.deleted_at DESC, m.id DESC");
expect(pageQuery.sql).not.toContain("NULLIF(m.title, '')");
});
test("filters by one exact tag in page and count queries", async () => {
const database = createDatabase();
await listMemos(database, {
+12 -7
View File
@@ -30,7 +30,7 @@ export type MemoSummaryRow = {
revision: number;
};
type MemoListSortMode = "updated-desc" | "created-desc" | "title-asc";
type MemoListSortMode = "updated-desc" | "created-desc" | "title-asc" | "title-desc";
type MemoListFilterMode = "all" | "tagged" | "untagged" | "pinned";
type MemoListCursor = {
@@ -94,7 +94,10 @@ const normalizeMemoTitle = (value: string | null | undefined) => {
};
const normalizeMemoListSort = (value: string | undefined): MemoListSortMode =>
value === "created-desc" || value === "title-asc" ? value : "updated-desc";
value === "created-desc" || value === "title-asc" || value === "title-desc" ? value : "updated-desc";
const isTitleSort = (sort: MemoListSortMode): sort is "title-asc" | "title-desc" =>
sort === "title-asc" || sort === "title-desc";
const normalizeMemoListFilter = (value: string | undefined): MemoListFilterMode =>
value === "tagged" || value === "untagged" || value === "pinned" ? value : "all";
@@ -110,7 +113,7 @@ const encodeMemoListCursor = (
else cursor.pinned = memo.is_pinned;
if (sort === "created-desc") cursor.createdAt = memo.created_at;
else if (sort === "title-asc") {
else if (isTitleSort(sort)) {
cursor.title = normalizeMemoTitle(memo.title).toLocaleLowerCase();
cursor.updatedAt = memo.updated_at;
} else cursor.updatedAt = memo.updated_at;
@@ -208,8 +211,9 @@ export const listMemos = async (
const getOrderBy = () => {
if (includeTrash) return "m.deleted_at DESC, m.id DESC";
if (sort === "created-desc") return "m.is_pinned DESC, m.created_at DESC, m.id DESC";
if (sort === "title-asc") {
return `m.is_pinned DESC, ${titleSortExpression} ASC, m.updated_at DESC, m.id DESC`;
if (isTitleSort(sort)) {
const direction = sort === "title-desc" ? "DESC" : "ASC";
return `m.is_pinned DESC, ${titleSortExpression} ${direction}, m.updated_at DESC, m.id DESC`;
}
return "m.is_pinned DESC, m.updated_at DESC, m.id DESC";
};
@@ -223,9 +227,10 @@ export const listMemos = async (
} else if (sort === "created-desc") {
cursorConditions.push("(m.is_pinned < ? OR (m.is_pinned = ? AND (m.created_at < ? OR (m.created_at = ? AND m.id < ?))))");
cursorBinds.push(cursor.pinned ?? 0, cursor.pinned ?? 0, cursor.createdAt ?? "", cursor.createdAt ?? "", cursor.id);
} else if (sort === "title-asc") {
} else if (isTitleSort(sort)) {
const titleOperator = sort === "title-desc" ? "<" : ">";
cursorConditions.push(
`(m.is_pinned < ? OR (m.is_pinned = ? AND (${titleSortExpression} > ? OR (${titleSortExpression} = ? AND (m.updated_at < ? OR (m.updated_at = ? AND m.id < ?))))))`,
`(m.is_pinned < ? OR (m.is_pinned = ? AND (${titleSortExpression} ${titleOperator} ? OR (${titleSortExpression} = ? AND (m.updated_at < ? OR (m.updated_at = ? AND m.id < ?))))))`,
);
cursorBinds.push(cursor.pinned ?? 0, cursor.pinned ?? 0, cursor.title ?? "", cursor.title ?? "", cursor.updatedAt ?? "", cursor.updatedAt ?? "", cursor.id);
} else {
@@ -133,6 +133,7 @@ final class LocalMirrorRepository: @unchecked Sendable {
switch params.sort {
case .createdDesc: orderBy = "is_pinned DESC, created_at DESC, id DESC"
case .titleAsc: orderBy = "is_pinned DESC, title COLLATE NOCASE ASC, updated_at DESC, id DESC"
case .titleDesc: orderBy = "is_pinned DESC, title COLLATE NOCASE DESC, updated_at DESC, id DESC"
case .updatedDesc: orderBy = "is_pinned DESC, updated_at DESC, id DESC"
}
}
@@ -422,6 +422,7 @@ enum MemoSortMode: String, CaseIterable, Sendable {
case updatedDesc = "updated-desc"
case createdDesc = "created-desc"
case titleAsc = "title-asc"
case titleDesc = "title-desc"
}
enum MemoFilterMode: String, CaseIterable, Sendable {
@@ -120,6 +120,15 @@ struct ListActionsSheet: View {
store.reload(env: env)
dismiss()
}
optionRow(
active: store.sort == .titleDesc,
icon: nil,
label: env.preferences.t("标题 Z-A", en: "Title Z-A", ja: "タイトル Z-A", pl: "Tytuł Z–A")
) {
store.sort = .titleDesc
store.reload(env: env)
dismiss()
}
divider
sheetItem(
@@ -158,6 +158,7 @@ final class MobileUIParityTests: XCTestCase {
XCTAssertEqual(MemoListTimestampField.resolve(for: .createdDesc).value(from: memo), memo.createdAt)
XCTAssertEqual(MemoListTimestampField.resolve(for: .updatedDesc).value(from: memo), memo.updatedAt)
XCTAssertEqual(MemoListTimestampField.resolve(for: .titleAsc).value(from: memo), memo.updatedAt)
XCTAssertEqual(MemoListTimestampField.resolve(for: .titleDesc).value(from: memo), memo.updatedAt)
}
func testMemoDetailDateIncludesHistoricalYear() {
+3 -1
View File
@@ -136,7 +136,9 @@ export const listLocalMemos = async (scope: string, params: LocalMemoListParams)
? "is_pinned DESC, created_at DESC, id DESC"
: params.sort === "title-asc"
? "is_pinned DESC, title COLLATE NOCASE ASC, updated_at DESC, id DESC"
: "is_pinned DESC, updated_at DESC, id DESC";
: params.sort === "title-desc"
? "is_pinned DESC, title COLLATE NOCASE DESC, updated_at DESC, id DESC"
: "is_pinned DESC, updated_at DESC, id DESC";
const tag = params.tag?.trim() ?? "";
const limit = params.limit ?? 50;
const offset = Math.max(0, params.offset ?? 0);
@@ -148,6 +148,7 @@ export const NotesActionsModal = ({
<SheetOptionRow active={memoSortMode === "updated-desc"} label="最近更新" onPress={() => onSortModeChange("updated-desc")} />
<SheetOptionRow active={memoSortMode === "created-desc"} label="创建时间" onPress={() => onSortModeChange("created-desc")} />
<SheetOptionRow active={memoSortMode === "title-asc"} label="标题 A-Z" onPress={() => onSortModeChange("title-asc")} />
<SheetOptionRow active={memoSortMode === "title-desc"} label="标题 Z-A" onPress={() => onSortModeChange("title-desc")} />
</ScrollView>
</Pressable>
</Pressable>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { Notebook } from "@edgeever/shared";
import type { MemoSummary, Notebook } from "@edgeever/shared";
import {
filterCollapsedNotebookOptions,
formatMemoDetailDate,
@@ -11,6 +11,7 @@ import {
isEnglishMobileLocale,
markdownToLocalText,
parseTags,
sortMemoSummaries,
} from "./workspace-utils";
const notebook = (id: string, parentId: string | null, sortOrder: number): Notebook => ({
@@ -91,4 +92,25 @@ describe("mobile workspace utilities", () => {
Intl.DateTimeFormat.prototype.resolvedOptions = original;
}
});
test("reverses title order and keeps equal titles in their original order", () => {
const memo = (id: string, title: string): MemoSummary => ({
id,
notebookId: "nb",
title,
excerpt: "",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
deletedAt: null,
});
const memos = [memo("b", "Alpha"), memo("a", "Alpha"), memo("z", "Zulu")];
expect(sortMemoSummaries(memos, "title-asc").map((item) => item.id)).toEqual(["b", "a", "z"]);
expect(sortMemoSummaries(memos, "title-desc").map((item) => item.id)).toEqual(["z", "b", "a"]);
});
});
+3 -2
View File
@@ -166,8 +166,9 @@ export const formatMemoDetailDate = (value: string, locale: string) => {
export const sortMemoSummaries = (memos: MemoSummary[], sortMode: unknown) =>
[...memos].sort((left, right) => {
if (sortMode === "title-asc") {
return (left.title || DEFAULT_MEMO_TITLE).localeCompare(right.title || DEFAULT_MEMO_TITLE);
if (sortMode === "title-asc" || sortMode === "title-desc") {
const titleCompare = (left.title || DEFAULT_MEMO_TITLE).localeCompare(right.title || DEFAULT_MEMO_TITLE);
return sortMode === "title-desc" ? -titleCompare : titleCompare;
}
if (sortMode === "created-desc") {
return right.createdAt.localeCompare(left.createdAt);
+3 -2
View File
@@ -169,6 +169,7 @@ export const getMemoSortOptions = (t: TFunction): Array<{ value: MemoSortMode; l
{ value: "updated-desc", label: t("options.memoSort.updatedDesc") },
{ value: "created-desc", label: t("options.memoSort.createdDesc") },
{ value: "title-asc", label: t("options.memoSort.titleAsc") },
{ value: "title-desc", label: t("options.memoSort.titleDesc") },
];
const NOTEBOOK_SORT_VALUES: NotebookSortMode[] = ["custom", "name-asc", "memo-count-desc", "updated-desc"];
@@ -707,14 +708,14 @@ export const sortMemos = (memos: MemoSummary[], sortMode: MemoSortMode, prioriti
return first.isPinned ? -1 : 1;
}
if (sortMode === "title-asc") {
if (sortMode === "title-asc" || sortMode === "title-desc") {
const titleCompare = getMemoTitle(first.title).localeCompare(getMemoTitle(second.title), "zh-CN", {
numeric: true,
sensitivity: "base",
});
if (titleCompare !== 0) {
return titleCompare;
return sortMode === "title-desc" ? -titleCompare : titleCompare;
}
return compareDateDesc(first.updatedAt, second.updatedAt);
+5 -1
View File
@@ -264,7 +264,11 @@ export const listLocalMemos = async (scope: string, params: LocalMemoListParams)
memos.sort((left, right) => {
if (params.trash) return (right.deletedAt ?? "").localeCompare(left.deletedAt ?? "") || right.id.localeCompare(left.id);
if (params.sort === "created-desc") return right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id);
if (params.sort === "title-asc") return (left.title || left.excerpt).localeCompare(right.title || right.excerpt, "zh-CN") || right.updatedAt.localeCompare(left.updatedAt);
if (params.sort === "title-asc" || params.sort === "title-desc") {
const titleCompare = (left.title || left.excerpt).localeCompare(right.title || right.excerpt, "zh-CN");
if (titleCompare !== 0) return params.sort === "title-desc" ? -titleCompare : titleCompare;
return right.updatedAt.localeCompare(left.updatedAt);
}
return Number(right.isPinned) - Number(left.isPinned) || right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id);
});
+5 -2
View File
@@ -59,11 +59,14 @@ const sortMemoSummariesForList = (memos: MemoSummary[], queryKey: readonly unkno
const sortMode = queryKey[5];
const sorted = [...memos];
if (sortMode === "title-asc") {
if (sortMode === "title-asc" || sortMode === "title-desc") {
const direction = sortMode === "title-desc" ? -1 : 1;
return sorted.sort((left, right) => {
const leftTitle = left.title?.trim() || left.excerpt || DEFAULT_MEMO_TITLE;
const rightTitle = right.title?.trim() || right.excerpt || DEFAULT_MEMO_TITLE;
return leftTitle.localeCompare(rightTitle, "zh-CN") || left.id.localeCompare(right.id);
const titleCompare = leftTitle.localeCompare(rightTitle, "zh-CN");
if (titleCompare !== 0) return titleCompare * direction;
return left.id.localeCompare(right.id);
});
}