sync: it can sync by itself less often or only when asked, and the server's setup can be restored over this computer's, undoably (Sun1090, #847: 同步太快了,需要设置个开关控制自动同步,还有恢复按钮) — the gateway's magpie synced every 3 minutes with no way to change it, and a free 坚果云 account is allowed only so many WebDAV requests (600 per 30 minutes, 1 GB up and 3 GB down a month); and there was no way to take the server's setup back over a computer's own, which the next sync would otherwise push up. Settings › Sync now has Sync by itself, the app's segmented control (no <select>): Off, 3 min (the default, as before), 15 min, 30 min, 1 hour; Off syncs only on Sync now, and the sync row says only when asked. The loop reads the setting each minute (no request), so a change takes effect without a restart, and a server limiting requests is still waited for as backoff says. Restore from the server asks first in the app's own dialog, naming the parts replaced; it reads the server's file as it is now, seals this computer's setup (keys and all) into the sync folder as <time>-before-restore (newest 5 kept), brings in each part that differs as a sync brings one changed only there, and leaves the state as such a sync would, so the next sync pushes nothing back. Its notice says what was restored, with Undo, which brings the kept setup back (the next sync then takes it to the others); OK, or a later sync's own notice, drops the undo. The form's Save keeps the setting (SetAuto alone sets it). CLI: magpie webdav|s3 auto off|3|15|30|60, restore, undo; the status says how often it syncs by itself. zh, ja, de strings. TestAutoSync (off: no request by itself; back to 15 min: one sync, then none; Save keeps it; 7 refused) and TestRestore (b's own changes replaced by the server's with no push, the kept copy holds b's provider and key, Undo puts it back and a gets it, nothing differing restores nothing) pass -race; sync-auto-restore.test.cjs (WebKit, en and zh: Off posts -1 and the rows say so, Cancel posts nothing, Restore and Undo post, no click moves the page) fails on the old code. sync-other-kind, sync-spin, s3-sync, webdav-settings-refresh, usage-computers, click-scroll, gui-ja, gui-de and settings-sections pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
3ce0a0bf27
commit
76f3b97c09
8 files changed
+785
-27
No files matched your search
+82
-20
@@ -75,6 +75,27 @@ type Config struct {
|
||||
// WebDAV folder's while an S3 bucket is synced to, or the other way):
|
||||
// moving back finds it as it was, its password too. Nothing syncs to it.
|
||||
Other *Server `json:"other,omitempty"`
|
||||
// AutoEvery is how many minutes apart the gateway's magpie syncs by
|
||||
// itself: 0 is Every's 3, Manual never — then only Sync now syncs
|
||||
// (#847). Set by SetAuto alone; Configure keeps it.
|
||||
AutoEvery int `json:"autoEvery,omitempty"`
|
||||
}
|
||||
|
||||
// Manual is AutoEvery's never: sync runs only when asked.
|
||||
const Manual = -1
|
||||
|
||||
// AutoChoices are the minutes SetAuto takes, Manual first.
|
||||
var AutoChoices = []int{Manual, 3, 15, 30, 60}
|
||||
|
||||
// auto is how often c syncs by itself; 0 when it doesn't.
|
||||
func (c Config) auto() time.Duration {
|
||||
switch {
|
||||
case c.AutoEvery == Manual:
|
||||
return 0
|
||||
case c.AutoEvery > 0:
|
||||
return time.Duration(c.AutoEvery) * time.Minute
|
||||
}
|
||||
return Every
|
||||
}
|
||||
|
||||
// Server is where a Config syncs to, without what it syncs or the
|
||||
@@ -127,6 +148,9 @@ type Notice struct {
|
||||
Here []string `json:"here,omitempty"` // this computer's parts, replaced by the server's
|
||||
There []string `json:"there,omitempty"` // the server's parts, replaced by this computer's
|
||||
Saved string `json:"saved,omitempty"` // the folder the replaced copies are kept in
|
||||
// Restored is a Restore's notice: Here are the parts it brought in,
|
||||
// what was here before kept in Saved, which Undo brings back
|
||||
Restored bool `json:"restored,omitempty"`
|
||||
}
|
||||
|
||||
// state is what the last sync saw: the parts here and on the server, by
|
||||
@@ -143,6 +167,9 @@ type state struct {
|
||||
Local map[string]string `json:"local,omitempty"`
|
||||
Remote map[string]string `json:"remote,omitempty"`
|
||||
Usage *usageState `json:"usage,omitempty"`
|
||||
// Undo is the file the last Restore kept this computer's setup in, from
|
||||
// just before it: Undo brings it back
|
||||
Undo string `json:"undo,omitempty"`
|
||||
}
|
||||
|
||||
func path(name string) string { return filepath.Join(settings.Dir(), name) }
|
||||
@@ -197,6 +224,7 @@ func Configure(c Config) error {
|
||||
// the other kind's server is kept: the one moved from, or the
|
||||
// one kept before
|
||||
c.Other = old.Other
|
||||
c.AutoEvery = old.AutoEvery // SetAuto's, not the form's
|
||||
if old.S3() != c.S3() {
|
||||
o := old.where()
|
||||
c.Other = &o
|
||||
@@ -333,6 +361,11 @@ type View struct {
|
||||
// Other is the other kind's server, kept for moving back to: not
|
||||
// synced to
|
||||
Other *OtherView `json:"other,omitempty"`
|
||||
// Auto is how many minutes apart it syncs by itself; 0 never: only when
|
||||
// asked
|
||||
Auto int `json:"auto"`
|
||||
// Undo is whether the last Restore can be undone
|
||||
Undo bool `json:"undo,omitempty"`
|
||||
}
|
||||
|
||||
// OtherView is the other kind's server as the Settings page shows it.
|
||||
@@ -355,9 +388,11 @@ func Status() View {
|
||||
st := loadState()
|
||||
v := View{On: true, URL: c.URL, User: c.User, PasswordSet: c.Password != "", PassphraseSet: c.Passphrase != "",
|
||||
Keys: c.Keys, Agents: c.Agents, Library: c.library(), Error: st.Error, Notice: st.Notice,
|
||||
Kind: strings.ToLower(c.Kind()), Endpoint: c.Endpoint, Region: c.Region, PathStyle: c.PathStyle}
|
||||
Kind: strings.ToLower(c.Kind()), Endpoint: c.Endpoint, Region: c.Region, PathStyle: c.PathStyle,
|
||||
Auto: int(c.auto() / time.Minute)}
|
||||
if st.Key == stateKey(c) {
|
||||
v.Last = st.Last
|
||||
v.Undo = st.Undo != "" && isFile(st.Undo)
|
||||
if c.Usage && st.Usage != nil {
|
||||
v.UsageError = st.Usage.Error
|
||||
}
|
||||
@@ -370,11 +405,11 @@ func Status() View {
|
||||
return v
|
||||
}
|
||||
|
||||
// Dismiss clears the notice.
|
||||
// Dismiss clears the notice, and with a restore's, its undo.
|
||||
func Dismiss() error {
|
||||
return locked(func() error {
|
||||
st := loadState()
|
||||
st.Notice = nil
|
||||
st.Notice, st.Undo = nil, "" // a restore's undo goes with its notice
|
||||
saveState(st)
|
||||
return nil
|
||||
})
|
||||
@@ -517,47 +552,73 @@ const (
|
||||
// wait having been prev: Every, but longer for a server limiting requests
|
||||
// (429, 503) — as long as its Retry-After says, or else twice the last
|
||||
// wait, up to maxWait — so that a limit is not run into again and again.
|
||||
func backoff(err error, prev time.Duration) time.Duration {
|
||||
func backoff(err error, prev time.Duration) time.Duration { return backoffFrom(err, prev, Every) }
|
||||
|
||||
// backoffFrom is backoff for a sync every base.
|
||||
func backoffFrom(err error, prev, base time.Duration) time.Duration {
|
||||
var rl *rateLimited
|
||||
if !errors.As(err, &rl) {
|
||||
return Every
|
||||
return base
|
||||
}
|
||||
if rl.after > 0 {
|
||||
return min(max(rl.after, Every), limitWait)
|
||||
return min(max(rl.after, base), max(limitWait, base))
|
||||
}
|
||||
return min(max(2*prev, 2*Every), maxWait)
|
||||
return min(max(2*prev, 2*base), max(maxWait, base))
|
||||
}
|
||||
|
||||
// Run syncs a little after it starts and every Every after that, until
|
||||
// ctx ends — less often while the server is limiting requests. A failure
|
||||
// is logged once, not on every try.
|
||||
func Run(ctx context.Context) {
|
||||
t := time.NewTimer(20 * time.Second)
|
||||
// poll is how often Run looks at the setup: whether sync is on, by
|
||||
// itself, and how often (#847). Looking costs no request. A var for tests.
|
||||
var poll = time.Minute
|
||||
|
||||
// Run syncs a little after it starts and every AutoEvery after that, until
|
||||
// ctx ends — less often while the server is limiting requests, and never
|
||||
// while sync is set to run only when asked. A failure is logged once, not
|
||||
// on every try.
|
||||
func Run(ctx context.Context) { run(ctx, 20*time.Second) }
|
||||
|
||||
func run(ctx context.Context, first time.Duration) {
|
||||
t := time.NewTimer(first)
|
||||
defer t.Stop()
|
||||
last, next := "", Every
|
||||
last := ""
|
||||
var tried time.Time // the last sync Run made
|
||||
var gap time.Duration
|
||||
limited := false
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
t.Reset(poll)
|
||||
cfg, ok := Load()
|
||||
every := cfg.auto()
|
||||
if !ok || every == 0 {
|
||||
continue
|
||||
}
|
||||
// a server limiting requests is waited for as backoff says; else the
|
||||
// time set now, which may have changed since the last sync
|
||||
w := every
|
||||
if limited {
|
||||
w = max(gap, every)
|
||||
}
|
||||
if !tried.IsZero() && time.Since(tried) < w {
|
||||
continue
|
||||
}
|
||||
// no request is let stand still for long (stallAfter), but a large
|
||||
// backup on a slow line takes what it takes: only an hour ends it
|
||||
c, cancel := context.WithTimeout(ctx, time.Hour)
|
||||
err := Now(c)
|
||||
cancel()
|
||||
tried = time.Now()
|
||||
if msg := fmt.Sprint(err); err != nil && msg != last {
|
||||
kind := "WebDAV"
|
||||
if c, ok := Load(); ok {
|
||||
kind = c.Kind()
|
||||
}
|
||||
log.Printf("syncing through %s: %s", kind, msg)
|
||||
log.Printf("syncing through %s: %s", cfg.Kind(), msg)
|
||||
last = msg
|
||||
} else if err == nil {
|
||||
last = ""
|
||||
}
|
||||
next = backoff(err, next)
|
||||
t.Reset(next)
|
||||
gap = backoffFrom(err, w, every)
|
||||
var rl *rateLimited
|
||||
limited = errors.As(err, &rl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,6 +1041,7 @@ func syncOnce(ctx context.Context, c Config, st *state) error {
|
||||
remember(data)
|
||||
if len(here)+len(there) > 0 {
|
||||
st.Notice = &Notice{At: time.Now(), Here: here, There: there, Saved: saved}
|
||||
st.Undo = "" // a restore's undo goes with its notice
|
||||
}
|
||||
// A rebuild always writes back: the server's file is the damaged one,
|
||||
// so even a merge that matches the cached copy must replace it.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package davsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/backup"
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
)
|
||||
|
||||
// ErrOff is a Restore, Undo or SetAuto asked for with sync off.
|
||||
var ErrOff = errors.New("sync is off")
|
||||
|
||||
// SetAuto sets how many minutes apart the gateway's magpie syncs by
|
||||
// itself, one of AutoChoices; Manual: never, only when asked (#847).
|
||||
func SetAuto(minutes int) error {
|
||||
if !slices.Contains(AutoChoices, minutes) {
|
||||
return fmt.Errorf("sync by itself every 3, 15, 30 or 60 minutes, or never, not %d", minutes)
|
||||
}
|
||||
return locked(func() error {
|
||||
c, ok := Load()
|
||||
if !ok {
|
||||
return ErrOff
|
||||
}
|
||||
c.AutoEvery = minutes
|
||||
if minutes == int(Every/time.Minute) {
|
||||
c.AutoEvery = 0
|
||||
}
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := edit.WriteAtomic(path("sync.json"), b); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path("sync.json"), 0o600)
|
||||
})
|
||||
}
|
||||
|
||||
// Restore makes this computer's setup the server's, whatever changed here:
|
||||
// each part synced that differs is brought in, as a sync brings in a part
|
||||
// changed only there. What was here first is sealed into the sync folder,
|
||||
// with the API keys, and Undo brings it back. It is the parts brought in.
|
||||
func Restore(ctx context.Context) ([]string, error) {
|
||||
var brought []string
|
||||
err := locked(func() error {
|
||||
c, ok := Load()
|
||||
if !ok {
|
||||
return ErrOff
|
||||
}
|
||||
d, err := newRemote(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, ver, err := d.get(ctx, version{}) // the file as it is now, never the cached copy
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
return errors.New("there is no magpie setup on the server yet: nothing to restore")
|
||||
}
|
||||
remote, err := backup.Open(data, c.Passphrase)
|
||||
if errors.Is(err, backup.ErrPassphrase) {
|
||||
return errors.New("the passphrase doesn't open the file on the server: it was sealed with another one; use the passphrase set on your other computers")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
local, err := collect(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
L, R := hashes(local), hashes(remote)
|
||||
for _, p := range Parts {
|
||||
if L[p] == R[p] || !c.syncs(p) || (p == "library" && remote.Library == nil) {
|
||||
continue
|
||||
}
|
||||
brought = append(brought, p)
|
||||
}
|
||||
st := loadState()
|
||||
if st.Key != stateKey(c) {
|
||||
st = state{Key: stateKey(c)}
|
||||
}
|
||||
if len(brought) > 0 {
|
||||
kept, err := keepHere(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keeping this computer's setup before restoring: %w", err)
|
||||
}
|
||||
for _, p := range brought {
|
||||
if err := bring(remote, p); err != nil {
|
||||
return fmt.Errorf("bringing in the %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
if local, err = collect(c); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Undo = kept
|
||||
st.Notice = &Notice{At: time.Now(), Here: brought, Saved: filepath.Dir(kept), Restored: true}
|
||||
}
|
||||
// as a sync that brought them in leaves it: the next one has
|
||||
// nothing to push back
|
||||
st.Local, st.Remote, st.Sum, st.Server = hashes(local), R, sum(data), ver
|
||||
remember(data)
|
||||
st.Last, st.Error = time.Now(), ""
|
||||
saveState(st)
|
||||
return nil
|
||||
})
|
||||
return brought, err
|
||||
}
|
||||
|
||||
// Undo puts back the setup the last Restore replaced. The next sync takes
|
||||
// it to the server, as any change made here.
|
||||
func Undo() ([]string, error) {
|
||||
var back []string
|
||||
err := locked(func() error {
|
||||
c, ok := Load()
|
||||
if !ok {
|
||||
return ErrOff
|
||||
}
|
||||
st := loadState()
|
||||
if st.Key != stateKey(c) || st.Undo == "" {
|
||||
return errors.New("there is no restore to undo")
|
||||
}
|
||||
data, err := os.ReadFile(st.Undo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the copy kept before the restore is gone: %w", err)
|
||||
}
|
||||
kept, err := backup.Open(data, c.Passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
local, err := collect(withKeys(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
L, K := hashes(local), hashes(kept)
|
||||
for _, p := range Parts {
|
||||
if L[p] == K[p] || !c.syncs(p) || (p == "library" && kept.Library == nil) {
|
||||
continue
|
||||
}
|
||||
if err := bring(kept, p); err != nil {
|
||||
return fmt.Errorf("bringing back the %s: %w", p, err)
|
||||
}
|
||||
back = append(back, p)
|
||||
}
|
||||
st.Undo, st.Notice = "", nil
|
||||
saveState(st)
|
||||
return nil
|
||||
})
|
||||
return back, err
|
||||
}
|
||||
|
||||
// syncs is whether part goes to the server with c.
|
||||
func (c Config) syncs(part string) bool {
|
||||
return (part != "agents" || c.Agents) && (part != "library" || c.library())
|
||||
}
|
||||
|
||||
func withKeys(c Config) Config { c.Keys = true; return c }
|
||||
|
||||
// keepHere seals this computer's setup, keys and all, into the sync
|
||||
// folder before a restore replaces it; the path. The newest few are kept.
|
||||
func keepHere(c Config) (string, error) {
|
||||
b, err := collect(withKeys(c))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed, err := backup.Seal(b, c.Passphrase)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := path("sync")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := filepath.Join(dir, time.Now().Format("2006-01-02-150405")+"-before-restore"+backup.Ext)
|
||||
if err := edit.WriteAtomic(name, sealed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
os.Chmod(name, 0o600)
|
||||
old, _ := filepath.Glob(filepath.Join(dir, "*-before-restore"+backup.Ext))
|
||||
slices.Sort(old)
|
||||
for len(old) > 5 {
|
||||
os.Remove(old[0])
|
||||
old = old[1:]
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func isFile(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.Mode().IsRegular()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package davsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/backup"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// Sync can be set to run by itself less often, or never — only when asked
|
||||
// — and the gateway's loop keeps to it, a change taking effect without a
|
||||
// restart (Sun1090, #847: free 坚果云 accounts are limited in requests).
|
||||
func TestAutoSync(t *testing.T) {
|
||||
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}
|
||||
srv := httptest.NewServer(fake)
|
||||
defer srv.Close()
|
||||
newComputer(t).use(t)
|
||||
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Agents: true}
|
||||
if err := SetAuto(Manual); err != ErrOff {
|
||||
t.Fatalf("set with sync off: %v", err)
|
||||
}
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := Status(); v.Auto != 3 {
|
||||
t.Fatalf("by default it syncs every %d minutes", v.Auto)
|
||||
}
|
||||
if err := SetAuto(7); err == nil {
|
||||
t.Fatal("7 minutes was taken")
|
||||
}
|
||||
if err := SetAuto(Manual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// saving the form again keeps it
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := Status(); v.Auto != 0 {
|
||||
t.Fatalf("manual, after saving again: every %d", v.Auto)
|
||||
}
|
||||
|
||||
old := poll
|
||||
poll = 5 * time.Millisecond
|
||||
defer func() { poll = old }()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { run(ctx, time.Millisecond); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
fake.mu.Lock()
|
||||
n := fake.gets + fake.puts
|
||||
fake.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("set to sync only when asked, it made %d requests by itself", n)
|
||||
}
|
||||
// set back to every 15 minutes: the loop syncs at once, then waits
|
||||
if err := SetAuto(15); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for Status().Last.IsZero() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if v := Status(); v.Last.IsZero() || v.Auto != 15 {
|
||||
t.Fatalf("every 15: %+v", v)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
fake.mu.Lock()
|
||||
gets := fake.gets
|
||||
fake.mu.Unlock()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.gets != gets {
|
||||
t.Fatalf("synced again within 15 minutes: %d reads, then %d", gets, fake.gets)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore makes this computer's setup the server's, keeping what was here
|
||||
// to undo; the next sync pushes nothing back. Undo puts it back, and the
|
||||
// next sync takes that to the others.
|
||||
func TestRestore(t *testing.T) {
|
||||
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}
|
||||
srv := httptest.NewServer(fake)
|
||||
defer srv.Close()
|
||||
ctx := context.Background()
|
||||
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Agents: true}
|
||||
|
||||
a, b := newComputer(t), newComputer(t)
|
||||
a.use(t)
|
||||
if _, err := Restore(ctx); err != ErrOff {
|
||||
t.Fatalf("restore with sync off: %v", err)
|
||||
}
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Restore(ctx); err == nil || !strings.Contains(err.Error(), "nothing to restore") {
|
||||
t.Fatalf("restore from an empty server: %v", err)
|
||||
}
|
||||
provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k1"})
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.use(t)
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Dismiss()
|
||||
// b's setup goes astray: a provider gone, another added
|
||||
provider.Delete("deepseek")
|
||||
provider.Save(provider.Provider{ID: "mine", Name: "Mine", Chat: "https://x/v1", Key: "kb"})
|
||||
puts := fake.puts
|
||||
got, err := Restore(ctx)
|
||||
if err != nil || !slices.Equal(got, []string{"providers"}) {
|
||||
t.Fatalf("restored %v: %v", got, err)
|
||||
}
|
||||
if ids := ids(); !slices.Equal(ids, []string{"deepseek=k1"}) {
|
||||
t.Fatalf("after restoring: %v", ids)
|
||||
}
|
||||
v := Status()
|
||||
if v.Notice == nil || !v.Notice.Restored || !v.Undo || v.Error != "" || fake.puts != puts {
|
||||
t.Fatalf("restored: %+v, %d puts", v, fake.puts-puts)
|
||||
}
|
||||
kept := loadState().Undo
|
||||
if old, err := backup.Open(must(os.ReadFile(kept)), "correct horse"); err != nil || len(old.Providers) != 1 || old.Providers[0].ID != "mine" || old.Providers[0].Key != "kb" {
|
||||
t.Fatalf("kept before restoring: %v %+v", err, old.Providers)
|
||||
}
|
||||
if err := Now(ctx); err != nil || fake.puts != puts {
|
||||
t.Fatalf("the sync after a restore pushed (%d puts): %v", fake.puts-puts, err)
|
||||
}
|
||||
|
||||
back, err := Undo()
|
||||
if err != nil || !slices.Equal(back, []string{"providers"}) {
|
||||
t.Fatalf("undone %v: %v", back, err)
|
||||
}
|
||||
if ids := ids(); !slices.Equal(ids, []string{"mine=kb"}) {
|
||||
t.Fatalf("after undoing: %v", ids)
|
||||
}
|
||||
if v := Status(); v.Undo || v.Notice != nil {
|
||||
t.Fatalf("after undoing: %+v", v)
|
||||
}
|
||||
if _, err := Undo(); err == nil {
|
||||
t.Fatal("undone twice")
|
||||
}
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.use(t)
|
||||
if err := Now(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ids := ids(); !slices.Equal(ids, []string{"mine=kb"}) {
|
||||
t.Fatalf("a after b undid: %v", ids)
|
||||
}
|
||||
// nothing differs: nothing brought in, nothing kept
|
||||
if got, err := Restore(ctx); err != nil || len(got) != 0 {
|
||||
t.Fatalf("restoring what is here: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -15271,6 +15271,7 @@ async function renderSync(v) {
|
||||
// the other kind's server, kept from before sync moved here
|
||||
if (v.other) status += " · " + t("{kind} settings kept", { kind: v.other.kind === "s3" ? "S3" : "WebDAV" });
|
||||
if (!v.error && v.usageError) status += " · " + t("Couldn't share usage: {error}", { error: v.usageError });
|
||||
if (v.auto === 0) status += " · " + t("only when asked");
|
||||
}
|
||||
const sub = row(t(s3 ? "S3 sync" : v.on ? "WebDAV sync" : "WebDAV or S3 sync"), status, ...(v.on
|
||||
? [btn(t("Sync now"), async (e) => { e.target.classList.add("busy"); renderSync(await api("davsync/now", {}).catch((x) => ({ ...v, error: x.message }))); }),
|
||||
@@ -15280,17 +15281,33 @@ async function renderSync(v) {
|
||||
if (v.notice) {
|
||||
const n = v.notice, r = el("div", "row pref sync-note");
|
||||
const lines = [];
|
||||
if (n.here?.length) lines.push(t("Replaced here by newer ones from another computer: {parts}", { parts: parts(n.here) }));
|
||||
if (n.restored) lines.push(t("Restored from the server: {parts}", { parts: parts(n.here || []) }));
|
||||
else if (n.here?.length) lines.push(t("Replaced here by newer ones from another computer: {parts}", { parts: parts(n.here) }));
|
||||
if (n.there?.length) lines.push(t("Replaced on the server by this computer's newer ones: {parts}", { parts: parts(n.there) }));
|
||||
const who = el("div", "who");
|
||||
for (const l of lines) who.append(el("div", "sub", l));
|
||||
who.append(el("div", "sub", t("The copies replaced are kept in the sync folder.")));
|
||||
who.append(el("div", "sub", t(n.restored ? "This computer's setup from before is kept in the sync folder." : "The copies replaced are kept in the sync folder.")));
|
||||
const val = el("div", "val");
|
||||
if (n.restored && v.undo) val.append(btn(t("Undo"), async (e) => {
|
||||
e.target.classList.add("busy");
|
||||
try { renderSync(await api("davsync/undo", {})); refreshAfterSync(); } catch (x) { e.target.classList.remove("busy"); sub.textContent = x.message; sub.classList.add("bad"); }
|
||||
}));
|
||||
val.append(btn(t("Show"), () => api("davsync/reveal", {}).catch((e) => status(e.message, "err"))), btn(t("OK"), async () => renderSync(await api("davsync/dismiss", {}))));
|
||||
r.append(who, val);
|
||||
box.append(r);
|
||||
}
|
||||
if (syncOpen === "dav") box.append(davForm(v));
|
||||
if (v.on) {
|
||||
// how often it syncs by itself, or never: a free 坚果云 account is
|
||||
// allowed only so many requests (#847)
|
||||
const every = v.auto ?? 3;
|
||||
row(t("Sync by itself"), every ? t("Every {n} minutes while magpie runs", { n: every }) : t("Off: only Sync now syncs"),
|
||||
segs([[0, t("Off")], [3, t("3 min")], [15, t("15 min")], [30, t("30 min")], [60, t("1 hour")]], every, async (m) => {
|
||||
renderSync(await api("davsync/auto", { minutes: m || -1 }).catch((x) => ({ ...v, error: x.message })));
|
||||
}));
|
||||
row(t("Restore from the server"), t("This computer's setup becomes the one on the server. What is here now is kept first, to undo."),
|
||||
btn(t("Restore…"), () => askRestore(v, parts)));
|
||||
}
|
||||
|
||||
// export and import
|
||||
row(t("Export"), t("Everything above in one file, sealed with a passphrase, to carry to another computer"), btn(t(syncOpen === "export" ? "Close" : "Export…"), toggle("export")));
|
||||
@@ -15299,6 +15316,49 @@ async function renderSync(v) {
|
||||
if (syncOpen === "import") box.append(importForm());
|
||||
}
|
||||
|
||||
// askRestore asks before the server's setup replaces this computer's
|
||||
// (#847); what is here is kept, and the notice it leaves can undo it.
|
||||
function askRestore(v, parts) {
|
||||
const ed = el("div", "editor restore-ask");
|
||||
const head = el("div", "ehead");
|
||||
head.append(el("b", "", t("Restore from the server?")));
|
||||
ed.append(head);
|
||||
const goes = ["providers", "settings", "profiles"];
|
||||
if (v.agents !== false) goes.push("agents");
|
||||
if (v.library !== false) goes.push("library");
|
||||
ed.append(el("p", "lib-confirm", t("This computer's {parts} become the ones on the server, as your other computers last synced them. Whatever was changed here since is replaced.", { parts: parts(goes) })));
|
||||
ed.append(el("p", "lib-confirm", t("What is here now is sealed into the sync folder first, and Undo puts it back.")));
|
||||
const err = el("p", "editor-error", "");
|
||||
ed.append(err);
|
||||
const bar = el("div", "bar");
|
||||
const go = el("button", "text primary danger-fill", t("Restore setup"));
|
||||
go.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
go.disabled = true;
|
||||
go.classList.add("busy");
|
||||
try {
|
||||
const r = await api("davsync/restore", {});
|
||||
closeConfirmAsk();
|
||||
renderSync(r);
|
||||
if (r.error) return;
|
||||
if (r.brought?.length) refreshAfterSync();
|
||||
else status(t("This computer's setup is already the server's: nothing to restore"), "ok");
|
||||
} catch (x) {
|
||||
go.disabled = false;
|
||||
go.classList.remove("busy");
|
||||
err.textContent = x.message;
|
||||
}
|
||||
};
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
||||
bar.append(el("span", "grow"), cancel, go);
|
||||
ed.append(bar);
|
||||
confirmAsk = ed;
|
||||
openModal(ed);
|
||||
$("#modal").classList.add("lib");
|
||||
cancel.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// refreshAfterSync: what a sync or an import brought in reaches the other
|
||||
// pages, leaving this one (and what it says was done) as it is.
|
||||
function refreshAfterSync() {
|
||||
|
||||
@@ -1381,6 +1381,25 @@ const I18N = {
|
||||
"Replaced here by newer ones from another computer: {parts}": "本机的{parts}已被另一台电脑上较新的版本替换",
|
||||
"Replaced on the server by this computer's newer ones: {parts}": "服务器上的{parts}已被本机较新的版本替换",
|
||||
"The copies replaced are kept in the sync folder.": "被替换的副本已保存在 sync 文件夹。",
|
||||
"only when asked": "仅手动",
|
||||
"Restored from the server: {parts}": "已从服务器恢复:{parts}",
|
||||
"This computer's setup from before is kept in the sync folder.": "本机恢复前的配置已备份在 sync 文件夹。",
|
||||
"Undo": "撤销",
|
||||
"Sync by itself": "自动同步",
|
||||
"Every {n} minutes while magpie runs": "magpie 运行时每 {n} 分钟同步一次",
|
||||
"Off: only Sync now syncs": "已关闭,仅在点「立即同步」时同步",
|
||||
"3 min": "3 分钟",
|
||||
"15 min": "15 分钟",
|
||||
"30 min": "30 分钟",
|
||||
"1 hour": "1 小时",
|
||||
"Restore from the server": "从服务器恢复",
|
||||
"This computer's setup becomes the one on the server. What is here now is kept first, to undo.": "用服务器上的配置覆盖本机,覆盖前先备份当前配置,可撤销。",
|
||||
"Restore…": "恢复…",
|
||||
"Restore from the server?": "从服务器恢复?",
|
||||
"This computer's {parts} become the ones on the server, as your other computers last synced them. Whatever was changed here since is replaced.": "本机的{parts}将换成服务器上的(其他电脑最后同步的版本),本机之后的改动会被替换。",
|
||||
"What is here now is sealed into the sync folder first, and Undo puts it back.": "当前配置会先加密备份到 sync 文件夹,可点「撤销」换回。",
|
||||
"Restore setup": "恢复配置",
|
||||
"This computer's setup is already the server's: nothing to restore": "本机的配置已与服务器一致,无需恢复",
|
||||
"OK": "知道了",
|
||||
"Export": "导出", "Export…": "导出…",
|
||||
"Everything above in one file, sealed with a passphrase, to carry to another computer": "把上面这些打包成一个用口令加密的文件,带到另一台电脑",
|
||||
@@ -4466,6 +4485,25 @@ const I18N = {
|
||||
"Replaced here by newer ones from another computer: {parts}": "別のコンピューターの新しいもので置き換えました:{parts}",
|
||||
"Replaced on the server by this computer's newer ones: {parts}": "サーバー上のものをこのコンピューターの新しいもので置き換えました:{parts}",
|
||||
"The copies replaced are kept in the sync folder.": "置き換えられたコピーは同期フォルダーに保存されています。",
|
||||
"only when asked": "手動のみ",
|
||||
"Restored from the server: {parts}": "サーバーから復元しました:{parts}",
|
||||
"This computer's setup from before is kept in the sync folder.": "復元前のこのコンピューターの設定は同期フォルダーに保存されています。",
|
||||
"Undo": "取り消す",
|
||||
"Sync by itself": "自動同期",
|
||||
"Every {n} minutes while magpie runs": "magpie の実行中、{n} 分ごとに同期",
|
||||
"Off: only Sync now syncs": "オフ:「今すぐ同期」を押したときだけ同期",
|
||||
"3 min": "3 分",
|
||||
"15 min": "15 分",
|
||||
"30 min": "30 分",
|
||||
"1 hour": "1 時間",
|
||||
"Restore from the server": "サーバーから復元",
|
||||
"This computer's setup becomes the one on the server. What is here now is kept first, to undo.": "このコンピューターの設定をサーバーのものにします。今の設定は先に保存され、取り消せます。",
|
||||
"Restore…": "復元…",
|
||||
"Restore from the server?": "サーバーから復元しますか?",
|
||||
"This computer's {parts} become the ones on the server, as your other computers last synced them. Whatever was changed here since is replaced.": "このコンピューターの{parts}が、ほかのコンピューターが最後に同期したサーバーのものに置き換わります。ここでその後に変えたものは置き換えられます。",
|
||||
"What is here now is sealed into the sync folder first, and Undo puts it back.": "今の設定は先に暗号化して同期フォルダーに保存され、「取り消す」で戻せます。",
|
||||
"Restore setup": "設定を復元",
|
||||
"This computer's setup is already the server's: nothing to restore": "このコンピューターの設定はすでにサーバーと同じです。復元するものはありません",
|
||||
"OK": "OK",
|
||||
"Export": "エクスポート",
|
||||
"Export…": "エクスポート…",
|
||||
@@ -7539,6 +7577,25 @@ const I18N = {
|
||||
"Replaced here by newer ones from another computer: {parts}": "Hier durch neuere von einem anderen Computer ersetzt: {parts}",
|
||||
"Replaced on the server by this computer's newer ones: {parts}": "Auf dem Server durch neuere von diesem Computer ersetzt: {parts}",
|
||||
"The copies replaced are kept in the sync folder.": "Die ersetzten Kopien bleiben im Sync-Ordner erhalten.",
|
||||
"only when asked": "nur auf Anfrage",
|
||||
"Restored from the server: {parts}": "Vom Server wiederhergestellt: {parts}",
|
||||
"This computer's setup from before is kept in the sync folder.": "Die Einrichtung dieses Computers von vorher liegt im Sync-Ordner.",
|
||||
"Undo": "Rückgängig",
|
||||
"Sync by itself": "Selbst synchronisieren",
|
||||
"Every {n} minutes while magpie runs": "Alle {n} Minuten, solange magpie läuft",
|
||||
"Off: only Sync now syncs": "Aus: nur „Jetzt synchronisieren“ synchronisiert",
|
||||
"3 min": "3 Min.",
|
||||
"15 min": "15 Min.",
|
||||
"30 min": "30 Min.",
|
||||
"1 hour": "1 Stunde",
|
||||
"Restore from the server": "Vom Server wiederherstellen",
|
||||
"This computer's setup becomes the one on the server. What is here now is kept first, to undo.": "Die Einrichtung dieses Computers wird die auf dem Server. Was jetzt hier ist, wird vorher gesichert und lässt sich zurückholen.",
|
||||
"Restore…": "Wiederherstellen…",
|
||||
"Restore from the server?": "Vom Server wiederherstellen?",
|
||||
"This computer's {parts} become the ones on the server, as your other computers last synced them. Whatever was changed here since is replaced.": "{parts} dieses Computers werden zu denen auf dem Server, wie Ihre anderen Computer sie zuletzt synchronisiert haben. Was hier seitdem geändert wurde, wird ersetzt.",
|
||||
"What is here now is sealed into the sync folder first, and Undo puts it back.": "Was jetzt hier ist, wird zuerst verschlüsselt im Sync-Ordner abgelegt, und „Rückgängig“ holt es zurück.",
|
||||
"Restore setup": "Einrichtung wiederherstellen",
|
||||
"This computer's setup is already the server's: nothing to restore": "Die Einrichtung dieses Computers ist schon die des Servers: nichts wiederherzustellen",
|
||||
"OK": "OK",
|
||||
"Export": "Exportieren",
|
||||
"Export…": "Exportieren…",
|
||||
|
||||
@@ -98,6 +98,7 @@ func backupRoutes(mux *http.ServeMux, w Windows) {
|
||||
// and davsync ends a request that stops moving
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
var brought []string
|
||||
switch r.PathValue("action") {
|
||||
case "save": // and sync at once, so a wrong address or password shows now
|
||||
var c davsync.Config
|
||||
@@ -115,6 +116,15 @@ func backupRoutes(mux *http.ServeMux, w Windows) {
|
||||
err = davsync.Off()
|
||||
case "dismiss":
|
||||
err = davsync.Dismiss()
|
||||
case "auto": // how often it syncs by itself, or never (#847)
|
||||
var in struct{ Minutes int }
|
||||
if err = json.NewDecoder(r.Body).Decode(&in); err == nil {
|
||||
err = davsync.SetAuto(in.Minutes)
|
||||
}
|
||||
case "restore": // the server's setup over this computer's, which is kept
|
||||
brought, err = davsync.Restore(ctx)
|
||||
case "undo":
|
||||
_, err = davsync.Undo()
|
||||
case "reveal":
|
||||
err = w.OpenFolder(filepath.Join(settings.Dir(), "sync"))
|
||||
default:
|
||||
@@ -130,6 +140,13 @@ func backupRoutes(mux *http.ServeMux, w Windows) {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
if r.PathValue("action") == "restore" { // and what it brought in: nothing when it was the same
|
||||
writeJSON(rw, struct {
|
||||
davsync.View
|
||||
Brought []string `json:"brought"`
|
||||
}{v, brought})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, v)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Sync can run by itself less often, or only when asked, and the server's
|
||||
// setup can be restored over this computer's, undoably (Sun1090, #847: free
|
||||
// 坚果云 accounts are limited in requests, and there was no way back). With
|
||||
// WebDAV sync on, the Sync by itself row's control (no <select>) shows every
|
||||
// 3 minutes; picking Off posts minutes -1, the row then says only Sync now
|
||||
// syncs and the sync row says only when asked. Restore from the server asks
|
||||
// first in the app's own dialog, naming what is replaced and that it is kept
|
||||
// to undo; Cancel posts nothing; Restore posts it, and the notice it leaves
|
||||
// has Undo, which posts undo. No click moves the page. In English and
|
||||
// Chinese, Chromium and WebKit. The API is faked; nothing is synced.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
|
||||
function serve(lang, posts) {
|
||||
const settings = {
|
||||
theme: "light", lang, tray: "panel", version: "test", dir: "/tmp/magpie",
|
||||
gateway: "http://127.0.0.1:3425", visionModels: [], imageGenModels: [],
|
||||
fx: { rate: 7.2, stale: false },
|
||||
};
|
||||
let sync = { on: true, kind: "webdav", url: "https://dav.jianguoyun.com/dav/", user: "me", passwordSet: true, passphraseSet: true,
|
||||
keys: true, agents: true, library: true, auto: 3, last: new Date().toISOString() };
|
||||
return async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
|
||||
if (pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (pathname === "/api/state") return json({ agents: [], profiles: [], settings });
|
||||
if (pathname === "/api/settings") return json(settings);
|
||||
if (pathname === "/api/usage/quotas") return json([]);
|
||||
if (pathname === "/api/groups") return json({ groups: [], models: [] });
|
||||
if (pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (pathname === "/api/davsync") return json(sync);
|
||||
if (pathname.startsWith("/api/davsync/")) {
|
||||
const action = pathname.slice("/api/davsync/".length);
|
||||
posts.push({ action, body: request.postDataJSON() });
|
||||
if (action === "auto") sync = { ...sync, auto: Math.max(0, request.postDataJSON().minutes) };
|
||||
if (action === "restore") {
|
||||
sync = { ...sync, undo: true, notice: { at: new Date().toISOString(), here: ["providers", "settings"], saved: "/tmp/magpie/sync", restored: true } };
|
||||
return json({ ...sync, brought: ["providers", "settings"] });
|
||||
}
|
||||
if (action === "undo") sync = { ...sync, undo: false, notice: undefined };
|
||||
return json(sync);
|
||||
}
|
||||
if (pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, pathname === "/" ? "index.html" : pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
return route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: {
|
||||
auto: "Sync by itself", every3: "Every 3 minutes while magpie runs", off: "Off", offSub: "Off: only Sync now syncs",
|
||||
asked: " · only when asked", restoreRow: "Restore from the server", restore: "Restore…", ask: "Restore from the server?",
|
||||
kept: "What is here now is sealed into the sync folder first, and Undo puts it back.", go: "Restore setup", cancel: "Cancel",
|
||||
restored: "Restored from the server: providers, settings", undo: "Undo",
|
||||
},
|
||||
zh: {
|
||||
auto: "自动同步", every3: "magpie 运行时每 3 分钟同步一次", off: "关闭", offSub: "已关闭,仅在点「立即同步」时同步",
|
||||
asked: " · 仅手动", restoreRow: "从服务器恢复", restore: "恢复…", ask: "从服务器恢复?",
|
||||
kept: "当前配置会先加密备份到 sync 文件夹,可点「撤销」换回。", go: "恢复配置", cancel: "取消",
|
||||
restored: "已从服务器恢复:", undo: "撤销",
|
||||
},
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: sync by itself can be turned off, and the server's setup restored and undone`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage();
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-sync-auto-restore.png`), fullPage: true });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
page.setDefaultTimeout(5000);
|
||||
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator("#prefs").click();
|
||||
await page.locator("#setTab-sync").click();
|
||||
const list = page.locator("#syncList");
|
||||
const rowNamed = (name) => list.locator(".row.pref").filter({ has: page.locator(".name", { hasText: name }) });
|
||||
const auto = rowNamed(w.auto);
|
||||
await auto.waitFor({ state: "visible" });
|
||||
const toEnd = async () => {
|
||||
await page.mouse.move(450, 300);
|
||||
const left = () => page.locator("#view-settings").evaluate((v) => v.scrollHeight - v.clientHeight - v.scrollTop);
|
||||
for (let i = 0; i < 120 && (await left()) > 0.5; i++) { await page.mouse.wheel(0, 80); await page.waitForTimeout(15); }
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
await toEnd();
|
||||
const top = () => page.evaluate(() => document.querySelector("#view-settings").scrollTop);
|
||||
assert.equal(await page.locator("#syncList select").count(), 0, "no native select");
|
||||
assert.equal(await auto.locator(".sub").textContent(), w.every3);
|
||||
assert.equal(await auto.locator(".segs .opt.on").textContent(), lang === "en" ? "3 min" : "3 分钟");
|
||||
|
||||
// off: only when asked
|
||||
let at = await top();
|
||||
await auto.locator(".segs .opt", { hasText: w.off }).click();
|
||||
await page.waitForFunction((s) => [...document.querySelectorAll("#syncList .row.pref .sub")].some((x) => x.textContent === s), w.offSub);
|
||||
assert.deepEqual(posts.at(-1), { action: "auto", body: { minutes: -1 } });
|
||||
const first = list.locator(".row.pref").first();
|
||||
assert.ok((await first.locator(".sub").textContent()).endsWith(w.asked), "the sync row says it syncs only when asked");
|
||||
assert.equal(await top(), at, "picking Off didn't move the page");
|
||||
|
||||
// restore asks first; cancel posts nothing
|
||||
const restoreRow = rowNamed(w.restoreRow);
|
||||
await restoreRow.locator("button", { hasText: w.restore }).click();
|
||||
const dialog = page.locator(".restore-ask");
|
||||
await dialog.waitFor({ state: "visible" });
|
||||
assert.equal(await dialog.locator(".ehead b").textContent(), w.ask);
|
||||
assert.ok((await dialog.textContent()).includes(w.kept), "the dialog says what is here is kept to undo");
|
||||
const n = posts.length;
|
||||
await dialog.locator("button", { hasText: w.cancel }).click();
|
||||
await dialog.waitFor({ state: "detached" });
|
||||
assert.equal(posts.length, n, "Cancel restored nothing");
|
||||
|
||||
// restore: posted, and the notice it leaves undoes it
|
||||
at = await top();
|
||||
await restoreRow.locator("button", { hasText: w.restore }).click();
|
||||
await page.locator(".restore-ask button", { hasText: w.go }).click();
|
||||
const note = list.locator(".sync-note");
|
||||
await note.waitFor({ state: "visible" });
|
||||
assert.equal(posts.at(-1).action, "restore");
|
||||
assert.ok((await note.textContent()).includes(w.restored), "the notice says what was restored");
|
||||
await note.locator("button", { hasText: w.undo }).click();
|
||||
await note.waitFor({ state: "detached" });
|
||||
assert.equal(posts.at(-1).action, "undo");
|
||||
assert.equal(await top(), at, "restoring and undoing didn't move the page");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
+55
-5
@@ -28,7 +28,10 @@ const webdavUsage = `usage:
|
||||
(with a user) and the passphrase
|
||||
magpie webdav set k=v… change it: address, user, keys, agents, library, usage (yes|no);
|
||||
password= and passphrase= ask for a new one
|
||||
magpie webdav now sync now (the gateway does every 3 minutes, while it runs)
|
||||
magpie webdav now sync now (the gateway also syncs by itself while it runs: see auto)
|
||||
magpie webdav auto off|3|15|30|60 how often the gateway syncs by itself, in minutes; off: only when asked
|
||||
magpie webdav restore make this computer's setup the server's; what is here is kept first
|
||||
magpie webdav undo put back what the last restore replaced
|
||||
magpie webdav dismiss clear what the last sync said it replaced
|
||||
magpie webdav off turn it off; the file on the server stays
|
||||
|
||||
@@ -65,7 +68,10 @@ const s3Usage = `usage:
|
||||
magpie s3 set k=v… change it: address, bucket, prefix, endpoint, region, access-key-id,
|
||||
path-style, keys, agents, library, usage (yes|no); secret= and passphrase=
|
||||
ask for a new one
|
||||
magpie s3 now sync now (the gateway does every 3 minutes, while it runs)
|
||||
magpie s3 now sync now (the gateway also syncs by itself while it runs: see auto)
|
||||
magpie s3 auto off|3|15|30|60 how often the gateway syncs by itself, in minutes; off: only when asked
|
||||
magpie s3 restore make this computer's setup the server's; what is here is kept first
|
||||
magpie s3 undo put back what the last restore replaced
|
||||
magpie s3 dismiss clear what the last sync said it replaced
|
||||
magpie s3 off turn it off; the file in the bucket stays
|
||||
|
||||
@@ -126,6 +132,42 @@ func syncCmd(k syncKind, args []string) error {
|
||||
return syncSet(k, args[1:], false)
|
||||
case "now":
|
||||
return syncNow(k)
|
||||
case "auto":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("magpie %s auto off|3|15|30|60", k.cmd)
|
||||
}
|
||||
m := davsync.Manual
|
||||
if args[1] != "off" {
|
||||
if _, err := fmt.Sscan(args[1], &m); err != nil {
|
||||
return fmt.Errorf("magpie %s auto off|3|15|30|60, not %q", k.cmd, args[1])
|
||||
}
|
||||
}
|
||||
if err := davsync.SetAuto(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncShow(k)
|
||||
case "restore", "undo":
|
||||
if _, ok := davsync.Load(); !ok {
|
||||
return fmt.Errorf("%s sync is off: %s", k.name, k.turnOn())
|
||||
}
|
||||
var parts []string
|
||||
var err error
|
||||
if args[0] == "restore" {
|
||||
parts, err = davsync.Restore(context.Background())
|
||||
} else {
|
||||
parts, err = davsync.Undo()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
fmt.Println("nothing differed: nothing was replaced")
|
||||
} else if args[0] == "restore" {
|
||||
fmt.Printf("restored from the server: %s; what was here is kept, and magpie %s undo puts it back\n", strings.Join(parts, ", "), k.cmd)
|
||||
} else {
|
||||
fmt.Printf("put back: %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
return nil
|
||||
case "dismiss":
|
||||
if err := davsync.Dismiss(); err != nil {
|
||||
return err
|
||||
@@ -410,7 +452,10 @@ func syncShow(k syncKind) error {
|
||||
if v.UsageError != "" {
|
||||
fmt.Println(" couldn't share usage:", v.UsageError)
|
||||
}
|
||||
if n := v.Notice; n != nil {
|
||||
if n := v.Notice; n != nil && n.Restored {
|
||||
fmt.Println(" Restored from the server:", partNames(n.Here))
|
||||
fmt.Println(muted.Render(" This computer's setup from before is kept in " + tilde(n.Saved) + " (magpie " + cmd + " undo puts it back; magpie " + cmd + " dismiss clears this)"))
|
||||
} else if n != nil {
|
||||
if len(n.Here) > 0 {
|
||||
fmt.Println(" Replaced here by newer ones from another computer:", partNames(n.Here))
|
||||
}
|
||||
@@ -423,8 +468,13 @@ func syncShow(k syncKind) error {
|
||||
}
|
||||
fmt.Println(muted.Render(" The copies replaced are kept in " + tilde(dir) + " (magpie restore opens one; magpie " + cmd + " dismiss clears this)"))
|
||||
}
|
||||
if !gateway.Running() {
|
||||
fmt.Println(muted.Render(" No gateway runs here: nothing syncs by itself until one does (the app, magpie web or magpie serve), every 3 minutes; magpie " + cmd + " now syncs once"))
|
||||
switch {
|
||||
case v.Auto == 0:
|
||||
fmt.Println(muted.Render(" Syncs only when asked: magpie " + cmd + " now syncs once (magpie " + cmd + " auto 3 syncs by itself again)"))
|
||||
case !gateway.Running():
|
||||
fmt.Println(muted.Render(fmt.Sprintf(" No gateway runs here: nothing syncs by itself until one does (the app, magpie web or magpie serve), every %d minutes; magpie %s now syncs once", v.Auto, cmd)))
|
||||
default:
|
||||
fmt.Println(muted.Render(fmt.Sprintf(" Syncs by itself every %d minutes (magpie %s auto changes it)", v.Auto, cmd)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user