574 lines
21 KiB
Dart
574 lines
21 KiB
Dart
|
|
// 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,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|