feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s

Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:27:35 +08:00
parent 4a6fe7d05e
commit 2b1c6ba7e0
18 changed files with 1344 additions and 112 deletions

View File

@@ -5,6 +5,7 @@ import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/notebook_manifest.dart';
import '../storage/sidecar_store.dart';
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
@@ -78,6 +79,21 @@ class VaultNote {
final DateTime modified;
}
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
class VaultContainer {
const VaultContainer({
required this.folderPath,
required this.title,
required this.modified,
required this.memberCount,
});
final String folderPath;
final String title;
final DateTime modified;
final int memberCount;
}
/// Records the user-picked vault root folder (an Obsidian-style vault) and
/// gates app startup behind a valid choice.
///
@@ -190,6 +206,169 @@ class VaultService {
/// at least one importable source file. Returns the notebooks sorted by
/// source-file mtime, most-recent first. An empty / missing vault yields an
/// empty list (never throws).
/// Scan vault folders that hold a [kNotebookManifestName] container.
Future<List<VaultContainer>> scanContainers() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final out = <VaultContainer>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
final c = await _readContainerFolder(entity);
if (c != null) out.add(c);
}
out.sort((a, b) => b.modified.compareTo(a.modified));
return out;
}
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
final manifest = await NotebookManifest.read(folder.path);
if (manifest == null) return null;
final file = NotebookManifest.fileIn(folder.path);
final stat = await file.stat();
final title = manifest.title.trim().isNotEmpty
? manifest.title.trim()
: p.basename(folder.path);
return VaultContainer(
folderPath: folder.path,
title: title,
modified: stat.modified,
memberCount: manifest.members.length,
);
}
/// Create an OneNote-style notebook container with one blank ink page.
Future<VaultContainer> createNotebookContainer(String title) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final trimmed = title.trim();
final baseName = _sanitizeFolderName(trimmed);
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = p.join('pages', pageId, kNotebookBaseName);
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folder.path, pageRel);
final now = DateTime.now().toUtc();
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: pageTitle,
createdAt: now,
updatedAt: now,
),
);
final manifest = NotebookManifest(
title: pageTitle,
members: [
NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel.replaceAll('\\', '/'),
title: pageTitle,
),
],
);
await NotebookManifest.write(folder.path, manifest);
return VaultContainer(
folderPath: folder.path,
title: pageTitle,
modified: now,
memberCount: 1,
);
}
/// Append a blank ink page to an existing container. Returns the new member.
Future<NotebookMember> addBlankPageToContainer(
String folderPath, {
String title = 'Untitled page',
}) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = 'pages/$pageId/$kNotebookBaseName';
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
final now = DateTime.now().toUtc();
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: title,
createdAt: now,
updatedAt: now,
),
);
final member = NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel,
title: title,
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Copy [sourceAbsolutePath] into the container and register it as a member.
Future<NotebookMember> importFileIntoContainer(
String folderPath,
String sourceAbsolutePath,
) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final basename = p.basename(sourceAbsolutePath);
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
final kind = notebookMemberKindFromExt(ext);
if (kind == null || kind == NotebookMemberKind.note) {
throw StateError('Unsupported import type: $ext');
}
final destRel = basename;
var destPath = p.join(folderPath, destRel);
var n = 2;
while (await File(destPath).exists()) {
final stem = p.basenameWithoutExtension(basename);
destPath = p.join(folderPath, '$stem $n.$ext');
n++;
}
await File(sourceAbsolutePath).copy(destPath);
final member = NotebookMember(
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
kind: kind,
relativePath: p.basename(destPath),
title: p.basenameWithoutExtension(destPath),
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Absolute path for a member inside [folderPath].
String memberAbsolutePath(String folderPath, NotebookMember member) =>
p.normalize(p.join(folderPath, member.relativePath));
Future<List<VaultNotebook>> scanNotebooks() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
@@ -200,7 +379,9 @@ class VaultService {
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue; // skip .badnote etc.
if (folderName.startsWith('.')) continue;
// Container folders are listed by [scanContainers], not here.
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
final notebook = await _readNotebookFolder(entity);
if (notebook != null) notebooks.add(notebook);
@@ -261,6 +442,7 @@ class VaultService {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
final note = await _readNoteFolder(entity);
if (note != null) notes.add(note);