diff --git a/internal/davsync/davsync.go b/internal/davsync/davsync.go index 48953362..a10b5294 100644 --- a/internal/davsync/davsync.go +++ b/internal/davsync/davsync.go @@ -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. diff --git a/internal/davsync/restore.go b/internal/davsync/restore.go new file mode 100644 index 00000000..d3af67fd --- /dev/null +++ b/internal/davsync/restore.go @@ -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() +} diff --git a/internal/davsync/restore_test.go b/internal/davsync/restore_test.go new file mode 100644 index 00000000..1e11a03c --- /dev/null +++ b/internal/davsync/restore_test.go @@ -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) + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 204b56f9..1e9b2e85 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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() { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 4a5399da..d248f835 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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…", diff --git a/internal/gui/backup.go b/internal/gui/backup.go index 9f79e443..b5348165 100644 --- a/internal/gui/backup.go +++ b/internal/gui/backup.go @@ -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) }) } diff --git a/internal/gui/tests/sync-auto-restore.test.cjs b/internal/gui/tests/sync-auto-restore.test.cjs new file mode 100644 index 00000000..8464dfd6 --- /dev/null +++ b/internal/gui/tests/sync-auto-restore.test.cjs @@ -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