feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
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:
@@ -166,8 +166,13 @@ class VaultSearchIndex {
|
||||
}) {
|
||||
final parts = <String>[title];
|
||||
if (sidecar != null) {
|
||||
// Typed text boxes: a stroke carries `textContent` regardless of tool
|
||||
// (EditorTool has only pen/highlighter/eraser; text is a content flag).
|
||||
// Typed text boxes on the pen-first text tool (SidecarText), plus any
|
||||
// legacy stroke-embedded textContent.
|
||||
for (final pageTexts in sidecar.texts.values) {
|
||||
for (final t in pageTexts) {
|
||||
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
|
||||
}
|
||||
}
|
||||
for (final pageStrokes in sidecar.strokes.values) {
|
||||
for (final stroke in pageStrokes) {
|
||||
final text = stroke.textContent;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user