1 parent
17490040f7
commit
a820b5e2b4
31 files changed
+575
-41
No files matched your search
@@ -69,6 +69,7 @@ jobs:
|
||||
DEST="$(
|
||||
xcodebuild -project EdgeEver.xcodeproj -scheme EdgeEver -showdestinations 2>/dev/null \
|
||||
| sed -n 's/.*name:\(iPhone[^,}]*\).*/\1/p' \
|
||||
| sed 's/[[:space:]]*$//' \
|
||||
| head -1
|
||||
)"
|
||||
if [[ -z "$DEST" ]]; then
|
||||
|
||||
@@ -43,6 +43,11 @@ final class PreferencesStore {
|
||||
didSet { defaults.set(useCompression, forKey: Keys.compression) }
|
||||
}
|
||||
|
||||
/// Parent notebooks keep listing their sub-notebooks' notes unless turned off.
|
||||
var showDescendantNotes: Bool {
|
||||
didSet { defaults.set(showDescendantNotes, forKey: Keys.showDescendantNotes) }
|
||||
}
|
||||
|
||||
/// system | light | dark
|
||||
var theme: String {
|
||||
didSet { defaults.set(theme, forKey: Keys.theme) }
|
||||
@@ -60,6 +65,7 @@ final class PreferencesStore {
|
||||
self.defaults = defaults
|
||||
self.localeCode = defaults.string(forKey: Keys.locale) ?? "system"
|
||||
self.useCompression = defaults.object(forKey: Keys.compression) as? Bool ?? true
|
||||
self.showDescendantNotes = defaults.object(forKey: Keys.showDescendantNotes) as? Bool ?? true
|
||||
self.theme = defaults.string(forKey: Keys.theme) ?? "system"
|
||||
let densityRaw = defaults.string(forKey: Keys.density) ?? ListDensity.preview.rawValue
|
||||
// Migrate legacy "comfortable" → preview
|
||||
@@ -147,6 +153,7 @@ final class PreferencesStore {
|
||||
private enum Keys {
|
||||
static let locale = "edgeever.ios.locale"
|
||||
static let compression = "edgeever.ios.imageCompression"
|
||||
static let showDescendantNotes = "edgeever.ios.showDescendantNotes"
|
||||
static let theme = "edgeever.ios.theme"
|
||||
static let density = "edgeever.ios.listDensity"
|
||||
static let aiAssistantLastAction = "edgeever.aiAssistant.lastAction"
|
||||
|
||||
@@ -121,6 +121,20 @@ enum NotebookHierarchy {
|
||||
return descendantIds
|
||||
}
|
||||
|
||||
/// Port of `getNotebookScopeIds`: the notebooks a notebook view lists.
|
||||
static func scopeIds(notebooks: [Notebook], targetNotebookId: String, includeDescendants: Bool) -> [String] {
|
||||
includeDescendants ? descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId) : [targetNotebookId]
|
||||
}
|
||||
|
||||
/// Port of `getNotebookDescendantMemoCount`: notes in sub-notebooks, not the notebook itself.
|
||||
/// Expects direct per-notebook counts, as `LocalMirrorRepository.listNotebooks` returns them.
|
||||
static func descendantMemoCount(notebooks: [Notebook], targetNotebookId: String) -> Int {
|
||||
let memoCountById = Dictionary(notebooks.map { ($0.id, $0.memoCount) }, uniquingKeysWith: { first, _ in first })
|
||||
return descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId)
|
||||
.filter { $0 != targetNotebookId }
|
||||
.reduce(0) { $0 + (memoCountById[$1] ?? 0) }
|
||||
}
|
||||
|
||||
static func treeItems(from notebooks: [Notebook]) -> [NotebookTreeItem] {
|
||||
let byParent = Dictionary(grouping: notebooks) { $0.parentId ?? "" }
|
||||
var items: [NotebookTreeItem] = []
|
||||
|
||||
@@ -394,7 +394,21 @@ struct NotesListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// With sub-notebooks hidden, an empty parent must not look like its notes are gone.
|
||||
private var hiddenDescendantMemoCount: Int {
|
||||
guard !env.preferences.showDescendantNotes,
|
||||
store.searchText.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
store.filter == .all,
|
||||
store.selectedTag == nil,
|
||||
let notebookId = store.selectedNotebookId
|
||||
else { return 0 }
|
||||
return NotebookHierarchy.descendantMemoCount(notebooks: store.notebooks, targetNotebookId: notebookId)
|
||||
}
|
||||
|
||||
private var emptyTitle: String {
|
||||
if hiddenDescendantMemoCount > 0 {
|
||||
return env.preferences.t("本级暂无笔记", en: "No notes directly in this notebook", ja: "このノートブック直下にノートはありません")
|
||||
}
|
||||
if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return env.preferences.t("没有找到匹配笔记", en: "No matching notes")
|
||||
}
|
||||
@@ -405,6 +419,14 @@ struct NotesListView: View {
|
||||
}
|
||||
|
||||
private var emptyDescription: String {
|
||||
let hiddenCount = hiddenDescendantMemoCount
|
||||
if hiddenCount > 0 {
|
||||
return env.preferences.t(
|
||||
"子笔记本中还有 \(hiddenCount) 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。",
|
||||
en: "Its sub-notebooks still contain \(hiddenCount) notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings.",
|
||||
ja: "サブノートブックにはまだ \(hiddenCount) 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"
|
||||
)
|
||||
}
|
||||
if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return env.preferences.t("换个关键词再试", en: "Try another keyword")
|
||||
}
|
||||
|
||||
@@ -338,6 +338,26 @@ struct SettingsView: View {
|
||||
.tint(AppTheme.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
preferenceBlock(
|
||||
title: env.preferences.t("显示子笔记本中的笔记", en: "Show notes from sub-notebooks", ja: "サブノートブックのノートを表示"),
|
||||
description: env.preferences.t(
|
||||
"开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。",
|
||||
en: "When on, opening a parent notebook also lists notes from all of its sub-notebooks. When off, only notes stored directly in that notebook are listed.",
|
||||
ja: "オンにすると、親ノートブックを開いたときにすべてのサブノートブックのノートも一覧表示します。オフにすると、そのノートブックに直接保存されたノートのみを表示します。"
|
||||
),
|
||||
showTopBorder: true
|
||||
) {
|
||||
Toggle("", isOn: Bindable(env.preferences).showDescendantNotes)
|
||||
.labelsHidden()
|
||||
.tint(AppTheme.accent)
|
||||
.accessibilityLabel(env.preferences.t(
|
||||
"是否在父笔记本中显示子笔记本中的笔记",
|
||||
en: "Show notes from sub-notebooks in parent notebooks",
|
||||
ja: "親ノートブックにサブノートブックのノートを表示する"
|
||||
))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// List density lives in list-options sheet (Android NotesActionsModal), not here.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,11 @@ final class WorkspaceStore {
|
||||
let offset = resetOffset ? 0 : (nextOffset ?? memos.count)
|
||||
let notebookIds: [String]? = {
|
||||
guard selectedTag == nil, let selectedNotebookId else { return nil }
|
||||
return NotebookHierarchy.descendantIds(notebooks: notebooks, targetNotebookId: selectedNotebookId)
|
||||
return NotebookHierarchy.scopeIds(
|
||||
notebooks: notebooks,
|
||||
targetNotebookId: selectedNotebookId,
|
||||
includeDescendants: env.preferences.showDescendantNotes
|
||||
)
|
||||
}()
|
||||
let result = try env.mirror.listMemos(
|
||||
scope: scope,
|
||||
|
||||
@@ -210,6 +210,9 @@ struct WorkspaceView: View {
|
||||
.onChange(of: env.bootstrapProgress?.totalCount) { _, _ in
|
||||
store.reload(env: env)
|
||||
}
|
||||
.onChange(of: env.preferences.showDescendantNotes) { _, _ in
|
||||
store.reload(env: env)
|
||||
}
|
||||
.refreshable {
|
||||
await env.runSyncCycle(force: true)
|
||||
store.reload(env: env)
|
||||
|
||||
@@ -5,7 +5,9 @@ import XCTest
|
||||
/// Exercises shipped parity helpers (same semantics as Android `@edgeever/shared/mobile-ui` + notebooks).
|
||||
final class MobileUIParityTests: XCTestCase {
|
||||
func testUnmatchedSystemLanguageUsesEnglishUI() {
|
||||
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja"))
|
||||
// Japanese is a first-class UI language, so a Japanese system no longer falls back to English.
|
||||
XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja"))
|
||||
XCTAssertEqual(AppUILocale.language(preferenceCode: "system", systemLanguageCode: "ja"), .japanese)
|
||||
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "fr"))
|
||||
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "en"))
|
||||
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: nil))
|
||||
@@ -117,6 +119,33 @@ final class MobileUIParityTests: XCTestCase {
|
||||
XCTAssertEqual(Set(ids), Set(["root", "child", "grand"]))
|
||||
}
|
||||
|
||||
func testNotebookScopeFollowsDescendantPreference() {
|
||||
let notebooks = [
|
||||
makeNotebook(id: "root", parent: nil, name: "Root", order: 0),
|
||||
makeNotebook(id: "child", parent: "root", name: "Child", order: 0),
|
||||
makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0),
|
||||
]
|
||||
XCTAssertEqual(
|
||||
Set(NotebookHierarchy.scopeIds(notebooks: notebooks, targetNotebookId: "root", includeDescendants: true)),
|
||||
Set(["root", "child", "grand"])
|
||||
)
|
||||
XCTAssertEqual(
|
||||
NotebookHierarchy.scopeIds(notebooks: notebooks, targetNotebookId: "root", includeDescendants: false),
|
||||
["root"]
|
||||
)
|
||||
}
|
||||
|
||||
func testNotebookDescendantMemoCountExcludesTheNotebookItself() {
|
||||
let notebooks = [
|
||||
makeNotebook(id: "root", parent: nil, name: "Root", order: 0, memoCount: 2),
|
||||
makeNotebook(id: "child", parent: "root", name: "Child", order: 0, memoCount: 3),
|
||||
makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0, memoCount: 1),
|
||||
makeNotebook(id: "other", parent: nil, name: "Other", order: 1, memoCount: 9),
|
||||
]
|
||||
XCTAssertEqual(NotebookHierarchy.descendantMemoCount(notebooks: notebooks, targetNotebookId: "root"), 4)
|
||||
XCTAssertEqual(NotebookHierarchy.descendantMemoCount(notebooks: notebooks, targetNotebookId: "grand"), 0)
|
||||
}
|
||||
|
||||
func testFilterCollapsedHidesDescendants() {
|
||||
let notebooks = [
|
||||
makeNotebook(id: "root", parent: nil, name: "Root", order: 0),
|
||||
@@ -341,7 +370,9 @@ final class MobileUIParityTests: XCTestCase {
|
||||
nav.onFinish = { exp.fulfill() }
|
||||
webView.loadHTMLString(html, baseURL: URL(string: "https://edgeever.local/"))
|
||||
}
|
||||
await fulfillment(of: [exp], timeout: 5)
|
||||
// The first WKWebView in the test process cold-starts WebKit's helper processes,
|
||||
// which takes well over 5s on CI simulators.
|
||||
await fulfillment(of: [exp], timeout: 30)
|
||||
// Poll naturalWidth via JS (give the scheme handler a moment if needed).
|
||||
var width = 0
|
||||
for _ in 0 ..< 20 {
|
||||
@@ -389,7 +420,7 @@ final class MobileUIParityTests: XCTestCase {
|
||||
XCTAssertEqual(tagged.memos.map(\.id), ["p2"])
|
||||
}
|
||||
|
||||
private func makeNotebook(id: String, parent: String?, name: String, order: Int) -> Notebook {
|
||||
private func makeNotebook(id: String, parent: String?, name: String, order: Int, memoCount: Int = 0) -> Notebook {
|
||||
Notebook(
|
||||
id: id,
|
||||
parentId: parent,
|
||||
@@ -398,7 +429,7 @@ final class MobileUIParityTests: XCTestCase {
|
||||
icon: nil,
|
||||
color: nil,
|
||||
sortOrder: order,
|
||||
memoCount: 0,
|
||||
memoCount: memoCount,
|
||||
lastMemoUpdatedAt: nil,
|
||||
createdAt: EdgeEverDate.nowString(),
|
||||
updatedAt: EdgeEverDate.nowString()
|
||||
|
||||
@@ -18,6 +18,23 @@ describe("mobile locale translation", () => {
|
||||
expect(translateMobileText("筛选:Pinned · 3 条", "en-US")).toBe("Filter: Pinned · 3 notes");
|
||||
});
|
||||
|
||||
test("uses shared translations for the descendant preference", () => {
|
||||
expect(translateMobileText("显示子笔记本中的笔记", "en-US")).toBe("Show notes from sub-notebooks");
|
||||
expect(translateMobileText("显示子笔记本中的笔记", "ja")).toBe("サブノートブックのノートを表示");
|
||||
expect(translateMobileText("是否在父笔记本中显示子笔记本中的笔记", "en-US")).toBe("Show notes from sub-notebooks in parent notebooks");
|
||||
});
|
||||
|
||||
test("translates the hidden sub-notebook notes hint", () => {
|
||||
const hint = "子笔记本中还有 5 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。";
|
||||
expect(translateMobileText(hint, "en-US"))
|
||||
.toBe("Its sub-notebooks still contain 5 notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings.");
|
||||
expect(translateMobileText(hint, "ja"))
|
||||
.toBe("サブノートブックにはまだ 5 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。");
|
||||
expect(translateMobileText("本级暂无笔记", "en-US")).toBe("No notes directly in this notebook");
|
||||
expect(translateMobileText("无法保存“显示子笔记本中的笔记”设置,请稍后重试", "en-US"))
|
||||
.toBe("Could not save the \"Show notes from sub-notebooks\" setting. Please try again.");
|
||||
});
|
||||
|
||||
test("prefers specific shared templates over broader mobile-only templates", () => {
|
||||
expect(translateMobileText("永久删除 3 条笔记", "en-US")).toBe("Delete 3 notes permanently");
|
||||
expect(translateMobileText("当前:Inbox,3 条笔记", "en-US")).toBe("Current: Inbox, 3 notes");
|
||||
|
||||
@@ -19,6 +19,9 @@ type MobileLocaleContextValue = {
|
||||
type TranslationPair = { source: string; target: string; pattern?: RegExp; placeholders?: string[] };
|
||||
|
||||
const mobileOnlyTranslations = new Map<string, string>([
|
||||
["本级暂无笔记", "No notes directly in this notebook"],
|
||||
["无法保存“显示子笔记本中的笔记”设置,请稍后重试", "Could not save the \"Show notes from sub-notebooks\" setting. Please try again."],
|
||||
["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", "Its sub-notebooks still contain {{count}} notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings."],
|
||||
["返回", "Back"],
|
||||
["关闭对话框", "Close dialog"],
|
||||
["切换到深色模式", "Switch to dark mode"],
|
||||
@@ -205,6 +208,9 @@ const mobileOnlyTranslations = new Map<string, string>([
|
||||
]);
|
||||
|
||||
const mobileOnlyJapanese = new Map<string, string>([
|
||||
["本级暂无笔记", "このノートブック直下にノートはありません"],
|
||||
["无法保存“显示子笔记本中的笔记”设置,请稍后重试", "「サブノートブックのノートを表示」の設定を保存できませんでした。しばらくしてから再試行してください。"],
|
||||
["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", "サブノートブックにはまだ {{count}} 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"],
|
||||
["返回", "戻る"],
|
||||
["关闭对话框", "ダイアログを閉じる"],
|
||||
["切换到深色模式", "ダークモードに切り替え"],
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
// Keep the native module mock out of the other mobile tests' module cache.
|
||||
test("descendant preference defaults safely when native storage fails", () => {
|
||||
const result = Bun.spawnSync([process.execPath, "--eval", `
|
||||
import { mock } from "bun:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
let stored = null;
|
||||
let rejects = false;
|
||||
mock.module("@react-native-async-storage/async-storage", () => ({ default: {
|
||||
getItem: async () => { if (rejects) throw new Error("storage unavailable"); return stored; },
|
||||
setItem: async (_key, value) => { stored = value; },
|
||||
} }));
|
||||
const { readMobileShowDescendantNotes, writeMobileShowDescendantNotes } =
|
||||
await import(${JSON.stringify(new URL("./preferences.ts", import.meta.url).href)});
|
||||
assert.equal(await readMobileShowDescendantNotes(), true);
|
||||
await writeMobileShowDescendantNotes(false);
|
||||
assert.equal(await readMobileShowDescendantNotes(), false);
|
||||
await writeMobileShowDescendantNotes(true);
|
||||
assert.equal(await readMobileShowDescendantNotes(), true);
|
||||
rejects = true;
|
||||
assert.equal(await readMobileShowDescendantNotes(), true);
|
||||
`], { stdout: "pipe", stderr: "pipe" });
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ exitCode: 0, stderr: "" });
|
||||
});
|
||||
|
||||
test("descendant preference keeps the stored value when saving fails", () => {
|
||||
const result = Bun.spawnSync([process.execPath, "--eval", `
|
||||
import { mock } from "bun:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
let stored = "false";
|
||||
let writesFail = false;
|
||||
mock.module("@react-native-async-storage/async-storage", () => ({ default: {
|
||||
getItem: async () => stored,
|
||||
setItem: async (_key, value) => { if (writesFail) throw new Error("disk full"); stored = value; },
|
||||
} }));
|
||||
const { readMobileShowDescendantNotes, saveMobileShowDescendantNotes } =
|
||||
await import(${JSON.stringify(new URL("./preferences.ts", import.meta.url).href)});
|
||||
assert.deepEqual(await saveMobileShowDescendantNotes(true), { value: true, saved: true });
|
||||
writesFail = true;
|
||||
// The UI must keep showing what the next launch will restore, not the unsaved choice.
|
||||
assert.deepEqual(await saveMobileShowDescendantNotes(false), { value: true, saved: false });
|
||||
assert.equal(await readMobileShowDescendantNotes(), true);
|
||||
`], { stdout: "pipe", stderr: "pipe" });
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ exitCode: 0, stderr: "" });
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const MEMO_LIST_DENSITY_KEY = "edgeever.mobile.memoListDensity";
|
||||
const IMAGE_COMPRESSION_KEY = "edgeever.mobile.imageCompressionEnabled";
|
||||
const SHOW_DESCENDANT_NOTES_KEY = "edgeever.mobile.showDescendantNotes";
|
||||
const LOCALE_PREFERENCE_KEY = "edgeever.mobile.localePreference";
|
||||
const THEME_PREFERENCE_KEY = "edgeever.mobile.themePreference";
|
||||
|
||||
@@ -30,6 +31,30 @@ export const readMobileImageCompressionEnabled = async () => {
|
||||
|
||||
export const writeMobileImageCompressionEnabled = (enabled: boolean) => AsyncStorage.setItem(IMAGE_COMPRESSION_KEY, enabled ? "true" : "false");
|
||||
|
||||
// Parent notebooks keep listing their sub-notebooks' notes unless turned off.
|
||||
export const readMobileShowDescendantNotes = async () => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(SHOW_DESCENDANT_NOTES_KEY);
|
||||
return value !== "false";
|
||||
} catch {
|
||||
// A display preference must not leave all note queries disabled.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeMobileShowDescendantNotes = (enabled: boolean) => AsyncStorage.setItem(SHOW_DESCENDANT_NOTES_KEY, enabled ? "true" : "false");
|
||||
|
||||
// Resolves to the value that is actually stored, so the list never shows a scope
|
||||
// that would silently revert on the next launch.
|
||||
export const saveMobileShowDescendantNotes = async (enabled: boolean) => {
|
||||
try {
|
||||
await writeMobileShowDescendantNotes(enabled);
|
||||
return { value: enabled, saved: true };
|
||||
} catch {
|
||||
return { value: await readMobileShowDescendantNotes(), saved: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const readMobileLocalePreference = async (): Promise<MobileLocalePreference> => {
|
||||
const value = await AsyncStorage.getItem(LOCALE_PREFERENCE_KEY);
|
||||
return isMobileLocalePreference(value) ? value : "system";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useRef, type ReactNode } from "react";
|
||||
import type { MemoFilterMode, MemoSortMode } from "@edgeever/client";
|
||||
import { DEFAULT_MEMO_TITLE, getMemoListTimestamp, type MemoSummary, type Notebook } from "@edgeever/shared";
|
||||
import { DEFAULT_MEMO_TITLE, getMemoListTimestamp, getNotebookDescendantMemoCount, type MemoSummary, type Notebook } from "@edgeever/shared";
|
||||
import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui";
|
||||
import { FlatList, Platform, RefreshControl, View } from "react-native";
|
||||
import Animated, { FadeInDown, FadeOutUp, LinearTransition, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
||||
@@ -46,6 +46,7 @@ export const NotesView = ({
|
||||
onSetMemoView,
|
||||
searchText,
|
||||
selectedTag,
|
||||
showDescendantNotes,
|
||||
totalMemoCount,
|
||||
selectedMemoIds,
|
||||
selectionMode,
|
||||
@@ -80,6 +81,7 @@ export const NotesView = ({
|
||||
onSetMemoView: (memoView: MemoView) => void;
|
||||
searchText: string;
|
||||
selectedTag: string | null;
|
||||
showDescendantNotes: boolean;
|
||||
totalMemoCount: number;
|
||||
selectionMode: boolean;
|
||||
selectedMemoIds: Set<string>;
|
||||
@@ -88,6 +90,10 @@ export const NotesView = ({
|
||||
const { preference: localePreference, translate } = useMobileLocale();
|
||||
const searchActive = searchText.trim().length > 0;
|
||||
const filterActive = memoFilterMode !== "all" || Boolean(selectedTag);
|
||||
// With sub-notebooks hidden, an empty parent must not look like its notes are gone.
|
||||
const hiddenDescendantMemoCount = !showDescendantNotes && memoView === "notebook" && activeNotebook && !searchActive && !filterActive
|
||||
? getNotebookDescendantMemoCount(notebooks, activeNotebook.id)
|
||||
: 0;
|
||||
const searchStatusLabel = translate("正在搜索");
|
||||
const searchResultLabel = translate(`${totalMemoCount} 条结果`);
|
||||
const exitSearchLabel = translate("退出搜索");
|
||||
@@ -199,8 +205,8 @@ export const NotesView = ({
|
||||
: []),
|
||||
]
|
||||
: undefined}
|
||||
emptyDescription={searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"}
|
||||
emptyTitle={searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"}
|
||||
emptyDescription={hiddenDescendantMemoCount > 0 ? `子笔记本中还有 ${hiddenDescendantMemoCount} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。` : searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"}
|
||||
emptyTitle={hiddenDescendantMemoCount > 0 ? "本级暂无笔记" : searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"}
|
||||
error={error}
|
||||
initialSyncProgress={initialSyncProgress}
|
||||
isError={isError}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { ListMemosResponse, MemoFilterMode, MemoSortMode } from "@edgeever/client";
|
||||
import {
|
||||
@@ -19,13 +19,15 @@ import {
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Alert, Pressable, Text } from "../components/LocalizedText";
|
||||
import { ApiRequestError } from "@edgeever/client";
|
||||
import { DEFAULT_MEMO_TITLE, getNotebookDescendantIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail } from "@edgeever/shared";
|
||||
import { DEFAULT_MEMO_TITLE, getNotebookScopeIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail } from "@edgeever/shared";
|
||||
import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui";
|
||||
import { clearMobileMemoDraft, readMobileMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts";
|
||||
import {
|
||||
readMobileImageCompressionEnabled,
|
||||
readMobileShowDescendantNotes,
|
||||
readMobileMemoListDensity,
|
||||
writeMobileImageCompressionEnabled,
|
||||
saveMobileShowDescendantNotes,
|
||||
writeMobileMemoListDensity,
|
||||
type MobileLocalePreference,
|
||||
type MobileMemoListDensity,
|
||||
@@ -93,6 +95,7 @@ import {
|
||||
applyOptimisticMemoToCache,
|
||||
createOptimisticMemo,
|
||||
findCachedMemoDetail,
|
||||
memoQueriesShareScope,
|
||||
type MobileMemoUpdatePayload,
|
||||
} from "./workspace-memo-cache";
|
||||
import { refreshWorkspaceThemeStyles, styles } from "./workspace-styles";
|
||||
@@ -157,6 +160,9 @@ export const WorkspaceScreen = ({
|
||||
const [memoSortMode, setMemoSortMode] = useState<MemoSortMode>("updated-desc");
|
||||
const [memoListDensity, setMemoListDensity] = useState<MobileMemoListDensity>("preview");
|
||||
const [imageCompressionEnabled, setImageCompressionEnabled] = useState(true);
|
||||
// Null until the stored value loads, so lists never flash the wrong notebook scope.
|
||||
const [showDescendantNotes, setShowDescendantNotes] = useState<boolean | null>(null);
|
||||
const [showDescendantNotesSaveFailed, setShowDescendantNotesSaveFailed] = useState(false);
|
||||
const [selectedMemoId, setSelectedMemoId] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -250,13 +256,14 @@ export const WorkspaceScreen = ({
|
||||
}, [activeNotebookId, localePreference, notebooks, selectedTag]);
|
||||
|
||||
const activeNotebook = notebooks.find((notebook) => notebook.id === activeNotebookId) ?? null;
|
||||
const activeNotebookDescendantIds = useMemo(
|
||||
() => (activeNotebookId === ALL_NOTES_ID || selectedTag ? [] : getNotebookDescendantIds(notebooks, activeNotebookId)),
|
||||
[activeNotebookId, notebooks, selectedTag]
|
||||
const activeNotebookScopeIds = useMemo(
|
||||
() => (activeNotebookId === ALL_NOTES_ID || selectedTag ? [] : getNotebookScopeIds(notebooks, activeNotebookId, showDescendantNotes ?? true)),
|
||||
[activeNotebookId, notebooks, selectedTag, showDescendantNotes]
|
||||
);
|
||||
|
||||
const memosQueryKey = ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookScopeIds, selectedTag, "paged-v3"] as const;
|
||||
const memosQuery = useInfiniteQuery({
|
||||
queryKey: ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v3"],
|
||||
queryKey: memosQueryKey,
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!client) {
|
||||
@@ -264,7 +271,7 @@ export const WorkspaceScreen = ({
|
||||
}
|
||||
|
||||
return listLocalMemos(dataScope, {
|
||||
notebookIds: activeNotebookDescendantIds,
|
||||
notebookIds: activeNotebookScopeIds,
|
||||
filter: memoFilterMode,
|
||||
tag: memoView === "notebook" ? selectedTag ?? undefined : undefined,
|
||||
limit: 50,
|
||||
@@ -274,12 +281,14 @@ export const WorkspaceScreen = ({
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ? Number(lastPage.nextCursor) : undefined,
|
||||
enabled: Boolean(client),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: Boolean(client) && showDescendantNotes !== null,
|
||||
placeholderData: (previousData, previousQuery) =>
|
||||
previousQuery && memoQueriesShareScope("memos", previousQuery.queryKey, memosQueryKey) ? previousData : undefined,
|
||||
});
|
||||
|
||||
const searchQueryKey = ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookScopeIds, selectedTag, "paged-v5"] as const;
|
||||
const searchQuery = useInfiniteQuery({
|
||||
queryKey: ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v5"],
|
||||
queryKey: searchQueryKey,
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!client) {
|
||||
@@ -288,7 +297,7 @@ export const WorkspaceScreen = ({
|
||||
|
||||
return listLocalMemos(dataScope, {
|
||||
q: debouncedSearchText,
|
||||
notebookIds: activeNotebookDescendantIds,
|
||||
notebookIds: activeNotebookScopeIds,
|
||||
filter: memoFilterMode,
|
||||
tag: memoView === "notebook" ? selectedTag ?? undefined : undefined,
|
||||
limit: 50,
|
||||
@@ -298,8 +307,9 @@ export const WorkspaceScreen = ({
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ? Number(lastPage.nextCursor) : undefined,
|
||||
enabled: Boolean(client && debouncedSearchText.length > 0),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: Boolean(client && debouncedSearchText.length > 0) && showDescendantNotes !== null,
|
||||
placeholderData: (previousData, previousQuery) =>
|
||||
previousQuery && memoQueriesShareScope("search", previousQuery.queryKey, searchQueryKey) ? previousData : undefined,
|
||||
});
|
||||
|
||||
const memoDetailQuery = useQuery({
|
||||
@@ -725,6 +735,11 @@ export const WorkspaceScreen = ({
|
||||
setImageCompressionEnabled(enabled);
|
||||
}
|
||||
});
|
||||
readMobileShowDescendantNotes().then((enabled) => {
|
||||
if (mounted) {
|
||||
setShowDescendantNotes(enabled);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
@@ -759,6 +774,12 @@ export const WorkspaceScreen = ({
|
||||
void writeMobileImageCompressionEnabled(enabled);
|
||||
};
|
||||
|
||||
const handleShowDescendantNotesChange = async (enabled: boolean) => {
|
||||
const result = await saveMobileShowDescendantNotes(enabled);
|
||||
setShowDescendantNotes(result.value);
|
||||
setShowDescendantNotesSaveFailed(!result.saved);
|
||||
};
|
||||
|
||||
const optimisticallyRemoveMemoIds = async (memoIds: string[]): Promise<MobileMemoListCacheSnapshot> => {
|
||||
const queryKeys: QueryKey[] = [
|
||||
["mobile", "memos"],
|
||||
@@ -1210,6 +1231,7 @@ export const WorkspaceScreen = ({
|
||||
{activeView === "notes" ? (
|
||||
<NotesView
|
||||
activeNotebook={activeNotebook}
|
||||
showDescendantNotes={showDescendantNotes ?? true}
|
||||
initialSyncProgress={initialMirrorSyncProgress}
|
||||
isLoading={notebooksQuery.isLoading || (searchActive ? searchQuery.isLoading : memosQuery.isLoading) || (isInitialMirrorStatusPending && visibleMemos.length === 0)}
|
||||
isLoadingMore={searchActive ? searchQuery.isFetchingNextPage : memosQuery.isFetchingNextPage}
|
||||
@@ -1266,6 +1288,9 @@ export const WorkspaceScreen = ({
|
||||
onLocalePreferenceChange={handleLocalePreferenceChange}
|
||||
imageCompressionEnabled={imageCompressionEnabled}
|
||||
onImageCompressionChange={handleImageCompressionChange}
|
||||
showDescendantNotes={showDescendantNotes ?? true}
|
||||
showDescendantNotesSaveFailed={showDescendantNotesSaveFailed}
|
||||
onShowDescendantNotesChange={(enabled) => void handleShowDescendantNotesChange(enabled)}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -89,7 +89,10 @@ export const SettingsView = ({
|
||||
onClose,
|
||||
onImageCompressionChange,
|
||||
onLocalePreferenceChange,
|
||||
onShowDescendantNotesChange,
|
||||
onSignOut,
|
||||
showDescendantNotes,
|
||||
showDescendantNotesSaveFailed,
|
||||
}: {
|
||||
currentUser: AuthUser | null;
|
||||
imageCompressionEnabled: boolean;
|
||||
@@ -97,7 +100,10 @@ export const SettingsView = ({
|
||||
onClose: () => void;
|
||||
onImageCompressionChange: (enabled: boolean) => void;
|
||||
onLocalePreferenceChange: (locale: MobileLocaleMode) => void;
|
||||
onShowDescendantNotesChange: (enabled: boolean) => void;
|
||||
onSignOut: () => void;
|
||||
showDescendantNotes: boolean;
|
||||
showDescendantNotesSaveFailed: boolean;
|
||||
}) => {
|
||||
const { resolvedTheme, toggleTheme } = useMobileTheme();
|
||||
const { translate } = useMobileLocale();
|
||||
@@ -220,6 +226,18 @@ export const SettingsView = ({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.settingsContentRow}>
|
||||
<View style={styles.preferenceStack}>
|
||||
<View style={styles.preferenceText}>
|
||||
<Text style={styles.settingsRowTitle}>显示子笔记本中的笔记</Text>
|
||||
<Text style={styles.settingsRowDescription}>开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。</Text>
|
||||
{showDescendantNotesSaveFailed ? <Text accessibilityRole="alert" style={styles.errorText}>无法保存“显示子笔记本中的笔记”设置,请稍后重试</Text> : null}
|
||||
</View>
|
||||
<View style={styles.settingsSwitchStart}>
|
||||
<Switch accessibilityLabel={translate("是否在父笔记本中显示子笔记本中的笔记")} onValueChange={onShowDescendantNotesChange} value={showDescendantNotes} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { MemoDetail } from "@edgeever/shared";
|
||||
import { createOptimisticMemo, memoMatchesListQuery } from "./workspace-memo-cache";
|
||||
import { createOptimisticMemo, memoMatchesListQuery, memoQueriesShareScope } from "./workspace-memo-cache";
|
||||
|
||||
const memo: MemoDetail = {
|
||||
id: "memo_1",
|
||||
@@ -59,3 +59,24 @@ describe("mobile workspace memo cache", () => {
|
||||
expect(memoMatchesListQuery(taggedMemo, query("homework"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoQueriesShareScope", () => {
|
||||
const listKey = (notebookId: string, scopeIds: string[], sort = "updated-desc", filter = "all") =>
|
||||
["mobile", "memos", "notebook", notebookId, filter, sort, scopeIds, null, "paged-v3"];
|
||||
const searchKey = (text: string, scopeIds: string[]) =>
|
||||
["mobile", "search", "notebook", text, "parent", "all", "updated-desc", scopeIds, null, "paged-v5"];
|
||||
|
||||
test("keeps previous results when only sort, filter or search text changes", () => {
|
||||
expect(memoQueriesShareScope("memos", listKey("parent", ["parent", "child"]), listKey("parent", ["parent", "child"], "title-asc", "pinned"))).toBe(true);
|
||||
expect(memoQueriesShareScope("search", searchKey("scope", ["parent"]), searchKey("scopecheck", ["parent"]))).toBe(true);
|
||||
});
|
||||
|
||||
test("drops previous results when the sub-notebook setting changes the scope", () => {
|
||||
expect(memoQueriesShareScope("memos", listKey("parent", ["parent", "child"]), listKey("parent", ["parent"]))).toBe(false);
|
||||
expect(memoQueriesShareScope("search", searchKey("scopecheck", ["parent", "child"]), searchKey("scopecheck", ["parent"]))).toBe(false);
|
||||
});
|
||||
|
||||
test("drops previous results when switching notebooks", () => {
|
||||
expect(memoQueriesShareScope("memos", listKey("parent", ["parent"]), listKey("other", ["other"]))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,16 @@ export const createOptimisticMemo = (
|
||||
};
|
||||
};
|
||||
|
||||
// Where WorkspaceScreen puts the notebook scope (view, notebook, scope ids, tag) in
|
||||
// the list and search query keys.
|
||||
const MEMO_QUERY_SCOPE_INDEXES = { memos: [2, 3, 6, 7], search: [2, 4, 7, 8] } as const;
|
||||
|
||||
// Previous results may stand in while a query loads only when they cover the same
|
||||
// notebook scope. Otherwise another notebook's notes, or the other sub-notebook
|
||||
// setting's notes, would be shown with their count as the current scope.
|
||||
export const memoQueriesShareScope = (kind: keyof typeof MEMO_QUERY_SCOPE_INDEXES, previousKey: readonly unknown[], nextKey: readonly unknown[]) =>
|
||||
MEMO_QUERY_SCOPE_INDEXES[kind].every((index) => JSON.stringify(previousKey[index]) === JSON.stringify(nextKey[index]));
|
||||
|
||||
export const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unknown[]) => {
|
||||
const view = queryKey[2];
|
||||
const notebookId = queryKey[3];
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotebookTreeItem } from "./NotebookTreeItem";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useShowDescendantNotesPreference } from "@/lib/descendant-notes-preference";
|
||||
import { BETA_BADGE_CLASSNAME } from "@/lib/workspace-ui";
|
||||
import type { Notebook, AuthUser, NoteCreateKind } from "@edgeever/shared";
|
||||
import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers";
|
||||
@@ -542,6 +543,7 @@ export const NotebookPane = ({
|
||||
onToggleCollapsed?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const showDescendantNotes = useShowDescendantNotesPreference();
|
||||
const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice();
|
||||
// Temporarily keep template actions out of the primary workspace navigation.
|
||||
const showTemplateEntry = true;
|
||||
@@ -847,6 +849,7 @@ export const NotebookPane = ({
|
||||
onOpenChange={handleNotebookOpenChange}
|
||||
expandSiblingsRequest={expandSiblingsRequest}
|
||||
onExpandSiblings={handleExpandNotebookSiblings}
|
||||
showDescendantNotes={showDescendantNotes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
setMemoDragPreview,
|
||||
} from "@/lib/app-helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Notebook } from "@edgeever/shared";
|
||||
import { formatNotebookMemoCount, type Notebook } from "@edgeever/shared";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -41,6 +41,7 @@ export const NotebookTreeItem = ({
|
||||
onOpenChange,
|
||||
expandSiblingsRequest,
|
||||
onExpandSiblings,
|
||||
showDescendantNotes,
|
||||
}: {
|
||||
node: NotebookNode;
|
||||
depth: number;
|
||||
@@ -56,10 +57,12 @@ export const NotebookTreeItem = ({
|
||||
onOpenChange: (notebookId: string, open: boolean) => void;
|
||||
expandSiblingsRequest: { parentId: string | null; token: number } | null;
|
||||
onExpandSiblings: (parentId: string | null) => void;
|
||||
showDescendantNotes: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const open = !collapsedNotebookIds.has(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const showsSplitCount = !showDescendantNotes && hasChildren;
|
||||
const selected = node.id === selectedNotebookId;
|
||||
const isInbox = node.slug === "inbox";
|
||||
const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false;
|
||||
@@ -221,9 +224,9 @@ export const NotebookTreeItem = ({
|
||||
data-notebook-tree-button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
type="button"
|
||||
aria-label={selected
|
||||
? t("notebookTree.currentWithCount", { name: node.name, count: node.memoCount })
|
||||
: t("notebookTree.switchToWithCount", { name: node.name, count: node.memoCount })}
|
||||
aria-label={showsSplitCount
|
||||
? t(selected ? "notebookTree.currentWithSplitCount" : "notebookTree.switchToWithSplitCount", { name: node.name, direct: node.directMemoCount, total: node.memoCount })
|
||||
: t(selected ? "notebookTree.currentWithCount" : "notebookTree.switchToWithCount", { name: node.name, count: showDescendantNotes ? node.memoCount : node.directMemoCount })}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
aria-expanded={hasChildren ? open : undefined}
|
||||
onClick={() => onSelect(node.id)}
|
||||
@@ -276,7 +279,7 @@ export const NotebookTreeItem = ({
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{node.memoCount}
|
||||
{formatNotebookMemoCount({ directCount: node.directMemoCount, totalCount: node.memoCount, hasChildren }, showDescendantNotes)}
|
||||
</span>
|
||||
</button>
|
||||
<div ref={actionsRef} className="relative shrink-0">
|
||||
@@ -399,6 +402,7 @@ export const NotebookTreeItem = ({
|
||||
onOpenChange={onOpenChange}
|
||||
expandSiblingsRequest={expandSiblingsRequest}
|
||||
onExpandSiblings={onExpandSiblings}
|
||||
showDescendantNotes={showDescendantNotes}
|
||||
/>
|
||||
))}
|
||||
</m.div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from ".
|
||||
import { PluginPanelDialog } from "./plugins/PluginPanelDialog";
|
||||
import { shouldDiscardPluginNoteSearchRequest } from "./editor/note-search";
|
||||
import { api, getOrCreateClientDeviceId } from "@/lib/api";
|
||||
import { useShowDescendantNotesPreference } from "@/lib/descendant-notes-preference";
|
||||
import { MarkdownExportMemoryLimitError, type MarkdownExportProgress } from "@/lib/markdown-export";
|
||||
import { exportSelectedMemosAsMarkdownZip } from "@/lib/selected-markdown-export";
|
||||
import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter";
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
} from "@/lib/mobile-editor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
|
||||
import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
|
||||
import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
|
||||
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
|
||||
import type {
|
||||
Pane,
|
||||
@@ -1054,15 +1055,20 @@ export const WorkspaceApp = ({
|
||||
setOnline: setIsOnline,
|
||||
});
|
||||
|
||||
const selectedNotebookDescendantIds = useMemo(
|
||||
() => (selectedNotebookId && !selectedTag ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []),
|
||||
[notebooks, selectedNotebookId, selectedTag]
|
||||
const showDescendantNotes = useShowDescendantNotesPreference();
|
||||
useEffect(() => {
|
||||
// A changed scope must not retain hidden notes in bulk actions.
|
||||
clearMemoSelection();
|
||||
}, [showDescendantNotes, clearMemoSelection]);
|
||||
const selectedNotebookScopeIds = useMemo(
|
||||
() => (selectedNotebookId && !selectedTag ? getNotebookScopeIds(notebooks, selectedNotebookId, showDescendantNotes) : []),
|
||||
[notebooks, selectedNotebookId, selectedTag, showDescendantNotes]
|
||||
);
|
||||
const memosQuery = useInfiniteQuery({
|
||||
queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds, selectedTag],
|
||||
queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookScopeIds, selectedTag],
|
||||
queryFn: ({ pageParam }) => repository.listMemos({
|
||||
notebookId: memoView === "notebook" && !selectedTag ? selectedNotebookId : null,
|
||||
notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookDescendantIds : undefined,
|
||||
notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookScopeIds : undefined,
|
||||
q: search,
|
||||
tag: memoView === "notebook" ? selectedTag ?? undefined : undefined,
|
||||
trash: memoView === "trash",
|
||||
@@ -3659,7 +3665,7 @@ export const WorkspaceApp = ({
|
||||
search,
|
||||
memoFilterMode,
|
||||
memoSortMode,
|
||||
selectedNotebookDescendantIds,
|
||||
selectedNotebookScopeIds,
|
||||
selectedTag,
|
||||
],
|
||||
exact: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlignHorizontalJustifyCenter, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, MousePointerClick, Palette, Sparkles, SunMoon, Type } from "lucide-react";
|
||||
import { AlignHorizontalJustifyCenter, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, ListTree, MousePointerClick, Palette, Sparkles, SunMoon, Type } from "lucide-react";
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EditorContentWidth } from "@/lib/editor-content-width";
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui";
|
||||
import { SETTINGS_ITEM_DESCRIPTION_CLASSNAME, SETTINGS_ITEM_ICON_CLASSNAME, SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/editor-body-font";
|
||||
import { applyUiFontPreference, readUiFontPreference, writeUiFontPreference } from "@/lib/ui-font";
|
||||
import { syncPublishedNoteBodyFont } from "@/lib/published-note-body-font";
|
||||
import { useShowDescendantNotesPreference, writeShowDescendantNotesPreference } from "@/lib/descendant-notes-preference";
|
||||
import { NoteProseCssEditor } from "./NoteProseCssEditor";
|
||||
import { NoteProseCssPreview } from "./NoteProseCssPreview";
|
||||
|
||||
@@ -210,6 +211,7 @@ export const PreferenceCard = ({
|
||||
onNoteProseChange,
|
||||
}: PreferenceCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const showDescendantNotes = useShowDescendantNotesPreference();
|
||||
const { preference: appearancePreference, resolvedTheme, setPreference: setAppearancePreference } = useAppearanceTheme();
|
||||
const { mermaidThemePreference, setMermaidTheme } = useMermaidTheme();
|
||||
const [cssDialogOpen, setCssDialogOpen] = useState(false);
|
||||
@@ -365,6 +367,23 @@ export const PreferenceCard = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<ListTree className={SETTINGS_ITEM_ICON_CLASSNAME} />
|
||||
<div className="min-w-0">
|
||||
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.showDescendantNotesTitle")}</div>
|
||||
<div className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.showDescendantNotesDescription")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
|
||||
<Switch
|
||||
checked={showDescendantNotes}
|
||||
onCheckedChange={writeShowDescendantNotesPreference}
|
||||
aria-label={t("settings.showDescendantNotesAria")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AppWindow className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
readShowDescendantNotesPreference,
|
||||
resolveStoredShowDescendantNotes,
|
||||
SHOW_DESCENDANT_NOTES_CHANGED_EVENT,
|
||||
SHOW_DESCENDANT_NOTES_STORAGE_KEY,
|
||||
writeShowDescendantNotesPreference,
|
||||
} from "./descendant-notes-preference.ts";
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
const OriginalCustomEvent = globalThis.CustomEvent;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.CustomEvent = OriginalCustomEvent;
|
||||
});
|
||||
|
||||
const installWindow = () => {
|
||||
const values = new Map();
|
||||
const events = [];
|
||||
globalThis.CustomEvent ??= class CustomEvent {
|
||||
constructor(type, init) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
}
|
||||
};
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
},
|
||||
dispatchEvent: (event) => {
|
||||
events.push(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { events, values };
|
||||
};
|
||||
|
||||
describe("show descendant notes preference", () => {
|
||||
test("defaults to showing descendant notes unless explicitly disabled", () => {
|
||||
expect(resolveStoredShowDescendantNotes(null)).toBe(true);
|
||||
expect(resolveStoredShowDescendantNotes("unsupported")).toBe(true);
|
||||
expect(resolveStoredShowDescendantNotes("false")).toBe(false);
|
||||
});
|
||||
|
||||
test("persists changes and notifies the current document", () => {
|
||||
const { events, values } = installWindow();
|
||||
writeShowDescendantNotesPreference(false);
|
||||
expect(values.get(SHOW_DESCENDANT_NOTES_STORAGE_KEY)).toBe("false");
|
||||
expect(readShowDescendantNotesPreference()).toBe(false);
|
||||
expect(events.map((event) => [event.type, event.detail])).toEqual([[SHOW_DESCENDANT_NOTES_CHANGED_EVENT, false]]);
|
||||
});
|
||||
|
||||
test("falls back to the default when storage is unavailable", () => {
|
||||
globalThis.window = { localStorage: { getItem: () => { throw new Error("blocked"); } } };
|
||||
expect(readShowDescendantNotesPreference()).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the choice for this session when storage rejects the write", () => {
|
||||
const { events, values } = installWindow();
|
||||
values.set(SHOW_DESCENDANT_NOTES_STORAGE_KEY, "true");
|
||||
globalThis.window.localStorage.setItem = () => {
|
||||
throw new Error("QuotaExceededError");
|
||||
};
|
||||
|
||||
writeShowDescendantNotesPreference(false);
|
||||
expect(readShowDescendantNotesPreference()).toBe(false);
|
||||
expect(events.map((event) => event.detail)).toEqual([false]);
|
||||
|
||||
// Once storage accepts writes again, the stored value takes over.
|
||||
const restored = installWindow();
|
||||
writeShowDescendantNotesPreference(true);
|
||||
expect(restored.values.get(SHOW_DESCENDANT_NOTES_STORAGE_KEY)).toBe("true");
|
||||
restored.values.set(SHOW_DESCENDANT_NOTES_STORAGE_KEY, "false");
|
||||
expect(readShowDescendantNotesPreference()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const SHOW_DESCENDANT_NOTES_STORAGE_KEY = "edgeever.showDescendantNotes";
|
||||
|
||||
export const SHOW_DESCENDANT_NOTES_CHANGED_EVENT = "edgeever:show-descendant-notes-changed";
|
||||
|
||||
// Parent notebooks keep aggregating their sub-notebooks unless turned off.
|
||||
export const resolveStoredShowDescendantNotes = (stored: string | null): boolean =>
|
||||
stored !== "false";
|
||||
|
||||
// Holds the choice while localStorage rejects writes, so the toggle still works
|
||||
// for the rest of this page session.
|
||||
let unsavedPreference: boolean | null = null;
|
||||
|
||||
export const readShowDescendantNotesPreference = (): boolean => {
|
||||
if (typeof window === "undefined") return true;
|
||||
if (unsavedPreference !== null) return unsavedPreference;
|
||||
try {
|
||||
return resolveStoredShowDescendantNotes(
|
||||
window.localStorage?.getItem(SHOW_DESCENDANT_NOTES_STORAGE_KEY) ?? null,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeShowDescendantNotesPreference = (enabled: boolean) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage?.setItem(SHOW_DESCENDANT_NOTES_STORAGE_KEY, enabled ? "true" : "false");
|
||||
unsavedPreference = null;
|
||||
} catch {
|
||||
// Private mode / blocked storage — keep the choice in memory for this session.
|
||||
unsavedPreference = enabled;
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, { detail: enabled }),
|
||||
);
|
||||
};
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === SHOW_DESCENDANT_NOTES_STORAGE_KEY) onChange();
|
||||
};
|
||||
window.addEventListener(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, onChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, onChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
};
|
||||
|
||||
export const useShowDescendantNotesPreference = () =>
|
||||
useSyncExternalStore(subscribe, readShowDescendantNotesPreference, () => true);
|
||||
@@ -442,7 +442,8 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
|
||||
const remote = await api.listMemos({
|
||||
notebookId: params.notebookId,
|
||||
includeDescendants: Boolean(params.notebookIds?.length),
|
||||
// The scope reaches sub-notebooks only when it lists more than the notebook itself.
|
||||
includeDescendants: Boolean(params.notebookIds?.some((notebookId) => notebookId !== params.notebookId)),
|
||||
q: params.q,
|
||||
tag: params.tag,
|
||||
trash: params.trash,
|
||||
|
||||
@@ -30,6 +30,18 @@ describe("buildNotebookTree", () => {
|
||||
expect(root.children[0].children[0].memoCount).toBe(5);
|
||||
});
|
||||
|
||||
test("keeps each notebook's own count next to the rolled-up total", () => {
|
||||
const [root] = buildNotebookTree([
|
||||
notebook("root", null, 2),
|
||||
notebook("child", "root", 3),
|
||||
notebook("grandchild", "child", 5),
|
||||
]);
|
||||
|
||||
expect([root.directMemoCount, root.memoCount]).toEqual([2, 10]);
|
||||
expect([root.children[0].directMemoCount, root.children[0].memoCount]).toEqual([3, 8]);
|
||||
expect([root.children[0].children[0].directMemoCount, root.children[0].children[0].memoCount]).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
test("sorts siblings after descendant counts have been rolled up", () => {
|
||||
const roots = buildNotebookTree(
|
||||
[
|
||||
|
||||
@@ -3,6 +3,8 @@ import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type NotebookNode = Notebook & {
|
||||
// memoCount holds the subtree total; this keeps the notebook's own count.
|
||||
directMemoCount: number;
|
||||
children: NotebookNode[];
|
||||
};
|
||||
|
||||
@@ -16,7 +18,7 @@ export const buildNotebookTree = (notebooks: Notebook[], compareNodes: NotebookN
|
||||
const nodes = new Map<string, NotebookNode>();
|
||||
|
||||
for (const notebook of notebooks) {
|
||||
nodes.set(notebook.id, { ...notebook, children: [] });
|
||||
nodes.set(notebook.id, { ...notebook, directMemoCount: notebook.memoCount, children: [] });
|
||||
}
|
||||
|
||||
const roots: NotebookNode[] = [];
|
||||
|
||||
@@ -734,6 +734,8 @@ export const enUS = {
|
||||
switchTo: "Switch to {{name}}",
|
||||
currentWithCount: "Current: {{name}}, {{count}} notes",
|
||||
switchToWithCount: "Switch to {{name}}, {{count}} notes",
|
||||
currentWithSplitCount: "Current: {{name}}, {{direct}} notes here, {{total}} including sub-notebooks",
|
||||
switchToWithSplitCount: "Switch to {{name}}, {{direct}} notes here, {{total}} including sub-notebooks",
|
||||
actions: "Notebook actions",
|
||||
actionsAria: "Open actions for {{name}}",
|
||||
newChild: "New child notebook",
|
||||
@@ -756,6 +758,9 @@ export const enUS = {
|
||||
systemLanguage: "Follow system",
|
||||
imageCompressionTitle: "Compress note images",
|
||||
imageCompressionAria: "Compress note images",
|
||||
showDescendantNotesTitle: "Show notes from sub-notebooks",
|
||||
showDescendantNotesDescription: "When on, opening a parent notebook also lists notes from all of its sub-notebooks. When off, only notes stored directly in that notebook are listed.",
|
||||
showDescendantNotesAria: "Show notes from sub-notebooks in parent notebooks",
|
||||
aiSelectionMenuTitle: "Show AI assistant when text is selected",
|
||||
aiSelectionMenuAria: "Show the AI assistant action when text is selected",
|
||||
aiSpaceShortcutTitle: "Open AI with Space in an empty block",
|
||||
|
||||
@@ -734,6 +734,8 @@ export const ja = {
|
||||
switchTo: "{{name}} に切り替え",
|
||||
currentWithCount: "現在:{{name}}、{{count}} 件のノート",
|
||||
switchToWithCount: "{{name}} に切り替え、{{count}} 件のノート",
|
||||
currentWithSplitCount: "現在:{{name}}、直下に {{direct}} 件、サブノートブックを含めて {{total}} 件のノート",
|
||||
switchToWithSplitCount: "{{name}} に切り替え、直下に {{direct}} 件、サブノートブックを含めて {{total}} 件のノート",
|
||||
actions: "ノートブック操作",
|
||||
actionsAria: "{{name}} の操作を開く",
|
||||
newChild: "子ノートブックを作成",
|
||||
@@ -756,6 +758,9 @@ export const ja = {
|
||||
systemLanguage: "システムに合わせる",
|
||||
imageCompressionTitle: "ノート内の画像を圧縮",
|
||||
imageCompressionAria: "ノート内の画像を圧縮する",
|
||||
showDescendantNotesTitle: "サブノートブックのノートを表示",
|
||||
showDescendantNotesDescription: "オンにすると、親ノートブックを開いたときにすべてのサブノートブックのノートも一覧表示します。オフにすると、そのノートブックに直接保存されたノートのみを表示します。",
|
||||
showDescendantNotesAria: "親ノートブックにサブノートブックのノートを表示する",
|
||||
aiSelectionMenuTitle: "文字選択時に AI アシスタントを表示",
|
||||
aiSelectionMenuAria: "文字選択時に AI アシスタント操作を表示する",
|
||||
aiSpaceShortcutTitle: "空のブロックで Space から AI を開く",
|
||||
|
||||
@@ -734,6 +734,8 @@ export const zhCN = {
|
||||
switchTo: "切换到 {{name}}",
|
||||
currentWithCount: "当前:{{name}},{{count}} 条笔记",
|
||||
switchToWithCount: "切换到 {{name}},{{count}} 条笔记",
|
||||
currentWithSplitCount: "当前:{{name}},本级 {{direct}} 条笔记,含子笔记本共 {{total}} 条",
|
||||
switchToWithSplitCount: "切换到 {{name}},本级 {{direct}} 条笔记,含子笔记本共 {{total}} 条",
|
||||
actions: "笔记本操作",
|
||||
actionsAria: "打开 {{name}} 的操作菜单",
|
||||
newChild: "新建子笔记本",
|
||||
@@ -756,6 +758,9 @@ export const zhCN = {
|
||||
systemLanguage: "跟随系统",
|
||||
imageCompressionTitle: "压缩笔记内图片",
|
||||
imageCompressionAria: "是否压缩笔记内图片",
|
||||
showDescendantNotesTitle: "显示子笔记本中的笔记",
|
||||
showDescendantNotesDescription: "开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。",
|
||||
showDescendantNotesAria: "是否在父笔记本中显示子笔记本中的笔记",
|
||||
aiSelectionMenuTitle: "选中文字时显示 AI 助手",
|
||||
aiSelectionMenuAria: "选中文字时是否显示 AI 助手入口",
|
||||
aiSpaceShortcutTitle: "空白段落按 Space 唤起 AI",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getNotebookDescendantIds } from "./notebooks.ts";
|
||||
import { formatNotebookMemoCount, getNotebookDescendantIds, getNotebookDescendantMemoCount, getNotebookScopeIds } from "./notebooks.ts";
|
||||
|
||||
const notebook = (id, parentId) => ({
|
||||
const notebook = (id, parentId, memoCount = 0) => ({
|
||||
id,
|
||||
parentId,
|
||||
name: id,
|
||||
@@ -9,7 +9,7 @@ const notebook = (id, parentId) => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
memoCount: 0,
|
||||
memoCount,
|
||||
lastMemoUpdatedAt: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
@@ -35,3 +35,43 @@ describe("getNotebookDescendantIds", () => {
|
||||
expect(new Set(getNotebookDescendantIds(notebooks, "first"))).toEqual(new Set(["first", "second"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotebookScopeIds", () => {
|
||||
const notebooks = [notebook("root", null), notebook("child", "root"), notebook("grandchild", "child")];
|
||||
|
||||
test("covers the whole subtree when descendant notes are shown", () => {
|
||||
expect(new Set(getNotebookScopeIds(notebooks, "root", true))).toEqual(new Set(["root", "child", "grandchild"]));
|
||||
});
|
||||
|
||||
test("covers only the notebook itself when descendant notes are hidden", () => {
|
||||
expect(getNotebookScopeIds(notebooks, "root", false)).toEqual(["root"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNotebookMemoCount", () => {
|
||||
test("keeps the aggregated total when descendant notes are shown", () => {
|
||||
expect(formatNotebookMemoCount({ directCount: 2, totalCount: 7, hasChildren: true }, true)).toBe("7");
|
||||
});
|
||||
|
||||
test("shows direct/total for a parent when descendant notes are hidden", () => {
|
||||
expect(formatNotebookMemoCount({ directCount: 2, totalCount: 7, hasChildren: true }, false)).toBe("2/7");
|
||||
});
|
||||
|
||||
test("shows a single number for a leaf when descendant notes are hidden", () => {
|
||||
expect(formatNotebookMemoCount({ directCount: 2, totalCount: 2, hasChildren: false }, false)).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotebookDescendantMemoCount", () => {
|
||||
test("sums notes in sub-notebooks at every depth but not the notebook itself", () => {
|
||||
const notebooks = [
|
||||
notebook("root", null, 2),
|
||||
notebook("child", "root", 3),
|
||||
notebook("grandchild", "child", 1),
|
||||
notebook("other", null, 9),
|
||||
];
|
||||
|
||||
expect(getNotebookDescendantMemoCount(notebooks, "root")).toBe(4);
|
||||
expect(getNotebookDescendantMemoCount(notebooks, "grandchild")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -31,3 +31,27 @@ export const getNotebookDescendantIds = (notebooks: Notebook[], targetNotebookId
|
||||
|
||||
return descendantIds;
|
||||
};
|
||||
|
||||
// Notebooks whose memos a notebook view lists: the notebook alone, or its
|
||||
// whole subtree when the account shows descendant notes.
|
||||
export const getNotebookScopeIds = (notebooks: Notebook[], targetNotebookId: string, includeDescendants: boolean) =>
|
||||
includeDescendants ? getNotebookDescendantIds(notebooks, targetNotebookId) : [targetNotebookId];
|
||||
|
||||
// Notes stored in a notebook's sub-notebooks (not the notebook itself). Expects
|
||||
// direct per-notebook counts, as the notebook list endpoints return them.
|
||||
export const getNotebookDescendantMemoCount = (notebooks: Notebook[], targetNotebookId: string) => {
|
||||
const memoCountById = new Map(notebooks.map((notebook) => [notebook.id, notebook.memoCount]));
|
||||
return getNotebookDescendantIds(notebooks, targetNotebookId)
|
||||
.filter((notebookId) => notebookId !== targetNotebookId)
|
||||
.reduce((total, notebookId) => total + (memoCountById.get(notebookId) ?? 0), 0);
|
||||
};
|
||||
|
||||
// A parent notebook shows "direct/total" once descendant notes are hidden, so
|
||||
// the first number always matches what opening it lists.
|
||||
export const formatNotebookMemoCount = (
|
||||
counts: { directCount: number; totalCount: number; hasChildren: boolean },
|
||||
showDescendantNotes: boolean,
|
||||
) => {
|
||||
if (showDescendantNotes) return String(counts.totalCount);
|
||||
return counts.hasChildren ? `${counts.directCount}/${counts.totalCount}` : String(counts.directCount);
|
||||
};
|
||||
Reference in new issue
Block a user