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:
1 parent
942dadc00e
commit
9f0d18ca54
26 files changed
+144
-22
No files matched your search
@@ -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, {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user