Skip to content

fix: don't let pre-restore auto-export clobber the WebDAV backup being restored - #57

Merged
mikebarkmin merged 10 commits into
mainfrom
claude/webdav-sync-evaluation-n8rtby
Aug 4, 2026
Merged

fix: don't let pre-restore auto-export clobber the WebDAV backup being restored#57
mikebarkmin merged 10 commits into
mainfrom
claude/webdav-sync-evaluation-n8rtby

Conversation

@mikebarkmin

Copy link
Copy Markdown
Member

When restoring into the currently open library with auto-export enabled,
_restoreWebDavBackupInternal ran _persistOpenDatabaseState (which triggers
an auto-export) before downloading the remote backup. That upload
overwrote the exact canonical file the restore was about to read, archiving
the real newer backup away under a timestamp and leaving the "restore"
silently re-import the device's own stale data. Skip the auto-export step
when the restore destination is the currently open library.

claude added 10 commits August 3, 2026 18:49
…g restored

When restoring into the currently open library with auto-export enabled,
_restoreWebDavBackupInternal ran _persistOpenDatabaseState (which triggers
an auto-export) before downloading the remote backup. That upload
overwrote the exact canonical file the restore was about to read, archiving
the real newer backup away under a timestamp and leaving the "restore"
silently re-import the device's own stale data. Skip the auto-export step
when the restore destination is the currently open library.
Add a DeviceIdentityService that generates a stable per-install device
id and lets the user set a friendly device name (defaulting to a
generic, non-identifying platform label rather than the OS hostname).

Every export now embeds deviceId/deviceName in the backup manifest and
uploads a small .meta.json sidecar next to the backup (kept in sync
through archive/rename/prune), so the device that produced a backup
can be shown without downloading the whole archive. Surfaced in the
WebDAV restore picker, the settings backup list, and the auto-import
prompt ("Backup exported from Kitchen iPad on ..."), plus a new
"Device name" field in WebDAV settings.
Two devices exporting at the same moment can interleave the
upload/archive/rename/prune steps in exportBackupToWebDav and corrupt
the backup version history. Add a lock file
(<serverPath>/.classi-sync.lock, holding {deviceId, acquiredAt} JSON)
that exportBackupToWebDav acquires before touching any backup files
and releases in a finally block.

WebDAV servers vary in support for native LOCK/UNLOCK (RFC 4918), so
this avoids that entirely and uses a plain file any WebDAV server can
store: write our claim, wait a short random jitter, then read back to
confirm we're still the recorded owner. It's advisory, not a true
compare-and-swap, but catches the common case of two devices syncing
within moments of each other. A lease expires an abandoned lock left
by a crashed app rather than wedging the folder permanently.

Callers get a distinct WebDavSyncBusyException (surfaced as
'backup_export_busy') instead of a generic export failure when
another device currently holds the lock.
Add revision tracking so exportBackupToWebDav can tell whether the
remote backup moved on since this device last synced. Each export
embeds a fresh revision token (plus the parentRevision it was based
on) in the manifest and .meta.json sidecar. AppSessionController
persists the last known revision per library (new
LibraryBackupPreferencesService.lastKnownRevision) and passes it as
parentRevision on every export, updating it after a successful export
or restore.

If the remote's current revision doesn't match parentRevision, another
device pushed a change this device never saw. Rather than clobbering
it, the export is written as a separate `_CONFLICT_<timestamp>` copy
and a WebDavSyncConflictException is thrown; the canonical backup is
left untouched. AppSessionController surfaces this as a distinct
'backup_export_conflict' message pointing the user at the backup list
to reconcile manually, rather than a generic export failure.

A remote backup with no revision at all (nothing uploaded yet, or a
legacy backup predating revision tracking) is not treated as a
conflict, so this doesn't block export against pre-existing backups.
…atus button

The background/lock-triggered auto-export isn't reliable on every
platform: Android can suspend the process shortly after it's
backgrounded, cutting off the in-flight export (checkpoint, sync lock,
zip, upload, archive, prune) before it finishes, while desktop
platforms keep running normally when unfocused. Add a periodic timer
(default 10 min, injectable via AppSessionController's new
periodicExportInterval param) that opportunistically re-exports while
the app is open and unlocked, independent of backgrounding, so the
same code path works identically on every platform and the backup is
never more than one interval stale even when the background trigger
gets killed. The timer starts/stops alongside WebDAV auto-export
eligibility (configured + enabled) and the session's ready/not-ready
transitions.

Also add a persistent backup status indicator to the main app shell
(NavigationRail trailing slot on desktop, a strip above the bottom
NavigationBar on mobile) showing at a glance whether the backup is
current, syncing, behind, or failed (busy/conflict/generic), with a
tap-to-export-now action reusing the existing exportNow() flow. Only
shown once WebDAV auto-export is configured and enabled, matching the
existing Settings screen's "Export now" gating.
Applied the analyzer's own suggested fix for use_null_aware_elements
in the new WebDAV device/revision manifest code, replacing
if (x != null) 'key': x with 'key': ?x. No behavior change.
…table scrolling

Wrap timeframe grades, student detail, and student summary screens in
ContentConstraints so their content width matches the rest of the app
instead of stretching full-bleed. Convert the group detail screen's
timeframe and session DataTables to ellipsis-safe Row/Expanded layouts
so tables never scroll horizontally, and add defensive maxLines/ellipsis
to labels and table headers that could otherwise wrap to a second line.

Also fix a ChangeNotifier-used-after-dispose crash in the periodic
export timer: an in-flight export tick could call notifyListeners()
after the session controller was disposed.
Add isExpanded: true to DropdownButtonFormFields whose selected-item
text can exceed the field width (grade scale, list scope, grade
category, inactivity timeout) so the dropdown properly constrains to
the available width instead of overflowing its RenderFlex. Also add
maxLines/ellipsis to the underlying item labels so long group,
category, or list names truncate instead of wrapping or overflowing.
Delete grade_entry_screen.dart and group_tracking_screen.dart, both
unreachable from any route and unreferenced anywhere else in the app.

Fix the failing lesson_repository_test: watchGroupEntryCategories
reads from sessions_table, but the test only ever wrote to
grade_entries_table via GradeRepository.saveEntry, so the query
returned nothing. The real app creates the corresponding session row
via SessionRepository.upsertSession before entering grades (see
lesson_mode_screen.dart); the test now does the same.
Replace (_, __) error-callback parameters with (_, _) using Dart's
wildcard pattern support, matching the codebase's lint rules.
@mikebarkmin
mikebarkmin merged commit cd5c049 into main Aug 4, 2026
1 check passed
@mikebarkmin
mikebarkmin deleted the claude/webdav-sync-evaluation-n8rtby branch August 4, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants