603 lines
32 KiB
Markdown
603 lines
32 KiB
Markdown
|
|
# BadNote — File-Based Storage Re-Architecture (Obsidian-style vault)
|
|||
|
|
|
|||
|
|
**Status:** DESIGN (not yet implemented)
|
|||
|
|
**Date:** 2026-06-24
|
|||
|
|
**Owner plan file:** `docs/plans/2026-06-24-file-based-storage.md`
|
|||
|
|
**Mode:** DELIBERATE (touches persistence of a working pen editor — must not lose ink)
|
|||
|
|
|
|||
|
|
> Grounding note: written after reading the actual current sources —
|
|||
|
|
> `lib/services/database_service.dart`, `lib/editor/persistence/editor_repository.dart`,
|
|||
|
|
> `lib/editor/persistence/save_scheduler.dart`, `lib/editor/canvas/pen_editor_screen.dart`,
|
|||
|
|
> `lib/screens/split_view_screen.dart`, `lib/screens/home_screen.dart`,
|
|||
|
|
> `lib/services/pdf_service.dart`, `lib/services/pptx_service.dart`,
|
|||
|
|
> `lib/providers/document_provider.dart`, `lib/providers/settings_provider.dart`,
|
|||
|
|
> `lib/main.dart`, and the stroke models
|
|||
|
|
> (`lib/editor/engine/stroke_model.dart`, `lib/models/ink_stroke.dart`,
|
|||
|
|
> `lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
|
|||
|
|
> `lib/models/scratch_link.dart`, `lib/models/bookmark.dart`,
|
|||
|
|
> `lib/models/document.dart`). `file_picker` `getDirectoryPath` (Windows desktop
|
|||
|
|
> support) confirmed via Context7 against the installed `file_picker: ^8.0.0`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 0. The user's intent (verbatim)
|
|||
|
|
|
|||
|
|
1. **"Import file" is a TOP-LEVEL home-screen action**, sibling of "Create
|
|||
|
|
notebook", accepting **multiple file types** (docx, pptx, pdf, …).
|
|||
|
|
2. **Annotations travel WITH the file** ("跟着文件走") — stored as JSON
|
|||
|
|
**sidecar files** next to the source file, **NOT in SQLite**. Sync follows the
|
|||
|
|
file.
|
|||
|
|
3. After importing a PDF, the file is placed inside a **notebook folder**, then
|
|||
|
|
synced to a **server**. User believes this is **more robust than SQLite**.
|
|||
|
|
4. On first launch, the user **picks a notebook ROOT folder** (an Obsidian-style
|
|||
|
|
vault).
|
|||
|
|
5. **Robust auto-save.**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. What is stored in SQLite TODAY (inventory)
|
|||
|
|
|
|||
|
|
Read from `lib/services/database_service.dart` (schema version **8**). This is the
|
|||
|
|
data that must move to files or be re-homed.
|
|||
|
|
|
|||
|
|
| Table | Written/read by | Holds | Coordinate format | Disposition |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| `documents` | `DocumentListNotifier` (`document_provider.dart`), `DatabaseService.insertDocument/getAllDocuments/deleteDocument` | id (uuid), filename, doc_type (`pdf`/`pptx`), **absolute** file_path, page_count, rotation, timestamps | — | → derived from vault scan; becomes a cache/index only |
|
|||
|
|
| `ink` | `EditorRepository` (`editor_repository.dart`), `SaveScheduler` | committed PDF-editor strokes; one row/stroke; `host_id = "doc:<documentId>:page:<pageIndex>"`, `stroke_json` = `EditorStroke.toJson()` | normalized [0,1], width = fraction of page width | → **sidecar `strokes[pageIndex]`** |
|
|||
|
|
| `scratch_links` | `PenEditorScreen._placeScratchLink`, `DatabaseService.saveScratchLink/loadScratchLinks` | anchor id, document_id, page_index, nx, ny | normalized [0,1] | → **sidecar `scratchLinks[]`** |
|
|||
|
|
| `scratchpads` | `SplitViewScreen` (keyed by **anchor id**), `DatabaseService.saveScratchpad/loadScratchpad` | one row per anchor id; `strokes_json` = list of `InkStroke.toJson()` | **absolute world pixels** (infinite canvas) | → **sidecar `scratchLinks[].scratchpad.strokes[]`** |
|
|||
|
|
| `bookmarks` | `DatabaseService.insertBookmark/getBookmarks` | id, document_id, page_number, label, color | page index | → **sidecar `bookmarks[]`** |
|
|||
|
|
| `annotations` | `DatabaseService.saveAnnotations/getAnnotations` | legacy per-page `annotation_json` blob | per-page | **DEAD in new editor** — migrate if present, else ignore |
|
|||
|
|
| `notes` + `strokes` | `note_provider.dart` via `insertNote/updateNote`; `Note` model | free ink notebooks (not file-backed); strokes are `InkStroke` rows | normalized | → **standalone notebook sidecars** (see §A.4) |
|
|||
|
|
| `board_cards` | `DatabaseService.saveBoardCards/loadBoard` (F7 sticky board) | board cards geometry+text | absolute | → **per-notebook `board.json`** (or keep in SQLite short-term, §B) |
|
|||
|
|
| `document_fts`, `notes_fts`, `ocr_results`, `notebook_pages` | FTS + OCR | search index, OCR text | — | **stays in SQLite as a rebuildable cache** (never the source of truth) |
|
|||
|
|
|
|||
|
|
**Key existing facts the new format must preserve (cite):**
|
|||
|
|
|
|||
|
|
- `EditorStroke.toJson()` (freezed/json_serializable, `stroke_model.dart`) emits:
|
|||
|
|
`{ "id", "points":[{ "x","y","pressure","tilt","timestamp","pointerDeviceKind" }], "tool":"pen"|"highlighter"|"eraser", "color":<int ARGB>, "width":<double>, "filled":<bool>, "textContent":<String?>, "fontSize":<double> }`.
|
|||
|
|
**`brush` is deliberately NOT serialized** (`@JsonKey(includeFromJson:false,includeToJson:false)` in `stroke_model.dart`), so loaded strokes default to `BrushKind.fountainPen`. The sidecar format inherits this limitation (see §A.5 "brush TODO").
|
|||
|
|
- `InkStroke.toJson()` (`ink_stroke.dart`) emits the same point shape plus
|
|||
|
|
`createdAt` and `strokeWidth` (note: `strokeWidth`, not `width`). This is the
|
|||
|
|
format already persisted for scratchpads and free notes.
|
|||
|
|
- `ScratchLink.toJson()` (`scratch_link.dart`, hand-written): `{id, documentId, pageIndex, nx, ny}`.
|
|||
|
|
- The editor derives its **documentId from the file path** via a djb2 hash
|
|||
|
|
(`_documentIdFromPath` in `pen_editor_screen.dart`). Once strokes live in a
|
|||
|
|
sidecar next to the file, **the path-hash document id becomes irrelevant** —
|
|||
|
|
the sidecar IS the identity. This removes a class of bugs (moving a file
|
|||
|
|
orphaned its SQLite rows).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Decision summary (what we are building)
|
|||
|
|
|
|||
|
|
- A **vault** = a user-picked root folder, path stored in `SharedPreferences`.
|
|||
|
|
- Each imported document becomes a **notebook folder** inside the vault:
|
|||
|
|
`<vault>/<notebook-name>/` containing the **source file** + one **sidecar**
|
|||
|
|
`<file>.badnote.json` holding all annotations for that file.
|
|||
|
|
- **The sidecar is the source of truth.** SQLite is demoted to a
|
|||
|
|
**rebuildable index/cache** (FTS + thumbnails + recent list). Recommendation:
|
|||
|
|
**keep SQLite, but only as cache** (§B explains why dropping it entirely is
|
|||
|
|
more work than it's worth right now).
|
|||
|
|
- **Auto-save** writes the sidecar atomically (temp + rename), debounced, with
|
|||
|
|
flush on pause/close.
|
|||
|
|
- **Sync** = file-level sync of the whole vault folder to a server; sync unit =
|
|||
|
|
notebook folder; conflict policy = last-write-wins per file + `.conflict` copy.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## A. On-disk layout + JSON schema
|
|||
|
|
|
|||
|
|
### A.1 Vault layout
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
<vault root>/ ← user-picked, persisted in SharedPreferences
|
|||
|
|
├─ .badnote/ ← vault-level app metadata (hidden)
|
|||
|
|
│ ├─ vault.json ← { "schemaVersion": 1, "vaultId": "<uuid>", "createdAt": ... }
|
|||
|
|
│ └─ index.sqlite ← OPTIONAL local cache/index (FTS, thumbnails). Rebuildable. NOT synced.
|
|||
|
|
├─ Calculus Lecture 3/ ← a notebook folder (one per imported doc)
|
|||
|
|
│ ├─ Calculus Lecture 3.pdf ← the source file (pdf/docx/pptx/…)
|
|||
|
|
│ ├─ Calculus Lecture 3.pdf.badnote.json ← the sidecar (annotations travel with the file)
|
|||
|
|
│ └─ .badnote-assets/ ← optional: rendered page PNGs for pptx/docx, thumbnails
|
|||
|
|
│ ├─ slide_1.png …
|
|||
|
|
│ └─ thumb_0.png …
|
|||
|
|
├─ My freehand notes/ ← a NON-file-backed notebook (free ink, no source doc)
|
|||
|
|
│ └─ notebook.badnote.json ← strokes-only notebook (replaces SQLite notes/strokes)
|
|||
|
|
└─ …
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Rules:
|
|||
|
|
- **Notebook folder name** = sanitized source filename (basename without
|
|||
|
|
extension), de-duplicated with a numeric suffix on collision.
|
|||
|
|
- **Sidecar name** = `<source filename incl. ext>.badnote.json`. Keeping the full
|
|||
|
|
source extension in the sidecar name means a folder with both `slides.pdf` and
|
|||
|
|
`slides.pptx` never collides.
|
|||
|
|
- **Sidecar lives in the same folder as its file** → moving/copying/syncing the
|
|||
|
|
folder moves the annotations with it ("跟着文件走"). This is the whole point.
|
|||
|
|
- `.badnote/index.sqlite` is **per-vault** and **excluded from sync** (it is a
|
|||
|
|
cache; each device rebuilds its own). It replaces today's
|
|||
|
|
`getApplicationDocumentsDirectory()/badnote.db`.
|
|||
|
|
|
|||
|
|
### A.2 Sidecar JSON schema (file-backed document)
|
|||
|
|
|
|||
|
|
`<file>.badnote.json` (UTF-8, pretty-printed for diff-friendliness / git sync):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"badnoteSidecarVersion": 1,
|
|||
|
|
"sourceFile": "Calculus Lecture 3.pdf",
|
|||
|
|
"docType": "pdf",
|
|||
|
|
"pageCount": 42,
|
|||
|
|
"rotation": 0,
|
|||
|
|
"createdAt": "2026-06-24T10:00:00.000Z",
|
|||
|
|
"updatedAt": "2026-06-24T10:32:11.500Z",
|
|||
|
|
|
|||
|
|
"strokes": {
|
|||
|
|
"0": [ <EditorStroke.toJson()>, <EditorStroke.toJson()>, … ],
|
|||
|
|
"3": [ <EditorStroke.toJson()>, … ]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
"highlights": {
|
|||
|
|
"0": [ { "l": 0.12, "t": 0.20, "r": 0.88, "b": 0.235, "color": 1714657595 } ]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
"bookmarks": [
|
|||
|
|
{ "id": "<uuid>", "pageNumber": 5, "label": "Proof", "color": 4283215696, "createdAt": "…" }
|
|||
|
|
],
|
|||
|
|
|
|||
|
|
"scratchLinks": [
|
|||
|
|
{
|
|||
|
|
"id": "<uuid>",
|
|||
|
|
"pageIndex": 7,
|
|||
|
|
"nx": 0.83, "ny": 0.41,
|
|||
|
|
"createdAt": "…",
|
|||
|
|
"scratchpad": {
|
|||
|
|
"canvasWidth": 4000,
|
|||
|
|
"canvasHeight": 4000,
|
|||
|
|
"strokes": [ <InkStroke.toJson()>, … ] // absolute world pixels (unchanged format)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Schema notes, tied to existing code:
|
|||
|
|
|
|||
|
|
- **`strokes`** is a map keyed by **string page index** → list of
|
|||
|
|
`EditorStroke.toJson()` objects. This is byte-for-byte the JSON already written
|
|||
|
|
to the `ink` table's `stroke_json` column by `EditorRepository.saveHost`
|
|||
|
|
(`jsonEncode(stroke.toJson())`). Loading just calls `EditorStroke.fromJson`.
|
|||
|
|
- Drop the `host_id = "doc:<id>:page:<i>"` convention entirely; the sidecar key
|
|||
|
|
IS the page index. `EditorRepository.pageHostId` /
|
|||
|
|
`_pageIndexFromHostId` (in `pen_editor_screen.dart`) become obsolete for the
|
|||
|
|
file path.
|
|||
|
|
- **`highlights`** persists what is TODAY in-memory only — see
|
|||
|
|
`_highlightsByPage` and `TODO(persist-highlights)` in `pen_editor_screen.dart`.
|
|||
|
|
Each rect stored normalized [0,1] (`l/t/r/b`) exactly as `_highlightSelection`
|
|||
|
|
computes it. This closes that TODO as a side benefit.
|
|||
|
|
- **`bookmarks`** mirrors `Bookmark.toJson()` (`bookmark.dart`); `documentId`
|
|||
|
|
field is dropped (the sidecar already scopes it).
|
|||
|
|
- **`scratchLinks[]`** merges today's TWO tables: `scratch_links` (anchor
|
|||
|
|
geometry) + `scratchpads` (the anchor's private ink, currently keyed by anchor
|
|||
|
|
id). Each anchor now **embeds** its scratchpad. `scratchpad.strokes` keep the
|
|||
|
|
`InkStroke.toJson()` format in **absolute world pixels** — unchanged from
|
|||
|
|
`SplitViewScreen._saveImmediate`, so the infinite-canvas logic
|
|||
|
|
(`_checkCanvasExpansion`, `penStrokesFromInk`) needs no change. We persist
|
|||
|
|
`canvasWidth/Height` so the world size restores (today it always resets to
|
|||
|
|
4000×4000).
|
|||
|
|
|
|||
|
|
### A.3 Why normalized vs absolute coords are preserved verbatim
|
|||
|
|
|
|||
|
|
- PDF-editor strokes (`EditorStroke`) are normalized to the page rect; width is a
|
|||
|
|
fraction of page width (`stroke_model.dart` header comment). Sidecar stores them
|
|||
|
|
unchanged → no re-projection, no rounding drift, export
|
|||
|
|
(`PdfService.exportAnnotatedPdf`) keeps working untouched.
|
|||
|
|
- Scratchpad strokes (`InkStroke`) are absolute world pixels (infinite canvas).
|
|||
|
|
Sidecar stores them unchanged.
|
|||
|
|
- This is a **format-preserving** migration: same `toJson`/`fromJson`, different
|
|||
|
|
container (file vs row). That is what makes it low-risk.
|
|||
|
|
|
|||
|
|
### A.4 Standalone (non-file) notebooks — the "Create notebook" path
|
|||
|
|
|
|||
|
|
Free-ink notes today live in `notes` + `strokes` (`Note` model, `note_provider.dart`).
|
|||
|
|
In the vault they become a notebook folder with **no source file**, holding a
|
|||
|
|
`notebook.badnote.json`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"badnoteSidecarVersion": 1,
|
|||
|
|
"docType": "notebook",
|
|||
|
|
"title": "My freehand notes",
|
|||
|
|
"tags": ["math"],
|
|||
|
|
"createdAt": "…", "updatedAt": "…",
|
|||
|
|
"pages": [
|
|||
|
|
{ "ordinal": 0, "strokes": [ <InkStroke.toJson()>, … ] }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
(`InkStroke.toJson()` is the exact format `Note.strokes` already serialize to.)
|
|||
|
|
|
|||
|
|
### A.5 Brush persistence caveat (honest limitation)
|
|||
|
|
|
|||
|
|
`EditorStroke.brush` and `PenStroke.brush` are **not serialized** today
|
|||
|
|
(`@JsonKey(includeFromJson:false,includeToJson:false)`, see `stroke_model.dart`
|
|||
|
|
and `TODO(brush-persist)` in `pen_editor_screen.dart`). The sidecar inherits this:
|
|||
|
|
a reloaded pen stroke renders as `fountainPen`; a highlighter is recovered from
|
|||
|
|
`tool == highlighter`. **Recommendation:** add an optional `"brush"` field to the
|
|||
|
|
sidecar `EditorStroke` JSON in a later increment by flipping the `@JsonKey` — the
|
|||
|
|
sidecar schema is forward-compatible (unknown fields ignored on read), so this is
|
|||
|
|
non-breaking. Not required for this storage migration.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## B. Migration: SQLite → sidecars (no data loss)
|
|||
|
|
|
|||
|
|
**Recommendation: KEEP SQLite, demote it to a rebuildable cache. Do NOT drop it.**
|
|||
|
|
|
|||
|
|
Reasons:
|
|||
|
|
- FTS5 (`document_fts`, `notes_fts`) and OCR (`ocr_results`) are non-trivial and
|
|||
|
|
query-shaped; re-implementing search over flat JSON files is a separate project.
|
|||
|
|
Keep them in `.badnote/index.sqlite`, rebuilt by scanning sidecars.
|
|||
|
|
- The home screen's "recent documents" list (`getAllDocuments`) wants fast sorted
|
|||
|
|
access; a cache table is the pragmatic backing for it (the source of truth is
|
|||
|
|
still the vault scan).
|
|||
|
|
- Dropping SQLite forces rewriting `note_provider`, `document_provider`,
|
|||
|
|
`search_provider`, OCR, and the board in one shot — high blast radius. Demoting
|
|||
|
|
is incremental and reversible.
|
|||
|
|
|
|||
|
|
### B.1 One-time migration on first launch after upgrade
|
|||
|
|
|
|||
|
|
Guarded by a `SharedPreferences` flag `vaultMigrationDone` (and only runs once a
|
|||
|
|
vault root exists — see §C). Algorithm:
|
|||
|
|
|
|||
|
|
1. Open the legacy DB at `getApplicationDocumentsDirectory()/badnote.db` (the path
|
|||
|
|
`DatabaseService._initialize` uses today). If absent → nothing to migrate.
|
|||
|
|
2. For each row in `documents`:
|
|||
|
|
a. Resolve the legacy `file_path` (absolute). If the file still exists, **copy**
|
|||
|
|
it into a new notebook folder `<vault>/<sanitized filename>/`.
|
|||
|
|
b. Build the sidecar:
|
|||
|
|
- `strokes`: query `ink WHERE host_id LIKE 'doc:<documentId>:page:%'`
|
|||
|
|
(the `EditorRepository.loadDocument` query), group by page index parsed
|
|||
|
|
from host_id, write each `stroke_json` straight through (it's already
|
|||
|
|
`EditorStroke` JSON — no re-encode).
|
|||
|
|
- `bookmarks`: `getBookmarks(documentId)`.
|
|||
|
|
- `scratchLinks`: `loadScratchLinks(documentId)`; for each, `loadScratchpad(anchorId)`
|
|||
|
|
→ embed as `scratchpad.strokes` (re-encode via `InkStroke.toJson`).
|
|||
|
|
- `annotations` (legacy per-page blob): if any rows exist, attempt to decode
|
|||
|
|
and fold into `strokes`; if format is unrecognized, copy the raw blob into
|
|||
|
|
a `legacyAnnotations` field so nothing is silently dropped.
|
|||
|
|
c. Write the sidecar **atomically** (§F).
|
|||
|
|
3. For each `notes` row → write a standalone notebook sidecar (§A.4) under a
|
|||
|
|
notebook folder.
|
|||
|
|
4. For `board_cards`: SHORT TERM leave them in SQLite (board is self-contained and
|
|||
|
|
not part of the "files" intent). LATER, write a `board.json` per notebook.
|
|||
|
|
5. **Do not delete the legacy DB.** Rename it to `badnote.db.premigration` as a
|
|||
|
|
safety net. Set `vaultMigrationDone = true`.
|
|||
|
|
6. Rebuild `.badnote/index.sqlite` (FTS + recent list) by scanning the new vault.
|
|||
|
|
|
|||
|
|
### B.2 Crash safety of migration
|
|||
|
|
|
|||
|
|
- Process sidecars one notebook at a time; each sidecar write is atomic.
|
|||
|
|
- The migration is **idempotent**: re-running skips notebook folders whose sidecar
|
|||
|
|
already exists and validates. If it dies halfway, relaunch resumes.
|
|||
|
|
- Because the legacy DB is preserved until the flag flips, a failed migration
|
|||
|
|
loses nothing.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## C. Folder picker on init (the vault prompt)
|
|||
|
|
|
|||
|
|
Use **`file_picker`** — already a dependency (`file_picker: ^8.0.0` in
|
|||
|
|
`pubspec.yaml`, already used by `PdfService.pickPdfFile` /
|
|||
|
|
`PptxService.openPptxFile`). Its **`FilePicker.platform.getDirectoryPath()`** is
|
|||
|
|
**Desktop/Windows supported** (confirmed via Context7). **No new package needed.**
|
|||
|
|
Do NOT add `file_selector` — `file_picker` already covers both file and directory
|
|||
|
|
picking and is wired in.
|
|||
|
|
|
|||
|
|
### C.1 Persistence + flow
|
|||
|
|
|
|||
|
|
- New `VaultService` (singleton, like `DatabaseService`):
|
|||
|
|
- `Future<String?> getVaultRoot()` — reads `SharedPreferences` key `vaultRoot`.
|
|||
|
|
- `Future<void> setVaultRoot(String path)` — writes it.
|
|||
|
|
- `Future<bool> vaultRootValid()` — true iff the stored path exists and is a
|
|||
|
|
writable directory.
|
|||
|
|
- `main.dart` change: after `SharedPreferences.getInstance()`, check
|
|||
|
|
`vaultRootValid()`.
|
|||
|
|
- If **valid** → go to `HomeScreen` as today.
|
|||
|
|
- If **missing/invalid** → show a `VaultSetupScreen` (a gate before
|
|||
|
|
`HomeScreen`) that explains "Pick a folder to store your notebooks (like an
|
|||
|
|
Obsidian vault)" and calls `getDirectoryPath(dialogTitle: 'Choose your BadNote vault', lockParentWindow: true)`.
|
|||
|
|
On selection: create `<root>/.badnote/vault.json`, persist the path, then
|
|||
|
|
enter `HomeScreen`.
|
|||
|
|
- **Re-prompt if missing:** if the stored path later disappears (external drive
|
|||
|
|
unplugged, folder deleted), `vaultRootValid()` returns false → the gate shows
|
|||
|
|
again with a "your vault folder is missing — relocate or pick a new one"
|
|||
|
|
message. Never silently fall back to app-documents (that would scatter data).
|
|||
|
|
- A **"Change vault"** entry in `SettingsScreen` re-runs the picker.
|
|||
|
|
|
|||
|
|
### C.2 Windows specifics
|
|||
|
|
|
|||
|
|
- Pass `lockParentWindow: true` so the native dialog is modal (Context7 note).
|
|||
|
|
- Wrap in try/catch (Context7 shows `getDirectoryPath` can throw on Windows for
|
|||
|
|
permission/system issues) and surface a retry.
|
|||
|
|
- Validate the chosen folder is writable by writing+deleting a probe file before
|
|||
|
|
committing it as the vault.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## D. Multi-format import (docx / pptx / pdf)
|
|||
|
|
|
|||
|
|
This is the genuinely hard part. Be honest about it.
|
|||
|
|
|
|||
|
|
### D.1 The home-screen entry (requirement #1)
|
|||
|
|
|
|||
|
|
Replace today's two separate `IconButton`s (`_importPdf`, `_importPptx` in
|
|||
|
|
`home_screen.dart`) and the empty-state buttons with **one top-level "Import
|
|||
|
|
file" action**, a sibling of "Create notebook" (the FAB / `_createAndOpenNote`).
|
|||
|
|
A single `FilePicker.pickFiles(type: FileType.custom, allowedExtensions: ['pdf','docx','pptx','ppt'])`
|
|||
|
|
call; route on extension. Both actions sit at the same visual level (e.g. two
|
|||
|
|
primary buttons in the empty state, and two entries in the app bar / a small
|
|||
|
|
"+ New" menu with "Create notebook" and "Import file").
|
|||
|
|
|
|||
|
|
### D.2 Per-format strategy
|
|||
|
|
|
|||
|
|
| Format | Annotate how | Mechanism | Windows-viable? |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| **PDF** | Directly, as today | `pdfrx` `PdfViewer` + normalized ink overlay (`PenEditorScreen`, unchanged) | YES — already shipping |
|
|||
|
|
| **PPTX/PPT** | **Convert to images, annotate as slides** | `PptxService.convertToImages` (LibreOffice headless → PNG, fallback placeholders) + `PenSlideScreen` | PARTIAL — needs LibreOffice |
|
|||
|
|
| **DOCX** | **Convert to PDF, then annotate as PDF** (recommended) | LibreOffice headless `--convert-to pdf`, then the PDF path flows into `PenEditorScreen` | PARTIAL — needs LibreOffice |
|
|||
|
|
|
|||
|
|
### D.3 The realistic recommendation
|
|||
|
|
|
|||
|
|
- **PDF:** unchanged. Place the picked file into a notebook folder, open
|
|||
|
|
`PenEditorScreen` on the **vault copy** (not the original picked path).
|
|||
|
|
- **DOCX → PDF (convert-on-import):** the cleanest path is to **convert DOCX to a
|
|||
|
|
PDF once, at import time**, store the **PDF** as the notebook's annotatable
|
|||
|
|
artifact (keep the original `.docx` alongside it for fidelity/round-trip). Then
|
|||
|
|
everything downstream is the existing, working PDF pipeline. This is far simpler
|
|||
|
|
than rendering Word layout natively in Flutter (there is no good pure-Dart DOCX
|
|||
|
|
renderer).
|
|||
|
|
- **PPTX:** keep the existing slide-image path (`PptxService` + `PenSlideScreen`),
|
|||
|
|
but **cache the rendered PNGs into the notebook's `.badnote-assets/`** instead of
|
|||
|
|
a temp dir (today `convertToImages` writes to `getTemporaryDirectory()`, so
|
|||
|
|
slides re-render every open — see `home_screen.dart` `_openDocument`). Caching
|
|||
|
|
also makes the notebook self-contained for sync.
|
|||
|
|
|
|||
|
|
### D.4 The hard truth about conversion (call it out)
|
|||
|
|
|
|||
|
|
- Both DOCX→PDF and PPTX→PNG currently depend on **LibreOffice headless** being on
|
|||
|
|
`PATH` (`PptxService._convertViaLibreOffice` runs `which libreoffice` then
|
|||
|
|
`libreoffice --headless --convert-to …`). On a **Windows tablet, LibreOffice is
|
|||
|
|
usually NOT installed**, and `which` is a POSIX tool that won't resolve `soffice.exe`.
|
|||
|
|
**This path will silently fail today on Windows** and fall back to placeholder
|
|||
|
|
slides.
|
|||
|
|
- **Proposed fallbacks, in order:**
|
|||
|
|
1. **Detect LibreOffice/`soffice.exe`** at the standard Windows install paths
|
|||
|
|
(`C:\Program Files\LibreOffice\program\soffice.exe`) in addition to `PATH`;
|
|||
|
|
invoke `soffice` (not `libreoffice`) on Windows. Fix the `which` assumption.
|
|||
|
|
2. If absent, **prompt the user** ("Install LibreOffice to import Word/PowerPoint,
|
|||
|
|
or convert to PDF first"), and offer a **"locate soffice.exe" picker** that we
|
|||
|
|
persist in SharedPreferences.
|
|||
|
|
3. **Bundle/ship nothing heavy.** Do not attempt to embed a converter. For a
|
|||
|
|
single-user tablet app, requiring LibreOffice (or pre-export to PDF) is an
|
|||
|
|
acceptable, honest constraint.
|
|||
|
|
- **Minimal first cut:** ship **PDF import end-to-end on the new vault**, plus the
|
|||
|
|
one-file "Import file" entry that *accepts* docx/pptx, but for docx/pptx route
|
|||
|
|
through the existing (LibreOffice-dependent) converters with the Windows
|
|||
|
|
`soffice.exe` fix. Treat full docx/pptx fidelity as a known limitation, not a
|
|||
|
|
blocker for the storage re-architecture.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## E. Server sync (file-level, single user)
|
|||
|
|
|
|||
|
|
Keep it pragmatic. The data is now plain files in one folder, which is exactly what
|
|||
|
|
makes simple sync viable.
|
|||
|
|
|
|||
|
|
### E.1 Sync unit & approach
|
|||
|
|
|
|||
|
|
- **Sync unit = the notebook folder** (source file + sidecar + assets). A notebook
|
|||
|
|
is self-contained, so syncing the folder syncs the annotations with it.
|
|||
|
|
- **Exclude** `.badnote/index.sqlite` and `.badnote-assets/` from sync if desired
|
|||
|
|
(assets are re-derivable; the index is per-device). Source file + sidecar are the
|
|||
|
|
must-sync pair.
|
|||
|
|
- **Recommended concrete approach (single-user): WebDAV** to a self-hosted/Nextcloud
|
|||
|
|
endpoint, OR a **simple REST blob sync** if the user controls the server.
|
|||
|
|
- WebDAV is the lowest-effort robust option (PUT/GET/PROPFIND, mtime-based),
|
|||
|
|
works against Nextcloud/ownCloud/rclone-serve, and there are Dart HTTP clients.
|
|||
|
|
- If the user already uses Nextcloud/Dropbox/OneDrive **and** the vault folder
|
|||
|
|
lives inside that synced folder, BadNote needs **zero sync code** — the OS sync
|
|||
|
|
client handles it. This is the cheapest path and worth recommending as option 0.
|
|||
|
|
- **git** is possible (text JSON diffs nicely) but binary PDFs bloat history and
|
|||
|
|
conflict UX is poor for a tablet — not recommended as the default.
|
|||
|
|
|
|||
|
|
### E.2 Conflict policy
|
|||
|
|
|
|||
|
|
- **Last-write-wins per file**, using a manifest of `{ relativePath, sha256, mtime }`
|
|||
|
|
per notebook (store in the sidecar's `updatedAt` + a small per-vault
|
|||
|
|
`.badnote/sync-manifest.json`, NOT synced).
|
|||
|
|
- On pull, if remote and local both changed a file since last sync (both differ
|
|||
|
|
from the last-synced hash): **keep local, write the remote copy as
|
|||
|
|
`<file>.badnote.json.conflict-<timestamp>`** next to it, and surface a
|
|||
|
|
non-blocking notice. No silent overwrite, no merge attempt. For a single user on
|
|||
|
|
≤2 devices this is rare and acceptable.
|
|||
|
|
- The **source PDF/docx is effectively immutable** after import (annotations live in
|
|||
|
|
the sidecar), so the only file that realistically conflicts is the sidecar JSON —
|
|||
|
|
which is small and human-readable, making `.conflict` copies easy to reconcile.
|
|||
|
|
|
|||
|
|
### E.3 "Annotations follow the file" with sidecars
|
|||
|
|
|
|||
|
|
Because the sidecar sits in the same folder as the source file and shares its
|
|||
|
|
basename, any sync that moves the folder moves both together. There is **no
|
|||
|
|
database to keep in lockstep** — that is the robustness the user asked for. The
|
|||
|
|
SQLite index is rebuilt locally from the synced files, never synced.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## F. Robust auto-save
|
|||
|
|
|
|||
|
|
### F.1 Atomic sidecar write
|
|||
|
|
|
|||
|
|
Single helper, e.g. `SidecarStore.writeAtomic(File target, String json)`:
|
|||
|
|
|
|||
|
|
1. Write to `target.path + '.tmp'` with `flush: true`
|
|||
|
|
(`File.writeAsString(..., flush: true)` — same pattern `PdfService` already uses
|
|||
|
|
with `writeAsBytes(flush: true)`).
|
|||
|
|
2. `await tmp.rename(target.path)` — rename is atomic on the same filesystem on
|
|||
|
|
Windows/NTFS and POSIX, so a reader never sees a half-written sidecar.
|
|||
|
|
3. Keep a one-deep backup: before rename, if `target` exists copy it to
|
|||
|
|
`target.path + '.bak'` (cheap insurance against a corrupt write taking out the
|
|||
|
|
last good copy). On load, if the main file fails to parse, fall back to `.bak`.
|
|||
|
|
|
|||
|
|
### F.2 Debounce + scheduler (reuse existing machinery)
|
|||
|
|
|
|||
|
|
- The editors already debounce: `SaveScheduler` (800 ms, `save_scheduler.dart`) for
|
|||
|
|
the PDF editor, and `SplitViewScreen`'s own 3 s `Timer`. **Reuse this exact
|
|||
|
|
shape**, but the scheduler's sink becomes the sidecar writer instead of
|
|||
|
|
`EditorRepository.saveHost`.
|
|||
|
|
- Concretely: introduce a `SidecarRepository` with the same method surface the
|
|||
|
|
`SaveScheduler` expects, so `_schedulePageSave` /
|
|||
|
|
`PenEditorScreen._initPersistence` change only their wiring, not their control
|
|||
|
|
flow. The scheduler still captures a synchronous snapshot before the async gap
|
|||
|
|
(it already does — `save_scheduler.dart` comment).
|
|||
|
|
- The unit of debounce stays "the whole document sidecar" (write the full JSON;
|
|||
|
|
sidecars are small — strokes are sparse normalized points). One write per
|
|||
|
|
debounce window, atomic.
|
|||
|
|
- **Snapshot discipline:** capture the in-memory `_strokesByPage` /
|
|||
|
|
`_highlightsByPage` / `_scratchLinks` into a plain JSON map synchronously in
|
|||
|
|
`schedule(...)`, exactly as `SaveScheduler.schedule` captures strokes today, so a
|
|||
|
|
later edit can't corrupt an in-flight write.
|
|||
|
|
|
|||
|
|
### F.3 Flush on pause/close (robustness)
|
|||
|
|
|
|||
|
|
- `PenEditorScreen.dispose` already calls `scheduler.flush()` then `dispose()`
|
|||
|
|
(`pen_editor_screen.dart`); `SplitViewScreen.dispose` already calls
|
|||
|
|
`_saveImmediate()`. Keep both, pointing at the sidecar writer.
|
|||
|
|
- **Add an app-lifecycle flush** (today missing): register a
|
|||
|
|
`WidgetsBindingObserver` (in `BadNoteApp` or each editor) and on
|
|||
|
|
`AppLifecycleState.inactive/paused/detached` call `flush()`. On a Windows tablet,
|
|||
|
|
app suspend/close is the main data-loss window; this closes it.
|
|||
|
|
- Optionally, also flush on a short idle timer so a hard power-off loses ≤1 debounce
|
|||
|
|
window.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## G. Phased migration plan
|
|||
|
|
|
|||
|
|
Each phase is independently shippable and testable, ordered to minimize risk to the
|
|||
|
|
working editors. "Files changed" lists the primary touch points.
|
|||
|
|
|
|||
|
|
### Phase 0 — Vault root + setup gate (no data move yet)
|
|||
|
|
- Add `VaultService` (SharedPreferences-backed) + `VaultSetupScreen`.
|
|||
|
|
- `main.dart`: gate `HomeScreen` behind `vaultRootValid()`; `SettingsScreen`:
|
|||
|
|
"Change vault".
|
|||
|
|
- **No editor or DB change.** Editors still read/write SQLite. Vault path is merely
|
|||
|
|
recorded.
|
|||
|
|
- **Ship/test:** first-run prompt appears, path persists, re-prompts when folder
|
|||
|
|
missing, Windows `getDirectoryPath` works (manual + a `VaultService` unit test).
|
|||
|
|
- Files: `lib/services/vault_service.dart` (new), `lib/screens/vault_setup_screen.dart`
|
|||
|
|
(new), `lib/main.dart`, `lib/screens/settings_screen.dart`.
|
|||
|
|
|
|||
|
|
### Phase 1 — Sidecar format + atomic store + read/write library (no UI swap)
|
|||
|
|
- Define `BadnoteSidecar` model (toJson/fromJson) per §A, reusing
|
|||
|
|
`EditorStroke`/`InkStroke`/`ScratchLink`/`Bookmark` JSON.
|
|||
|
|
- `SidecarStore.writeAtomic` (§F.1) + `.bak` fallback loader.
|
|||
|
|
- Pure unit tests: round-trip a sidecar with strokes/highlights/links/scratchpads;
|
|||
|
|
atomic-write crash simulation; `.bak` recovery.
|
|||
|
|
- **No runtime behavior change yet** (library only).
|
|||
|
|
- Files: `lib/storage/badnote_sidecar.dart` (new), `lib/storage/sidecar_store.dart`
|
|||
|
|
(new), tests.
|
|||
|
|
|
|||
|
|
### Phase 2 — PDF editor reads/writes sidecar (the core swap)
|
|||
|
|
- Introduce `SidecarRepository` implementing the `SaveScheduler` sink; rewire
|
|||
|
|
`PenEditorScreen._initPersistence`, `_loadPersistedStrokes`,
|
|||
|
|
`_schedulePageSave`, `_loadScratchLinks`, `_placeScratchLink`,
|
|||
|
|
`_confirmDeleteScratchLink` to the sidecar instead of `EditorRepository` /
|
|||
|
|
`DatabaseService.*ScratchLink`. Persist highlights (closes `TODO(persist-highlights)`).
|
|||
|
|
- `SplitViewScreen` reads/writes its scratchpad from the sidecar's
|
|||
|
|
`scratchLinks[].scratchpad`.
|
|||
|
|
- The document's identity becomes its **vault path**, not the djb2 path-hash;
|
|||
|
|
`_documentIdFromPath` retired for this path.
|
|||
|
|
- **Ship/test:** import a PDF (into vault), draw, place scratch links, reopen →
|
|||
|
|
everything restored from sidecar; SQLite `ink`/`scratch_links`/`scratchpads` no
|
|||
|
|
longer written for new docs. Add a widget/integration test.
|
|||
|
|
- Files: `lib/editor/persistence/sidecar_repository.dart` (new),
|
|||
|
|
`lib/editor/canvas/pen_editor_screen.dart`, `lib/screens/split_view_screen.dart`.
|
|||
|
|
|
|||
|
|
### Phase 3 — Vault-backed import + top-level "Import file"
|
|||
|
|
- `VaultService.createNotebook(sourceFilePath)` → makes the folder, copies the file
|
|||
|
|
in, returns the vault paths.
|
|||
|
|
- Home screen: collapse `_importPdf`/`_importPptx` into one **"Import file"** action
|
|||
|
|
(sibling of "Create notebook"); multi-extension picker; route by extension.
|
|||
|
|
- Documents list now comes from a **vault scan** (folders with sidecars), not
|
|||
|
|
`documents` table; `document_provider` reads the vault (cache table optional).
|
|||
|
|
- Windows `soffice.exe` detection fix in `PptxService`; PPTX assets cached into
|
|||
|
|
`.badnote-assets/`; DOCX→PDF convert-on-import (best-effort, with the LibreOffice
|
|||
|
|
caveat surfaced to the user).
|
|||
|
|
- **Ship/test:** one Import button accepts pdf/docx/pptx; imported files land in
|
|||
|
|
vault folders with sidecars; reopening reads from the vault.
|
|||
|
|
- Files: `lib/screens/home_screen.dart`, `lib/providers/document_provider.dart`,
|
|||
|
|
`lib/services/vault_service.dart`, `lib/services/pptx_service.dart`,
|
|||
|
|
`lib/services/pdf_service.dart` (open vault copy).
|
|||
|
|
|
|||
|
|
### Phase 4 — Standalone notebooks + free notes on sidecars
|
|||
|
|
- "Create notebook" writes a `notebook.badnote.json` (§A.4) instead of `notes`/`strokes`.
|
|||
|
|
- `note_provider` reads/writes the vault; `home_screen` note tiles come from the scan.
|
|||
|
|
- Files: `lib/providers/note_provider.dart`, `lib/editor/canvas/pen_note_screen.dart`,
|
|||
|
|
`lib/screens/home_screen.dart`.
|
|||
|
|
|
|||
|
|
### Phase 5 — One-time SQLite→sidecar migration (§B)
|
|||
|
|
- Migrator runs on first launch with a valid vault and `vaultMigrationDone == false`.
|
|||
|
|
- Demote SQLite to `.badnote/index.sqlite` cache; preserve legacy DB as
|
|||
|
|
`.premigration`.
|
|||
|
|
- **Ship/test:** install over an old DB → all docs/strokes/scratchpads/bookmarks/notes
|
|||
|
|
appear in the vault; idempotent re-run; legacy DB preserved. Golden-file tests
|
|||
|
|
with a seeded legacy DB.
|
|||
|
|
- Files: `lib/storage/sqlite_to_sidecar_migrator.dart` (new),
|
|||
|
|
`lib/services/database_service.dart` (relocate DB path, expose raw read helpers),
|
|||
|
|
`lib/main.dart` (invoke migrator).
|
|||
|
|
|
|||
|
|
### Phase 6 — Lifecycle-flush hardening + index rebuild
|
|||
|
|
- `WidgetsBindingObserver` app-pause flush (§F.3) across editors.
|
|||
|
|
- `.badnote/index.sqlite` (FTS + recent list + OCR) rebuilt by scanning sidecars;
|
|||
|
|
`search_provider` queries the cache.
|
|||
|
|
- Files: `lib/main.dart` (or a shared observer), the editors, `lib/providers/search_provider.dart`.
|
|||
|
|
|
|||
|
|
### Phase 7 — Server sync (optional, last)
|
|||
|
|
- `SyncService`: WebDAV (or "vault lives in an OS-synced folder → no-op") +
|
|||
|
|
per-notebook manifest + last-write-wins `.conflict` policy (§E).
|
|||
|
|
- Settings UI to configure endpoint/credentials; manual "Sync now" + periodic.
|
|||
|
|
- Files: `lib/services/sync_service.dart` (new), `lib/screens/settings_screen.dart`.
|
|||
|
|
|
|||
|
|
### Risk-minimization rationale
|
|||
|
|
- Phases 0–1 add code without changing runtime behavior (lowest risk first).
|
|||
|
|
- Phase 2 is the one delicate swap; it is isolated to the persistence wiring of two
|
|||
|
|
screens and is backed by Phase-1 round-trip tests — the on-screen stroke models
|
|||
|
|
and painters are untouched.
|
|||
|
|
- The destructive step (Phase 5 migration) ships **after** the new format is proven
|
|||
|
|
by Phases 2–4, and never deletes the legacy DB.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Appendix: exact files inspected
|
|||
|
|
|
|||
|
|
`lib/services/database_service.dart` (schema v8; `documents`, `ink`, `scratch_links`,
|
|||
|
|
`scratchpads`, `bookmarks`, `annotations`, `notes`, `strokes`, `board_cards`, FTS),
|
|||
|
|
`lib/editor/persistence/editor_repository.dart` (`saveHost`, `loadDocument`,
|
|||
|
|
`pageHostId`, host_id scheme), `lib/editor/persistence/save_scheduler.dart`
|
|||
|
|
(800 ms debounce, synchronous snapshot, `flush`), `lib/editor/engine/stroke_model.dart`
|
|||
|
|
(`EditorStroke.toJson`, brush not serialized), `lib/models/ink_stroke.dart`,
|
|||
|
|
`lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
|
|||
|
|
`lib/models/scratch_link.dart`, `lib/models/bookmark.dart`, `lib/models/document.dart`,
|
|||
|
|
`lib/editor/canvas/pen_editor_screen.dart` (`_documentIdFromPath`,
|
|||
|
|
`_initPersistence`, `_schedulePageSave`, `_highlightsByPage` +
|
|||
|
|
`TODO(persist-highlights)`, scratch-link flow), `lib/screens/split_view_screen.dart`
|
|||
|
|
(scratchpad keyed by anchor id, absolute world pixels, 3 s autosave),
|
|||
|
|
`lib/screens/home_screen.dart` (`_importPdf`/`_importPptx`, empty-state buttons),
|
|||
|
|
`lib/services/pdf_service.dart` (`pickPdfFile`, `exportAnnotatedPdf`, `writeAsBytes(flush:true)`),
|
|||
|
|
`lib/services/pptx_service.dart` (LibreOffice headless, `which libreoffice`,
|
|||
|
|
temp-dir output), `lib/providers/document_provider.dart`,
|
|||
|
|
`lib/providers/settings_provider.dart` (SharedPreferences pattern), `lib/main.dart`
|
|||
|
|
(`DatabaseService.getInstance`, `SharedPreferences.getInstance`, `HomeScreen` home).
|