feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s

Two-way sync of the vault folder to a user-configured WebDAV server,
so annotations (which travel with the file) sync with the file.

- WebDavSyncService.syncNow: per-file decision — local-only uploads,
  remote-only downloads, and when BOTH sides changed since the last
  sync it keeps the loser as <file>.conflict-<mtime> on both sides
  (last-write-wins by mtime) so no data is ever lost. Creates dirs as
  needed; deletes are conservative.
- The decision logic is pure and unit-tested against a fake WebDAV
  client; the real client is a thin http adapter (no dio dependency).
- Settings: WebDAV URL / user / password / remote folder, Test
  connection, Sync now (with status + last-synced), and an auto-sync
  toggle (default OFF).

Real server round-trips are device/server-validated. Credentials are in
SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
This commit is contained in:
2026-06-25 01:35:13 +08:00
parent e939759458
commit 3cabc7e074
12 changed files with 2012 additions and 5 deletions

View File

@@ -0,0 +1,348 @@
// lib/services/webdav_client.dart
//
// A minimal WebDAV client abstraction for vault sync. The [WebDavClient]
// interface is intentionally tiny (the four verbs the sync algorithm needs:
// list / download / upload / mkcol) so that:
// * the sync ALGORITHM in WebDavSyncService can be unit-tested against a
// FAKE in-memory implementation (no real server), and
// * the real network adapter ([HttpWebDavClient]) stays a thin shim over
// `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL).
//
// Paths handled here are REMOTE paths relative to the configured remote root,
// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths
// to/from these remote paths lives in WebDavSyncService.
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
/// One remote resource returned by a directory listing (PROPFIND).
class RemoteEntry {
const RemoteEntry({
required this.path,
required this.isDirectory,
this.modified,
this.size,
this.etag,
});
/// Remote path RELATIVE to the configured remote root, forward-slashed and
/// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no
/// trailing slash here (normalized by the client).
final String path;
/// Whether this entry is a collection (directory) rather than a file.
final bool isDirectory;
/// Server last-modified time (UTC) if the server reported one.
final DateTime? modified;
/// Content length in bytes if reported (files only).
final int? size;
/// Weak/strong ETag if reported (quotes stripped).
final String? etag;
}
/// Thrown by [WebDavClient] implementations for any transport/protocol error.
/// Carries a human-readable [message] suitable for surfacing in the UI.
class WebDavException implements Exception {
WebDavException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => 'WebDavException($message'
'${statusCode != null ? ', status: $statusCode' : ''})';
}
/// The four WebDAV operations the sync algorithm depends on. Inject a fake in
/// tests; inject [HttpWebDavClient] in production.
abstract class WebDavClient {
/// List the immediate-and-nested files under [remoteDir] (relative to the
/// remote root, `''` meaning the root itself). Returns every FILE found in
/// the subtree (directories are created on demand via [makeCollection], so
/// callers care about files). Implementations PROPFIND with Depth: infinity
/// and flatten the result. A missing remote dir yields an empty list.
Future<List<RemoteEntry>> list(String remoteDir);
/// Download the bytes of the remote file at [remotePath].
Future<Uint8List> download(String remotePath);
/// Upload [bytes] to [remotePath], creating/overwriting the remote file.
/// Parent collections must already exist (use [makeCollection]).
Future<void> upload(String remotePath, Uint8List bytes);
/// Create the collection (directory) at [remotePath]. Idempotent: an
/// already-existing collection is not an error.
Future<void> makeCollection(String remotePath);
/// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
/// Throws [WebDavException] on failure; returns normally on success.
Future<void> testConnection();
}
/// Real WebDAV adapter over `package:http`. Thin by design — all the sync
/// decision logic lives in WebDavSyncService, NOT here.
///
/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips
/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below
/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync
/// algorithm that consumes it is what the unit tests cover, via a fake client.
class HttpWebDavClient implements WebDavClient {
HttpWebDavClient({
required String baseUrl,
required String username,
required String password,
String remoteRoot = '',
http.Client? httpClient,
this.timeout = const Duration(seconds: 30),
}) : _client = httpClient ?? http.Client(),
_ownsClient = httpClient == null,
_baseUri = _normalizeBase(baseUrl, remoteRoot),
_authHeader =
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
final http.Client _client;
final bool _ownsClient;
/// Absolute base URI INCLUDING the remote root path, always ending in `/`.
final Uri _baseUri;
final String _authHeader;
final Duration timeout;
/// Combine the server [baseUrl] with the [remoteRoot] folder into a single
/// absolute base URI ending in a slash. Tolerates trailing/leading slashes.
static Uri _normalizeBase(String baseUrl, String remoteRoot) {
var base = baseUrl.trim();
if (!base.endsWith('/')) base = '$base/';
var uri = Uri.parse(base);
final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), '');
if (root.isNotEmpty) {
uri = uri.resolve('${Uri.encodeFull(root)}/');
}
return uri;
}
/// Resolve a remote-root-relative [remotePath] to an absolute URI.
Uri _resolve(String remotePath) {
final clean = remotePath.replaceAll(RegExp(r'^/+'), '');
if (clean.isEmpty) return _baseUri;
// Encode each segment but keep the slashes.
final encoded = clean.split('/').map(Uri.encodeComponent).join('/');
return _baseUri.resolve(encoded);
}
Map<String, String> get _headers => {'Authorization': _authHeader};
@override
Future<void> testConnection() async {
final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Server responded ${res.statusCode}',
statusCode: res.statusCode,
);
}
}
@override
Future<List<RemoteEntry>> list(String remoteDir) async {
final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/');
final http.Response res;
try {
res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'});
} on WebDavException {
rethrow;
}
if (res.statusCode == 404) return const [];
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'PROPFIND failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
return _parseMultiStatus(res.body);
}
@override
Future<Uint8List> download(String remotePath) async {
final res = await _get(_resolve(remotePath));
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Download failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
return res.bodyBytes;
}
@override
Future<void> upload(String remotePath, Uint8List bytes) async {
final res = await _put(_resolve(remotePath), bytes);
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Upload failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
}
@override
Future<void> makeCollection(String remotePath) async {
final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/');
final res = await _send('MKCOL', uri);
// 201 created; 405 method-not-allowed means it already exists (fine).
if (res.statusCode == 201 || res.statusCode == 405) return;
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'MKCOL failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
}
/// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping
/// collections. Paths are made relative to [_baseUri]'s path and stripped of
/// a leading slash.
List<RemoteEntry> _parseMultiStatus(String body) {
final doc = XmlDocument.parse(body);
final basePath = _baseUri.path; // ends with '/'
final entries = <RemoteEntry>[];
for (final response in doc.findAllElements('response', namespace: '*')) {
final href = response
.findElements('href', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (href.isEmpty) continue;
// href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative
// (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath.
var hrefPath = Uri.parse(href).path;
hrefPath = Uri.decodeFull(hrefPath);
final decodedBase = Uri.decodeFull(basePath);
if (!hrefPath.startsWith(decodedBase)) {
// Some servers omit the app prefix; try a looser suffix match.
final idx = hrefPath.indexOf(decodedBase);
if (idx < 0) continue;
hrefPath = hrefPath.substring(idx);
}
var rel = hrefPath.substring(decodedBase.length);
final isDir = rel.endsWith('/');
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
if (rel.isEmpty) continue; // the root collection itself
final propstat = response.findElements('propstat', namespace: '*');
DateTime? modified;
int? size;
String? etag;
var collection = isDir;
for (final ps in propstat) {
for (final prop in ps.findElements('prop', namespace: '*')) {
final lm = prop
.findElements('getlastmodified', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (lm.isNotEmpty) modified = _parseHttpDate(lm);
final cl = prop
.findElements('getcontentlength', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (cl.isNotEmpty) size = int.tryParse(cl);
final et = prop
.findElements('getetag', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (et.isNotEmpty) etag = et.replaceAll('"', '');
if (prop.findAllElements('collection', namespace: '*').isNotEmpty) {
collection = true;
}
}
}
if (collection) continue; // sync only cares about files
entries.add(RemoteEntry(
path: rel,
isDirectory: false,
modified: modified?.toUtc(),
size: size,
etag: etag,
));
}
return entries;
}
static DateTime? _parseHttpDate(String s) {
try {
return parseHttpDate(s);
} catch (_) {
return null;
}
}
Future<http.Response> _send(
String method,
Uri uri, {
Map<String, String>? headers,
}) async {
final req = http.Request(method, uri)..headers.addAll(_headers);
if (headers != null) req.headers.addAll(headers);
try {
final streamed = await _client.send(req).timeout(timeout);
return http.Response.fromStream(streamed);
} on WebDavException {
rethrow;
} catch (e) {
throw WebDavException('Network error: $e');
}
}
Future<http.Response> _get(Uri uri) async {
try {
return await _client.get(uri, headers: _headers).timeout(timeout);
} catch (e) {
throw WebDavException('Network error: $e');
}
}
Future<http.Response> _put(Uri uri, Uint8List bytes) async {
try {
return await _client
.put(uri, headers: _headers, body: bytes)
.timeout(timeout);
} catch (e) {
throw WebDavException('Network error: $e');
}
}
/// Release the underlying [http.Client] if this instance created it.
void close() {
if (_ownsClient) _client.close();
}
}
/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather
/// than pulling `http_parser`) since only `getlastmodified` needs it.
DateTime? parseHttpDate(String input) {
final s = input.trim();
// RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT"
final months = {
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12,
};
final m = RegExp(
r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})',
).firstMatch(s);
if (m == null) return null;
final day = int.parse(m.group(1)!);
final month = months[m.group(2)!];
if (month == null) return null;
final year = int.parse(m.group(3)!);
final hour = int.parse(m.group(4)!);
final min = int.parse(m.group(5)!);
final sec = int.parse(m.group(6)!);
return DateTime.utc(year, month, day, hour, min, sec);
}

View File

@@ -0,0 +1,573 @@
// lib/services/webdav_sync_service.dart
//
// Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote.
// The vault is a flat folder of notebook subfolders, each holding a source file
// plus its `<file>.badnote.json` sidecar; sync operates on the FILES only and
// never touches editors or storage formats.
//
// DESIGN: all sync DECISION logic (per-file winner, conflict handling, path
// mapping, last-synced bookkeeping) lives here and is injected with a
// [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires
// an [HttpWebDavClient]. The real network round-trip is device/server-validated
// only (no WebDAV server in CI).
//
// ALGORITHM (per file, keyed by its vault-root-relative path):
// Let L = local state (exists? mtime), R = remote state (exists? mtime),
// and B = the per-file LAST-SYNCED baseline we stored after the previous sync
// (the mtime we last reconciled to, or absent for never-synced files).
//
// * local-only (L, !R) -> upload L (create remote dirs)
// * remote-only (!L, R):
// - known-before (B present) -> remote was DELETED by peer? We
// do NOT delete locally (conservative);
// we re-UPLOAD to restore. [see note]
// - never-seen (B absent) -> download R
// * both exist (L, R):
// - localChanged = L.mtime != B.mtime (or B absent)
// - remoteChanged = R.mtime != B.mtime (or B absent)
// - neither changed -> skip
// - only local changed -> upload L
// - only remote changed -> download R
// - BOTH changed (true conflict) -> last-write-wins by mtime:
// keep the WINNER as the canonical file, write the LOSER's bytes to
// `<file>.conflict-<winnerMtimeMillis>` on BOTH sides so NO data is
// lost, then converge canonical (upload or download as needed).
// After acting, record the converged mtime as the new baseline B.
//
// Deletes are handled CONSERVATIVELY: we never delete a file on either side as a
// result of sync. A file vanishing on one side is treated as "restore from the
// other side" rather than "propagate the delete", because we cannot distinguish
// an intentional delete from a half-finished transfer without a tombstone log
// (a deliberate TODO — see report).
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'webdav_client.dart';
/// Persisted WebDAV connection + sync configuration.
class WebDavConfig {
const WebDavConfig({
required this.baseUrl,
required this.username,
required this.password,
this.remoteRoot = '',
this.autoSync = false,
});
final String baseUrl;
final String username;
final String password;
/// Folder under the server's WebDAV root to sync into, e.g. `BadNote`.
final String remoteRoot;
/// When true, sync runs on launch/resume (non-blocking, failure-tolerant).
final bool autoSync;
/// True once enough is set to attempt a sync (URL present).
bool get isConfigured => baseUrl.trim().isNotEmpty;
WebDavConfig copyWith({
String? baseUrl,
String? username,
String? password,
String? remoteRoot,
bool? autoSync,
}) {
return WebDavConfig(
baseUrl: baseUrl ?? this.baseUrl,
username: username ?? this.username,
password: password ?? this.password,
remoteRoot: remoteRoot ?? this.remoteRoot,
autoSync: autoSync ?? this.autoSync,
);
}
}
/// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI.
class SyncResult {
const SyncResult({
this.uploaded = 0,
this.downloaded = 0,
this.conflicts = 0,
this.skipped = 0,
this.finishedAt,
this.error,
});
final int uploaded;
final int downloaded;
final int conflicts;
final int skipped;
final DateTime? finishedAt;
/// Friendly error message when the run failed wholesale; null on success.
final String? error;
bool get ok => error == null;
}
/// What to do with one file after comparing local/remote/baseline.
@visibleForTesting
enum SyncAction { skip, upload, download, conflict }
/// A single planned per-file decision (exposed for testing the algorithm).
@visibleForTesting
class SyncDecision {
const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal});
final String relPath;
final SyncAction action;
/// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the
/// remote copy is the loser kept as `.conflict-*`; false if remote won.
final bool? conflictWinnerIsLocal;
}
/// Per-file last-synced baseline. After each sync we record BOTH sides'
/// observed mtimes, because an upload makes the server stamp its OWN mtime (≠
/// the local one) — a single shared timestamp would then look "changed" on the
/// next run. Comparing each side to its own baseline avoids that false conflict.
@visibleForTesting
class SyncBaseline {
const SyncBaseline({this.localMtime, this.remoteMtime});
final DateTime? localMtime;
final DateTime? remoteMtime;
}
/// Compact local/remote snapshot of a file for the decision function.
@visibleForTesting
class FileFacts {
const FileFacts({
required this.relPath,
required this.localMtime,
required this.remoteMtime,
this.baseline,
});
final String relPath;
/// Local file mtime (UTC, whole-second), or null if the file is absent.
final DateTime? localMtime;
/// Remote file mtime (UTC, whole-second), or null if absent.
final DateTime? remoteMtime;
/// Per-side mtimes recorded after the last successful sync, or null if never.
final SyncBaseline? baseline;
}
class WebDavSyncService {
WebDavSyncService(this._prefs);
final SharedPreferences _prefs;
static const String _kBaseUrl = 'webdav.baseUrl';
static const String _kUsername = 'webdav.username';
static const String _kPassword = 'webdav.password';
static const String _kRemoteRoot = 'webdav.remoteRoot';
static const String _kAutoSync = 'webdav.autoSync';
static const String _kLastSyncMillis = 'webdav.lastSyncMillis';
/// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across
/// runs — the per-file, per-side baseline that lets us detect which side
/// changed since the last successful sync.
static const String _kBaselineJson = 'webdav.baseline';
/// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated
/// conflicts from clobbering each other.
static const String conflictMarker = '.conflict-';
// ---- Configuration (SharedPreferences-backed) -------------------------
WebDavConfig get config => WebDavConfig(
baseUrl: _prefs.getString(_kBaseUrl) ?? '',
username: _prefs.getString(_kUsername) ?? '',
password: _prefs.getString(_kPassword) ?? '',
remoteRoot: _prefs.getString(_kRemoteRoot) ?? '',
autoSync: _prefs.getBool(_kAutoSync) ?? false,
);
Future<void> saveConfig(WebDavConfig c) async {
await _prefs.setString(_kBaseUrl, c.baseUrl.trim());
await _prefs.setString(_kUsername, c.username);
await _prefs.setString(_kPassword, c.password);
await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim());
await _prefs.setBool(_kAutoSync, c.autoSync);
}
DateTime? get lastSyncTime {
final millis = _prefs.getInt(_kLastSyncMillis);
return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
}
// ---- Baseline map -----------------------------------------------------
Map<String, SyncBaseline> _readBaseline() {
final raw = _prefs.getString(_kBaselineJson);
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return decoded.map((k, v) {
final m = v as Map<String, dynamic>;
final lm = m['l'] as int?;
final rm = m['r'] as int?;
return MapEntry(
k,
SyncBaseline(
localMtime:
lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm),
remoteMtime:
rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm),
),
);
});
} catch (_) {
return {};
}
}
Future<void> _writeBaseline(Map<String, SyncBaseline> baseline) async {
final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, {
'l': v.localMtime?.millisecondsSinceEpoch,
'r': v.remoteMtime?.millisecondsSinceEpoch,
})));
await _prefs.setString(_kBaselineJson, encoded);
}
// ---- The pure decision function (unit-tested) -------------------------
/// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is
/// the heart of the algorithm and is exercised directly by the tests.
///
/// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified`
/// has 1-second resolution); callers must truncate before passing facts in.
@visibleForTesting
static SyncDecision decide(FileFacts f) {
final l = f.localMtime;
final r = f.remoteMtime;
final bl = f.baseline?.localMtime;
final br = f.baseline?.remoteMtime;
if (l != null && r == null) {
// Local-only: either brand new locally, or remote vanished. Either way we
// (re)upload — never delete the local file.
return SyncDecision(f.relPath, SyncAction.upload);
}
if (l == null && r != null) {
// Never synced OR previously known but now gone locally — in both cases we
// conservatively pull from remote rather than propagating a delete.
return SyncDecision(f.relPath, SyncAction.download);
}
if (l == null && r == null) {
return SyncDecision(f.relPath, SyncAction.skip);
}
// Both sides have the file. Compare each side to ITS OWN baseline so an
// upload-stamped remote mtime isn't mistaken for a remote edit.
final localChanged = bl == null || !_sameSecond(l!, bl);
final remoteChanged = br == null || !_sameSecond(r!, br);
if (!localChanged && !remoteChanged) {
return SyncDecision(f.relPath, SyncAction.skip);
}
if (localChanged && !remoteChanged) {
return SyncDecision(f.relPath, SyncAction.upload);
}
if (!localChanged && remoteChanged) {
return SyncDecision(f.relPath, SyncAction.download);
}
// Both changed since baseline -> true conflict. Newer mtime wins.
final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy)
return SyncDecision(
f.relPath,
SyncAction.conflict,
conflictWinnerIsLocal: localWins,
);
}
static bool _sameSecond(DateTime a, DateTime b) =>
a.toUtc().millisecondsSinceEpoch ~/ 1000 ==
b.toUtc().millisecondsSinceEpoch ~/ 1000;
// ---- Path mapping (vault <-> remote), unit-tested ---------------------
/// Map a vault-root-relative path (OS separators) to a forward-slashed remote
/// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`.
@visibleForTesting
static String toRemotePath(String relPath) =>
p.split(relPath).where((s) => s.isNotEmpty).join('/');
/// Map a forward-slashed remote path back to a vault-root-relative path using
/// OS separators.
@visibleForTesting
static String toLocalRelPath(String remotePath) =>
p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty));
/// True for files sync must IGNORE: sidecar temp/backup artifacts and our own
/// conflict copies (conflict copies stay local; they are not re-synced as if
/// canonical, but ARE uploaded as plain new files if the user keeps them).
@visibleForTesting
static bool isSyncable(String relPath) {
final name = p.basename(relPath);
if (name.startsWith('.')) return false; // hidden / .badnote metadata
if (name.endsWith('.tmp') || name.endsWith('.bak')) return false;
return true;
}
// ---- The orchestrator (I/O; device/server-validated) ------------------
/// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions
/// from [decide] drive uploads/downloads; conflicts keep the loser as a
/// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale
/// failure returns a [SyncResult] with [SyncResult.error] set (never throws).
Future<SyncResult> syncNow({
required String vaultRoot,
required WebDavClient client,
}) async {
try {
final root = Directory(vaultRoot);
if (!await root.exists()) {
return const SyncResult(error: 'Vault folder not found');
}
// 1. Snapshot both sides keyed by vault-relative path.
final localFiles = await _scanLocal(root);
final remoteList = await client.list('');
final remoteFiles = <String, RemoteEntry>{};
for (final e in remoteList) {
final rel = toLocalRelPath(e.path);
if (isSyncable(rel)) remoteFiles[rel] = e;
}
final baseline = _readBaseline();
var uploaded = 0;
var downloaded = 0;
var conflicts = 0;
var skipped = 0;
// Rel paths that ended up converged (and so deserve a fresh baseline).
final converged = <String>{};
final allPaths = <String>{...localFiles.keys, ...remoteFiles.keys};
for (final rel in allPaths) {
final localMtime = localFiles[rel];
final remoteEntry = remoteFiles[rel];
final facts = FileFacts(
relPath: rel,
localMtime: localMtime == null ? null : _truncate(localMtime),
remoteMtime: remoteEntry?.modified == null
? null
: _truncate(remoteEntry!.modified!),
baseline: baseline[rel],
);
final decision = decide(facts);
switch (decision.action) {
case SyncAction.skip:
skipped++;
converged.add(rel);
break;
case SyncAction.upload:
await _doUpload(root, client, rel);
uploaded++;
converged.add(rel);
break;
case SyncAction.download:
await _doDownload(root, client, rel, remoteEntry!.modified);
downloaded++;
converged.add(rel);
break;
case SyncAction.conflict:
await _doConflict(
root,
client,
rel,
localWins: decision.conflictWinnerIsLocal == true,
localMtime: localMtime!,
remoteMtime: remoteEntry!.modified,
);
conflicts++;
converged.add(rel);
break;
}
}
// Re-snapshot both sides so the new baseline records each side's ACTUAL
// post-sync mtime (uploads make the server stamp its own mtime). Comparing
// each side to its own baseline next time avoids a false "remote changed".
final finalLocal = await _scanLocal(root);
final finalRemote = <String, DateTime?>{};
for (final e in await client.list('')) {
final r = toLocalRelPath(e.path);
if (isSyncable(r)) finalRemote[r] = e.modified;
}
final newBaseline = <String, SyncBaseline>{};
for (final rel in converged) {
final lm = finalLocal[rel];
final rm = finalRemote[rel];
// Only keep a baseline once a file exists on BOTH sides; a one-sided
// file (mid-restore) stays "new" so the next run finishes converging it.
if (lm != null && rm != null) {
newBaseline[rel] = SyncBaseline(
localMtime: _truncate(lm),
remoteMtime: _truncate(rm),
);
}
}
await _writeBaseline(newBaseline);
final finishedAt = DateTime.now();
await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch);
return SyncResult(
uploaded: uploaded,
downloaded: downloaded,
conflicts: conflicts,
skipped: skipped,
finishedAt: finishedAt,
);
} on WebDavException catch (e) {
return SyncResult(error: e.message);
} catch (e) {
return SyncResult(error: e.toString());
}
}
/// Recursively collect every syncable file under [root], keyed by its
/// vault-root-relative path, mapped to its mtime.
Future<Map<String, DateTime>> _scanLocal(Directory root) async {
final out = <String, DateTime>{};
await for (final entity in root.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
final rel = p.relative(entity.path, from: root.path);
// Skip anything inside a hidden folder (e.g. .badnote) or hidden file.
if (p.split(rel).any((seg) => seg.startsWith('.'))) continue;
if (!isSyncable(rel)) continue;
final stat = await entity.stat();
out[rel] = stat.modified;
}
return out;
}
/// Upload the local file at [rel], creating remote parent collections.
Future<void> _doUpload(Directory root, WebDavClient client, String rel) async {
final file = File(p.join(root.path, rel));
final bytes = await file.readAsBytes();
await _ensureRemoteDirs(client, rel);
await client.upload(toRemotePath(rel), bytes);
}
/// Download the remote file at [rel] into the vault, creating local parent
/// dirs. Sets the local mtime to the remote's so the next run sees no drift.
Future<void> _doDownload(
Directory root,
WebDavClient client,
String rel,
DateTime? remoteMtime,
) async {
final bytes = await client.download(toRemotePath(rel));
final file = File(p.join(root.path, rel));
await file.parent.create(recursive: true);
await file.writeAsBytes(bytes, flush: true);
if (remoteMtime != null) {
try {
await file.setLastModified(remoteMtime);
} catch (_) {
// Some filesystems reject setLastModified; the post-sync re-snapshot
// captures whatever mtime landed, so convergence still holds.
}
}
}
/// Resolve a true conflict: keep the loser as `<file>.conflict-<winnerMillis>`
/// on BOTH sides (no data lost), then converge the canonical to the winner.
Future<void> _doConflict(
Directory root,
WebDavClient client,
String rel, {
required bool localWins,
required DateTime localMtime,
required DateTime? remoteMtime,
}) async {
final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime);
final stamp = _truncate(winnerMtime).millisecondsSinceEpoch;
final conflictRel = '$rel$conflictMarker$stamp';
final localFile = File(p.join(root.path, rel));
final remoteBytes = await client.download(toRemotePath(rel));
if (localWins) {
// Local is canonical. Save the REMOTE bytes as the local conflict copy,
// upload that conflict copy remotely too, then push local up as canonical.
final conflictFile = File(p.join(root.path, conflictRel));
await conflictFile.parent.create(recursive: true);
await conflictFile.writeAsBytes(remoteBytes, flush: true);
await _ensureRemoteDirs(client, conflictRel);
await client.upload(toRemotePath(conflictRel), remoteBytes);
final localBytes = await localFile.readAsBytes();
await _ensureRemoteDirs(client, rel);
await client.upload(toRemotePath(rel), localBytes);
} else {
// Remote is canonical. Save the LOCAL bytes as the local conflict copy
// and push it remotely, then overwrite local with the remote (winner).
final localBytes = await localFile.readAsBytes();
final conflictFile = File(p.join(root.path, conflictRel));
await conflictFile.parent.create(recursive: true);
await conflictFile.writeAsBytes(localBytes, flush: true);
await _ensureRemoteDirs(client, conflictRel);
await client.upload(toRemotePath(conflictRel), localBytes);
await localFile.writeAsBytes(remoteBytes, flush: true);
if (remoteMtime != null) {
try {
await localFile.setLastModified(remoteMtime);
} catch (_) {}
}
}
}
/// Create each remote parent collection of [rel] from the root down (MKCOL is
/// idempotent), so an upload never 409s on a missing directory.
Future<void> _ensureRemoteDirs(WebDavClient client, String rel) async {
final segments = p.split(rel).where((s) => s.isNotEmpty).toList();
if (segments.length <= 1) return; // file at root, no dirs needed
var acc = '';
for (var i = 0; i < segments.length - 1; i++) {
acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}';
await client.makeCollection(acc);
}
}
static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch(
(t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000,
isUtc: true,
);
// ---- Convenience: build a real client from the saved config -----------
/// Construct an [HttpWebDavClient] from the persisted [config], or null when
/// unconfigured (caller disables the sync button). Caller owns close().
HttpWebDavClient? buildClient() {
final c = config;
if (!c.isConfigured) return null;
return HttpWebDavClient(
baseUrl: c.baseUrl,
username: c.username,
password: c.password,
remoteRoot: c.remoteRoot,
);
}
}