feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle

- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
  JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
  pull/merge/push flow, Settings UI, pre-sync backup option. Test
  connection now treats 404 as OK (snapshot not yet created) and 401/
  403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
  dragged from an active selection; folder + trash drop handlers parse
  and apply in batch via new moveEntriesToFolder helper that preserves
  TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
  bridge message-only window; FormCloseQuery bypasses the tray-minimize
  intercept on system shutdown / restart / logoff so FireDAC closes the
  SQLite WAL cleanly instead of leaving -shm / -wal residue after a
  force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
  in editor-position=center, swallows clicks below the panel so the
  existing outside-click handlers reliably dismiss the slideover /
  settings panel.
- Batch bar state fixes: state.checked cleared before render in
  moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
  permanentDelete / deleteEntry so the action bar disappears once the
  selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
  openSlideOver re-opens the freshly saved entry, otherwise the dirty
  check fired on the soState.id=null → newId switch and a Cancel left
  the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
  with 2 real instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-30 00:32:12 +01:00
parent b00da43ab0
commit 6869b7c692
10 changed files with 1132 additions and 25 deletions
+126
View File
@@ -18,6 +18,7 @@ interface
uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
System.Net.HttpClient, System.Net.URLClient,
Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
@@ -370,6 +371,18 @@ begin
// so we bypass the minimize-to-tray intercept in that case.
if FQuitting then Exit;
// Windows shutdown / logoff / restart: WM_QUERYENDSESSION flipped the
// bridge's flag. Let the form close normally so FServer.Free and the
// FireDAC connection get a chance to checkpoint the WAL — otherwise
// Windows force-kills us at the shutdown timeout and we leave
// -shm / -wal files next to vault.db.
if Assigned(FBridge) and FBridge.ShutdownPending then
begin
FQuitting := True;
LogLine('System shutdown detected — closing normally.');
Exit;
end;
// Otherwise: minimize to tray on close instead of quitting, so the vault
// stays available without the dev-panel being visible.
// When the server is stopped, allow normal close — there's no vault to
@@ -932,6 +945,119 @@ begin
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
end
// ---- WebDAV remote sync (THTTPClient → WinHTTP, async) --------------
// cmd://webdav/get | put | test ?reqId=&url=&user=&pwd=[&data=]
// Callback: Bridge.onWebdavResult(reqId, status, bodyOrError)
// - GET ok → status=200, body=base64 of response bytes
// - GET 404 → status=404, body='' (caller treats as "no remote yet")
// - PUT ok → status=200/201/204, body=''
// - test → status=200..399 means reachable, body=''
// Network errors → status=0, body=exception message.
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then
begin
var LMethod := ACmd;
var LReqId := GetParam('reqId');
var LUrl := GetParam('url');
var LUser := GetParam('user');
var LPwd := GetParam('pwd');
var LData := GetParam('data');
TThread.CreateAnonymousThread(
procedure
var
LHttp: System.Net.HttpClient.THTTPClient;
LResp: System.Net.HttpClient.IHTTPResponse;
LBodyStream: TBytesStream;
LReqStream: TBytesStream;
LBytes: TBytes;
LBodyB64: string;
LStatus: Integer;
LErr: string;
begin
LStatus := 0;
LBodyB64 := '';
LErr := '';
try
LHttp := System.Net.HttpClient.THTTPClient.Create;
try
LHttp.ConnectionTimeout := 10000;
LHttp.ResponseTimeout := 30000;
if (LUser <> '') then
begin
LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
end;
if LMethod = 'webdav/get' then
begin
LBodyStream := TBytesStream.Create;
try
LResp := LHttp.Get(LUrl, LBodyStream);
LStatus := LResp.StatusCode;
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
begin
SetLength(LBytes, LBodyStream.Size);
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
LBodyB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
LBodyB64 := StringReplace(LBodyB64, #13, '', [rfReplaceAll]);
LBodyB64 := StringReplace(LBodyB64, #10, '', [rfReplaceAll]);
end;
finally
LBodyStream.Free;
end;
end
else if LMethod = 'webdav/put' then
begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes);
try
LResp := LHttp.Put(LUrl, LReqStream);
LStatus := LResp.StatusCode;
finally
LReqStream.Free;
end;
end
else // webdav/test — HEAD is widely supported even when PROPFIND isn't
begin
LResp := LHttp.Head(LUrl);
LStatus := LResp.StatusCode;
end;
finally
LHttp.Free;
end;
except
on E: Exception do
begin
LStatus := 0;
LErr := E.Message;
end;
end;
TThread.Queue(nil,
procedure
var
LEscReq, LEscPayload: string;
begin
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
// GET success path → ship body base64. Otherwise the field
// carries either the empty string or the exception message
// (for status=0 network errors).
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then
LEscPayload := LBodyB64
else
LEscPayload := LErr;
LEscPayload := StringReplace(LEscPayload, '\', '\\', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onWebdavResult)' +
'Bridge.onWebdavResult("' + LEscReq + '",' +
IntToStr(LStatus) + ',"' + LEscPayload + '")');
LogLine(Format('%s %s → %d (%d bytes payload)',
[LMethod, LUrl, LStatus, Length(LEscPayload)]));
end);
end).Start;
end
// ---- Native file save (bypasses WebView2's browser download UI) ------
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls