feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s
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:
@@ -150,5 +150,42 @@
|
|||||||
"vaultFolderLabel": "Vault folder",
|
"vaultFolderLabel": "Vault folder",
|
||||||
"vaultNoneSelected": "No folder selected",
|
"vaultNoneSelected": "No folder selected",
|
||||||
"vaultChangeFolder": "Change vault folder",
|
"vaultChangeFolder": "Change vault folder",
|
||||||
"vaultUpdated": "Vault folder updated"
|
"vaultUpdated": "Vault folder updated",
|
||||||
|
"syncSection": "Sync (WebDAV)",
|
||||||
|
"syncServerUrl": "Server URL",
|
||||||
|
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
|
||||||
|
"syncUsername": "Username",
|
||||||
|
"syncPassword": "Password",
|
||||||
|
"syncRemoteFolder": "Remote folder",
|
||||||
|
"syncRemoteFolderHint": "BadNote",
|
||||||
|
"syncSave": "Save",
|
||||||
|
"syncSaved": "Sync settings saved",
|
||||||
|
"syncTestConnection": "Test connection",
|
||||||
|
"syncTestOk": "Connection OK",
|
||||||
|
"syncTestFailed": "Connection failed: {error}",
|
||||||
|
"@syncTestFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncNow": "Sync now",
|
||||||
|
"syncRunning": "Syncing…",
|
||||||
|
"syncNeverRun": "Never synced",
|
||||||
|
"syncLastRun": "Last synced: {when}",
|
||||||
|
"@syncLastRun": {
|
||||||
|
"placeholders": { "when": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncResultSummary": "{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts",
|
||||||
|
"@syncResultSummary": {
|
||||||
|
"placeholders": {
|
||||||
|
"uploaded": { "type": "int" },
|
||||||
|
"downloaded": { "type": "int" },
|
||||||
|
"conflicts": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncFailed": "Sync failed: {error}",
|
||||||
|
"@syncFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncAuto": "Sync automatically on launch",
|
||||||
|
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
|
||||||
|
"syncNotConfigured": "Enter a server URL to enable sync."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -787,6 +787,132 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Vault folder updated'**
|
/// **'Vault folder updated'**
|
||||||
String get vaultUpdated;
|
String get vaultUpdated;
|
||||||
|
|
||||||
|
/// No description provided for @syncSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync (WebDAV)'**
|
||||||
|
String get syncSection;
|
||||||
|
|
||||||
|
/// No description provided for @syncServerUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Server URL'**
|
||||||
|
String get syncServerUrl;
|
||||||
|
|
||||||
|
/// No description provided for @syncServerUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://dav.example.com/remote.php/dav/files/me'**
|
||||||
|
String get syncServerUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @syncUsername.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Username'**
|
||||||
|
String get syncUsername;
|
||||||
|
|
||||||
|
/// No description provided for @syncPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Password'**
|
||||||
|
String get syncPassword;
|
||||||
|
|
||||||
|
/// No description provided for @syncRemoteFolder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remote folder'**
|
||||||
|
String get syncRemoteFolder;
|
||||||
|
|
||||||
|
/// No description provided for @syncRemoteFolderHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BadNote'**
|
||||||
|
String get syncRemoteFolderHint;
|
||||||
|
|
||||||
|
/// No description provided for @syncSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get syncSave;
|
||||||
|
|
||||||
|
/// No description provided for @syncSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync settings saved'**
|
||||||
|
String get syncSaved;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestConnection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test connection'**
|
||||||
|
String get syncTestConnection;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestOk.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection OK'**
|
||||||
|
String get syncTestOk;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection failed: {error}'**
|
||||||
|
String syncTestFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @syncNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync now'**
|
||||||
|
String get syncNow;
|
||||||
|
|
||||||
|
/// No description provided for @syncRunning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Syncing…'**
|
||||||
|
String get syncRunning;
|
||||||
|
|
||||||
|
/// No description provided for @syncNeverRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Never synced'**
|
||||||
|
String get syncNeverRun;
|
||||||
|
|
||||||
|
/// No description provided for @syncLastRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last synced: {when}'**
|
||||||
|
String syncLastRun(String when);
|
||||||
|
|
||||||
|
/// No description provided for @syncResultSummary.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts'**
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts);
|
||||||
|
|
||||||
|
/// No description provided for @syncFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync failed: {error}'**
|
||||||
|
String syncFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @syncAuto.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync automatically on launch'**
|
||||||
|
String get syncAuto;
|
||||||
|
|
||||||
|
/// No description provided for @syncCredentialsNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Credentials are stored locally in plain text. Use a dedicated app password.'**
|
||||||
|
String get syncCredentialsNote;
|
||||||
|
|
||||||
|
/// No description provided for @syncNotConfigured.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter a server URL to enable sync.'**
|
||||||
|
String get syncNotConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -379,4 +379,77 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get vaultUpdated => 'Vault folder updated';
|
String get vaultUpdated => 'Vault folder updated';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSection => 'Sync (WebDAV)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrl => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrlHint =>
|
||||||
|
'https://dav.example.com/remote.php/dav/files/me';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncUsername => 'Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolder => 'Remote folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolderHint => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSave => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSaved => 'Sync settings saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestConnection => 'Test connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestOk => 'Connection OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncTestFailed(String error) {
|
||||||
|
return 'Connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => 'Sync now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRunning => 'Syncing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNeverRun => 'Never synced';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastRun(String when) {
|
||||||
|
return 'Last synced: $when';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
|
||||||
|
return '$uploaded uploaded · $downloaded downloaded · $conflicts conflicts';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncFailed(String error) {
|
||||||
|
return 'Sync failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncAuto => 'Sync automatically on launch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncCredentialsNote =>
|
||||||
|
'Credentials are stored locally in plain text. Use a dedicated app password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNotConfigured => 'Enter a server URL to enable sync.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,4 +376,76 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get vaultUpdated => '笔记库文件夹已更新';
|
String get vaultUpdated => '笔记库文件夹已更新';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSection => '同步(WebDAV)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrl => '服务器地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrlHint =>
|
||||||
|
'https://dav.example.com/remote.php/dav/files/me';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncUsername => '用户名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncPassword => '密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolder => '远程文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolderHint => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSave => '保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSaved => '同步设置已保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestConnection => '测试连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestOk => '连接成功';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncTestFailed(String error) {
|
||||||
|
return '连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => '立即同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRunning => '同步中…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNeverRun => '尚未同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastRun(String when) {
|
||||||
|
return '上次同步:$when';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
|
||||||
|
return '上传 $uploaded · 下载 $downloaded · 冲突 $conflicts';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncFailed(String error) {
|
||||||
|
return '同步失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncAuto => '启动时自动同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncCredentialsNote => '凭据以明文保存在本地,建议使用专用的应用密码。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNotConfigured => '请输入服务器地址以启用同步。';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,5 +120,42 @@
|
|||||||
"vaultFolderLabel": "笔记库文件夹",
|
"vaultFolderLabel": "笔记库文件夹",
|
||||||
"vaultNoneSelected": "尚未选择文件夹",
|
"vaultNoneSelected": "尚未选择文件夹",
|
||||||
"vaultChangeFolder": "更改笔记库文件夹",
|
"vaultChangeFolder": "更改笔记库文件夹",
|
||||||
"vaultUpdated": "笔记库文件夹已更新"
|
"vaultUpdated": "笔记库文件夹已更新",
|
||||||
|
"syncSection": "同步(WebDAV)",
|
||||||
|
"syncServerUrl": "服务器地址",
|
||||||
|
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
|
||||||
|
"syncUsername": "用户名",
|
||||||
|
"syncPassword": "密码",
|
||||||
|
"syncRemoteFolder": "远程文件夹",
|
||||||
|
"syncRemoteFolderHint": "BadNote",
|
||||||
|
"syncSave": "保存",
|
||||||
|
"syncSaved": "同步设置已保存",
|
||||||
|
"syncTestConnection": "测试连接",
|
||||||
|
"syncTestOk": "连接成功",
|
||||||
|
"syncTestFailed": "连接失败:{error}",
|
||||||
|
"@syncTestFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncNow": "立即同步",
|
||||||
|
"syncRunning": "同步中…",
|
||||||
|
"syncNeverRun": "尚未同步",
|
||||||
|
"syncLastRun": "上次同步:{when}",
|
||||||
|
"@syncLastRun": {
|
||||||
|
"placeholders": { "when": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}",
|
||||||
|
"@syncResultSummary": {
|
||||||
|
"placeholders": {
|
||||||
|
"uploaded": { "type": "int" },
|
||||||
|
"downloaded": { "type": "int" },
|
||||||
|
"conflicts": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncFailed": "同步失败:{error}",
|
||||||
|
"@syncFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncAuto": "启动时自动同步",
|
||||||
|
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
|
||||||
|
"syncNotConfigured": "请输入服务器地址以启用同步。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'screens/home_screen.dart';
|
|||||||
import 'screens/vault_setup_screen.dart';
|
import 'screens/vault_setup_screen.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
import 'services/vault_service.dart';
|
import 'services/vault_service.dart';
|
||||||
|
import 'services/webdav_sync_service.dart';
|
||||||
import 'storage/sqlite_to_sidecar_migrator.dart';
|
import 'storage/sqlite_to_sidecar_migrator.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -141,7 +142,34 @@ class _VaultGateState extends State<VaultGate> {
|
|||||||
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
|
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
if (valid) await _maybeMigrate(vault);
|
if (valid) {
|
||||||
|
await _maybeMigrate(vault);
|
||||||
|
_maybeAutoSync(vault); // fire-and-forget; never blocks the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optionally kick off a WebDAV sync on launch when the user has enabled
|
||||||
|
/// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a
|
||||||
|
/// bad config or offline server must never delay or crash startup. Results
|
||||||
|
/// are surfaced in Settings (last-synced time) rather than interrupting here.
|
||||||
|
Future<void> _maybeAutoSync(VaultService vault) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final sync = WebDavSyncService(prefs);
|
||||||
|
final config = sync.config;
|
||||||
|
if (!config.autoSync || !config.isConfigured) return;
|
||||||
|
final root = vault.vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return;
|
||||||
|
final client = sync.buildClient();
|
||||||
|
if (client == null) return;
|
||||||
|
try {
|
||||||
|
await sync.syncNow(vaultRoot: root, client: client);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Auto-sync is best-effort; swallow everything so launch is unaffected.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
|
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import '../models/pressure_curve.dart';
|
import '../models/pressure_curve.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/vault_service.dart';
|
import '../services/vault_service.dart';
|
||||||
|
import '../services/webdav_sync_service.dart';
|
||||||
import '../utils/stroke_stabilizer.dart';
|
import '../utils/stroke_stabilizer.dart';
|
||||||
|
|
||||||
/// Material 3 settings screen for BadNote.
|
/// Material 3 settings screen for BadNote.
|
||||||
@@ -290,6 +292,15 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
child: _VaultSettings(),
|
child: _VaultSettings(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).syncSection,
|
||||||
|
icon: Icons.cloud_sync,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: _SyncSettings(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
_SectionHeader(title: 'About', icon: Icons.info),
|
_SectionHeader(title: 'About', icon: Icons.info),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -415,6 +426,273 @@ class _VaultSettingsState extends State<_VaultSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WebDAV sync configuration + actions. Persists config through
|
||||||
|
/// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection /
|
||||||
|
/// Sync now buttons, the last-synced time and last result, and an
|
||||||
|
/// "auto-sync on launch" toggle (default OFF). All network ops are
|
||||||
|
/// time-bounded inside the service and surface friendly errors here.
|
||||||
|
class _SyncSettings extends StatefulWidget {
|
||||||
|
const _SyncSettings();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SyncSettings> createState() => _SyncSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncSettingsState extends State<_SyncSettings> {
|
||||||
|
WebDavSyncService? _sync;
|
||||||
|
final _urlCtrl = TextEditingController();
|
||||||
|
final _userCtrl = TextEditingController();
|
||||||
|
final _passCtrl = TextEditingController();
|
||||||
|
final _folderCtrl = TextEditingController();
|
||||||
|
bool _autoSync = false;
|
||||||
|
bool _busy = false;
|
||||||
|
bool _testing = false;
|
||||||
|
bool _obscure = true;
|
||||||
|
DateTime? _lastSync;
|
||||||
|
SyncResult? _lastResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlCtrl.dispose();
|
||||||
|
_userCtrl.dispose();
|
||||||
|
_passCtrl.dispose();
|
||||||
|
_folderCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final sync = WebDavSyncService(prefs);
|
||||||
|
if (!mounted) return;
|
||||||
|
final c = sync.config;
|
||||||
|
setState(() {
|
||||||
|
_sync = sync;
|
||||||
|
_urlCtrl.text = c.baseUrl;
|
||||||
|
_userCtrl.text = c.username;
|
||||||
|
_passCtrl.text = c.password;
|
||||||
|
_folderCtrl.text = c.remoteRoot;
|
||||||
|
_autoSync = c.autoSync;
|
||||||
|
_lastSync = sync.lastSyncTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WebDavConfig _currentConfig() => WebDavConfig(
|
||||||
|
baseUrl: _urlCtrl.text,
|
||||||
|
username: _userCtrl.text,
|
||||||
|
password: _passCtrl.text,
|
||||||
|
remoteRoot: _folderCtrl.text,
|
||||||
|
autoSync: _autoSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncSaved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testConnection() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
setState(() => _testing = true);
|
||||||
|
final client = sync.buildClient();
|
||||||
|
try {
|
||||||
|
if (client == null) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.testConnection();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk)));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger
|
||||||
|
.showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString()))));
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
if (mounted) setState(() => _testing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNow() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
final vault = await VaultService.getInstance();
|
||||||
|
final root = vault.vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final client = sync.buildClient();
|
||||||
|
try {
|
||||||
|
if (client == null) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await sync.syncNow(vaultRoot: root, client: client);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_lastResult = result;
|
||||||
|
_lastSync = sync.lastSyncTime;
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(result.ok
|
||||||
|
? l.syncResultSummary(
|
||||||
|
result.uploaded, result.downloaded, result.conflicts)
|
||||||
|
: l.syncFailed(result.error ?? '')),
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final configured = _urlCtrl.text.trim().isNotEmpty;
|
||||||
|
final lastResult = _lastResult;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _urlCtrl,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncServerUrl,
|
||||||
|
hintText: l.syncServerUrlHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncUsername,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _passCtrl,
|
||||||
|
obscureText: _obscure,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncPassword,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
|
||||||
|
onPressed: () => setState(() => _obscure = !_obscure),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _folderCtrl,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncRemoteFolder,
|
||||||
|
hintText: l.syncRemoteFolderHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l.syncCredentialsNote,
|
||||||
|
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l.syncAuto),
|
||||||
|
value: _autoSync,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _autoSync = v);
|
||||||
|
_save();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: (_busy || _testing) ? null : _save,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: Text(l.syncSave),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: (!configured || _busy || _testing)
|
||||||
|
? null
|
||||||
|
: _testConnection,
|
||||||
|
icon: _testing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.wifi_tethering),
|
||||||
|
label: Text(l.syncTestConnection),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: (!configured || _busy || _testing) ? null : _syncNow,
|
||||||
|
icon: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
label: Text(_busy ? l.syncRunning : l.syncNow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_lastSync == null
|
||||||
|
? l.syncNeverRun
|
||||||
|
: l.syncLastRun(_lastSync!.toLocal().toString()),
|
||||||
|
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
if (lastResult != null && lastResult.ok) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l.syncResultSummary(lastResult.uploaded, lastResult.downloaded,
|
||||||
|
lastResult.conflicts),
|
||||||
|
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
348
lib/services/webdav_client.dart
Normal file
348
lib/services/webdav_client.dart
Normal 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);
|
||||||
|
}
|
||||||
573
lib/services/webdav_sync_service.dart
Normal file
573
lib/services/webdav_sync_service.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -448,7 +448,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -1370,7 +1370,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ dependencies:
|
|||||||
# Settings persistence
|
# Settings persistence
|
||||||
shared_preferences: ^2.3.0
|
shared_preferences: ^2.3.0
|
||||||
|
|
||||||
|
# WebDAV sync: a minimal client (PROPFIND/GET/PUT/MKCOL) is hand-rolled over
|
||||||
|
# `package:http` (HttpWebDavClient) — both were already transitive deps. We
|
||||||
|
# avoid the `webdav_client` package because it drags in `dio`; `http` works on
|
||||||
|
# Windows and keeps the real adapter thin so the sync algorithm stays testable
|
||||||
|
# against a fake client.
|
||||||
|
http: ^1.2.0
|
||||||
|
xml: ^6.5.0
|
||||||
|
|
||||||
# Cross-platform file picker
|
# Cross-platform file picker
|
||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
|
|
||||||
|
|||||||
427
test/webdav_sync_service_test.dart
Normal file
427
test/webdav_sync_service_test.dart
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
// Proves the WebDAV sync ALGORITHM against a FAKE in-memory WebDavClient (no
|
||||||
|
// real server — the HttpWebDavClient adapter is device/server-validated only).
|
||||||
|
//
|
||||||
|
// Covers: the pure per-file decision (decide), vault<->remote path mapping, and
|
||||||
|
// the full syncNow orchestration — newer-local→upload, newer-remote→download,
|
||||||
|
// both-changed→.conflict kept (no data lost), new-local→created remotely.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/services/webdav_client.dart';
|
||||||
|
import 'package:badnote/services/webdav_sync_service.dart';
|
||||||
|
|
||||||
|
/// In-memory fake WebDAV server. Stores bytes + mtimes by remote path
|
||||||
|
/// (forward-slashed, relative to the remote root). Records uploads so tests can
|
||||||
|
/// assert what was pushed.
|
||||||
|
class FakeWebDavClient implements WebDavClient {
|
||||||
|
final Map<String, Uint8List> files = {};
|
||||||
|
final Map<String, DateTime> mtimes = {};
|
||||||
|
final Set<String> collections = {};
|
||||||
|
final List<String> uploads = [];
|
||||||
|
bool testConnectionThrows = false;
|
||||||
|
|
||||||
|
/// Seed a remote file with bytes + an mtime.
|
||||||
|
void seed(String remotePath, String content, DateTime mtime) {
|
||||||
|
files[remotePath] = Uint8List.fromList(utf8.encode(content));
|
||||||
|
mtimes[remotePath] = mtime.toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RemoteEntry>> list(String remoteDir) async {
|
||||||
|
return files.keys
|
||||||
|
.map((path) => RemoteEntry(
|
||||||
|
path: path,
|
||||||
|
isDirectory: false,
|
||||||
|
modified: mtimes[path],
|
||||||
|
size: files[path]!.length,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> download(String remotePath) async {
|
||||||
|
final bytes = files[remotePath];
|
||||||
|
if (bytes == null) {
|
||||||
|
throw WebDavException('not found: $remotePath', statusCode: 404);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upload(String remotePath, Uint8List bytes) async {
|
||||||
|
files[remotePath] = bytes;
|
||||||
|
mtimes[remotePath] = DateTime.now().toUtc();
|
||||||
|
uploads.add(remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> makeCollection(String remotePath) async {
|
||||||
|
collections.add(remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> testConnection() async {
|
||||||
|
if (testConnectionThrows) throw WebDavException('boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? contentOf(String remotePath) {
|
||||||
|
final b = files[remotePath];
|
||||||
|
return b == null ? null : utf8.decode(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _utc(int y, int mo, int d, [int h = 0, int mi = 0, int s = 0]) =>
|
||||||
|
DateTime.utc(y, mo, d, h, mi, s);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
late Directory vaultDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
vaultDir = await Directory.systemTemp.createTemp('webdav_sync_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (await vaultDir.exists()) await vaultDir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<WebDavSyncService> makeService() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return WebDavSyncService(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> writeLocal(String rel, String content, DateTime mtime) async {
|
||||||
|
final f = File(p.join(vaultDir.path, rel));
|
||||||
|
await f.parent.create(recursive: true);
|
||||||
|
await f.writeAsString(content);
|
||||||
|
await f.setLastModified(mtime);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readLocal(String rel) =>
|
||||||
|
File(p.join(vaultDir.path, rel)).readAsStringSync();
|
||||||
|
|
||||||
|
List<String> localConflictCopies(String rel) {
|
||||||
|
final folder = Directory(p.join(vaultDir.path, p.dirname(rel)));
|
||||||
|
final base = p.basename(rel);
|
||||||
|
return folder
|
||||||
|
.listSync()
|
||||||
|
.whereType<File>()
|
||||||
|
.map((f) => p.basename(f.path))
|
||||||
|
.where((n) => n.startsWith('$base${WebDavSyncService.conflictMarker}'))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
group('decide (pure per-file decision)', () {
|
||||||
|
SyncBaseline both(DateTime l, DateTime r) =>
|
||||||
|
SyncBaseline(localMtime: l, remoteMtime: r);
|
||||||
|
|
||||||
|
test('local-only → upload', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: _utc(2026, 1, 1),
|
||||||
|
remoteMtime: null,
|
||||||
|
baseline: null,
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.upload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-only never-seen → download', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: null,
|
||||||
|
remoteMtime: _utc(2026, 1, 1),
|
||||||
|
baseline: null,
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.download);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-only but known-before → download (restore, never delete)', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: null,
|
||||||
|
remoteMtime: _utc(2026, 1, 2),
|
||||||
|
baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.download);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both unchanged since baseline → skip', () {
|
||||||
|
final t = _utc(2026, 1, 1, 10);
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: t,
|
||||||
|
remoteMtime: t,
|
||||||
|
baseline: both(t, t),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged with DIFFERENT but-baselined mtimes per side → skip', () {
|
||||||
|
// After an upload the server stamps its own mtime; comparing each side to
|
||||||
|
// ITS baseline must still read as "no change".
|
||||||
|
final localT = _utc(2026, 1, 1, 10);
|
||||||
|
final remoteT = _utc(2026, 1, 1, 10, 0, 5); // server clock differs
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: localT,
|
||||||
|
remoteMtime: remoteT,
|
||||||
|
baseline: both(localT, remoteT),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only local changed → upload', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: _utc(2026, 1, 2),
|
||||||
|
remoteMtime: _utc(2026, 1, 1),
|
||||||
|
baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.upload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only remote changed → download', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: _utc(2026, 1, 1),
|
||||||
|
remoteMtime: _utc(2026, 1, 2),
|
||||||
|
baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.download);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both changed → conflict, newer wins (local newer)', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: _utc(2026, 1, 3),
|
||||||
|
remoteMtime: _utc(2026, 1, 2),
|
||||||
|
baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.conflict);
|
||||||
|
expect(d.conflictWinnerIsLocal, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both changed → conflict, newer wins (remote newer)', () {
|
||||||
|
final d = WebDavSyncService.decide(FileFacts(
|
||||||
|
relPath: 'a/x.pdf',
|
||||||
|
localMtime: _utc(2026, 1, 2),
|
||||||
|
remoteMtime: _utc(2026, 1, 3),
|
||||||
|
baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
|
||||||
|
));
|
||||||
|
expect(d.action, SyncAction.conflict);
|
||||||
|
expect(d.conflictWinnerIsLocal, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('path mapping', () {
|
||||||
|
test('toRemotePath forward-slashes the relative path', () {
|
||||||
|
final rel = p.join('Lecture', 'Lecture.pdf');
|
||||||
|
expect(WebDavSyncService.toRemotePath(rel), 'Lecture/Lecture.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toLocalRelPath round-trips with OS separators', () {
|
||||||
|
const remote = 'Lecture/Lecture.pdf';
|
||||||
|
final local = WebDavSyncService.toLocalRelPath(remote);
|
||||||
|
expect(local, p.join('Lecture', 'Lecture.pdf'));
|
||||||
|
expect(WebDavSyncService.toRemotePath(local), remote);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSyncable filters tmp/bak/hidden', () {
|
||||||
|
expect(WebDavSyncService.isSyncable('a/x.pdf'), isTrue);
|
||||||
|
expect(WebDavSyncService.isSyncable('a/x.pdf.badnote.json'), isTrue);
|
||||||
|
expect(WebDavSyncService.isSyncable('a/x.pdf.tmp'), isFalse);
|
||||||
|
expect(WebDavSyncService.isSyncable('a/x.pdf.bak'), isFalse);
|
||||||
|
expect(WebDavSyncService.isSyncable('.hidden'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('syncNow orchestration (fake client)', () {
|
||||||
|
test('new-local → created remotely (upload + MKCOL)', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('Lecture/Lecture.pdf', 'pdf-bytes', _utc(2026, 1, 1));
|
||||||
|
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.ok, isTrue);
|
||||||
|
expect(r.uploaded, 1);
|
||||||
|
expect(r.downloaded, 0);
|
||||||
|
expect(r.conflicts, 0);
|
||||||
|
expect(fake.contentOf('Lecture/Lecture.pdf'), 'pdf-bytes');
|
||||||
|
// Parent collection was created before the upload.
|
||||||
|
expect(fake.collections, contains('Lecture'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote-only → downloaded into the vault', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
fake.seed('Deck/Deck.pptx', 'remote-deck', _utc(2026, 2, 1));
|
||||||
|
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.downloaded, 1);
|
||||||
|
expect(readLocal(p.join('Deck', 'Deck.pptx')), 'remote-deck');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newer-local → upload overwrites remote', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
// First sync establishes a shared baseline.
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
expect(fake.contentOf('N/n.json'), 'v1');
|
||||||
|
|
||||||
|
// Local edited (newer mtime), remote untouched → upload.
|
||||||
|
await writeLocal('N/n.json', 'v2-local', _utc(2026, 1, 5));
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.uploaded, 1);
|
||||||
|
expect(r.conflicts, 0);
|
||||||
|
expect(fake.contentOf('N/n.json'), 'v2-local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newer-remote → download overwrites local', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
// Remote edited (newer), local untouched → download.
|
||||||
|
fake.seed('N/n.json', 'v2-remote', _utc(2026, 1, 6));
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.downloaded, 1);
|
||||||
|
expect(r.conflicts, 0);
|
||||||
|
expect(readLocal(p.join('N', 'n.json')), 'v2-remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both-changed → .conflict kept, no data lost (local wins)', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
// BOTH sides change after the baseline; local is NEWER → local wins.
|
||||||
|
await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 10));
|
||||||
|
fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 8));
|
||||||
|
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.conflicts, 1);
|
||||||
|
// Canonical is the winner (local) on BOTH sides.
|
||||||
|
expect(readLocal(p.join('N', 'n.json')), 'local-edit');
|
||||||
|
expect(fake.contentOf('N/n.json'), 'local-edit');
|
||||||
|
|
||||||
|
// The LOSER (remote bytes) is preserved as a local .conflict-* copy …
|
||||||
|
final copies = localConflictCopies(p.join('N', 'n.json'));
|
||||||
|
expect(copies, hasLength(1));
|
||||||
|
expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(),
|
||||||
|
'remote-edit');
|
||||||
|
// … and pushed remotely too, so neither side loses the loser.
|
||||||
|
final remoteConflict = fake.files.keys.firstWhere(
|
||||||
|
(k) => k.contains(WebDavSyncService.conflictMarker),
|
||||||
|
orElse: () => '',
|
||||||
|
);
|
||||||
|
expect(remoteConflict, isNotEmpty);
|
||||||
|
expect(fake.contentOf(remoteConflict), 'remote-edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both-changed → .conflict kept, no data lost (remote wins)', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
// BOTH change; remote is NEWER → remote wins.
|
||||||
|
await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 8));
|
||||||
|
fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 12));
|
||||||
|
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(r.conflicts, 1);
|
||||||
|
// Canonical is the remote winner on both sides.
|
||||||
|
expect(readLocal(p.join('N', 'n.json')), 'remote-edit');
|
||||||
|
expect(fake.contentOf('N/n.json'), 'remote-edit');
|
||||||
|
|
||||||
|
// The LOSER (local bytes) is preserved as a .conflict-* copy locally.
|
||||||
|
final copies = localConflictCopies(p.join('N', 'n.json'));
|
||||||
|
expect(copies, hasLength(1));
|
||||||
|
expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(),
|
||||||
|
'local-edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged on both sides → skip, nothing re-uploaded', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
fake.uploads.clear();
|
||||||
|
|
||||||
|
final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
expect(r.skipped, greaterThanOrEqualTo(1));
|
||||||
|
expect(r.uploaded, 0);
|
||||||
|
expect(r.downloaded, 0);
|
||||||
|
expect(fake.uploads, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing vault folder → friendly error, no throw', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
final r = await sync.syncNow(
|
||||||
|
vaultRoot: p.join(vaultDir.path, 'does-not-exist'),
|
||||||
|
client: fake,
|
||||||
|
);
|
||||||
|
expect(r.ok, isFalse);
|
||||||
|
expect(r.error, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tmp/bak artifacts are not uploaded', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
final fake = FakeWebDavClient();
|
||||||
|
await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
|
||||||
|
await writeLocal('N/n.json.tmp', 'partial', _utc(2026, 1, 1));
|
||||||
|
await writeLocal('N/n.json.bak', 'backup', _utc(2026, 1, 1));
|
||||||
|
|
||||||
|
await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
|
||||||
|
|
||||||
|
expect(fake.files.keys, contains('N/n.json'));
|
||||||
|
expect(fake.files.keys.any((k) => k.endsWith('.tmp')), isFalse);
|
||||||
|
expect(fake.files.keys.any((k) => k.endsWith('.bak')), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('config persistence', () {
|
||||||
|
test('saveConfig round-trips through SharedPreferences', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
await sync.saveConfig(const WebDavConfig(
|
||||||
|
baseUrl: 'https://dav.example.com',
|
||||||
|
username: 'me',
|
||||||
|
password: 'secret',
|
||||||
|
remoteRoot: 'BadNote',
|
||||||
|
autoSync: true,
|
||||||
|
));
|
||||||
|
final c = sync.config;
|
||||||
|
expect(c.baseUrl, 'https://dav.example.com');
|
||||||
|
expect(c.username, 'me');
|
||||||
|
expect(c.password, 'secret');
|
||||||
|
expect(c.remoteRoot, 'BadNote');
|
||||||
|
expect(c.autoSync, isTrue);
|
||||||
|
expect(c.isConfigured, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildClient returns null when unconfigured', () async {
|
||||||
|
final sync = await makeService();
|
||||||
|
expect(sync.buildClient(), isNull);
|
||||||
|
expect(sync.config.isConfigured, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user