feat: synchronize pinned tabs across devices
Development CI / check-and-package (push) Successful in 4m18s
GitHub Git mirror / mirror (push) Successful in 28s
Development CI / image (push) Successful in 21s
Development CI / deploy-development (push) Successful in 15s

This commit is contained in:
eezd committed 2026-11-04 18:20:16 +08:00
1 parent 403824eccb
commit 5514a1e465
12 files changed
+454 -29

No files matched your search

+20
View File
@@ -404,6 +404,26 @@ func TestSettingsMerge(t *testing.T) {
}
}
func TestPinnedTabsSharedAcrossSessions(t *testing.T) {
f := newFixture(t)
other := newClient()
f.login(other, "password123")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"pinnedTabs":["a.md","目录/b.md"]}`, nil).Body.Close()
got := decode[struct{ PinnedTabs []string }](t, f.do(other, "GET", "/api/v1/settings", "", nil))
if len(got.PinnedTabs) != 2 || got.PinnedTabs[0] != "a.md" || got.PinnedTabs[1] != "目录/b.md" {
t.Fatalf("另一会话未读取到固定标签: %v", got.PinnedTabs)
}
f.do(other, "PATCH", "/api/v1/settings", `{"pinnedTabs":[]}`, nil).Body.Close()
again := decode[struct{ PinnedTabs []string }](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if again.PinnedTabs == nil || len(again.PinnedTabs) != 0 {
t.Fatalf("取消固定必须保存为空列表: %v", again.PinnedTabs)
}
stored, ok, err := f.store.Setting(settingsKey)
if err != nil || !ok || !strings.Contains(stored, `"pinnedTabs":[]`) {
t.Fatalf("固定标签未持久化到数据库: %q, %v", stored, err)
}
}
func TestChangePassword(t *testing.T) {
f := newFixture(t)
other := newClient()
+7 -5
View File
@@ -156,17 +156,19 @@ func TestSettingsPackageRoundTripMergesResourcesAndPreservesAccount(t *testing.T
func TestResetPreferencesPreservesPluginDataAndCredentials(t *testing.T) {
f := newFixture(t)
f.write("note.md", "source")
f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0}`)
f.store.SetSetting(settingsKey, `{"fontSize":24,"trashRetentionDays":0,"pinnedTabs":["note.md"]}`)
f.store.SetSetting(pluginEnabledKey, `{"example":true}`)
f.store.SetSetting("plugin.data.example", `{"important":"content"}`)
if status := f.status(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":false}`, nil); status != 400 {
t.Fatal(status)
}
assertOK(t, f.do(f.admin, "POST", "/api/v1/maintenance/settings/reset", `{"confirmed":true}`, nil))
for _, key := range []string{settingsKey, pluginEnabledKey} {
if _, ok, err := f.store.Setting(key); err != nil || ok {
t.Fatalf("未重置 %s", key)
}
if _, ok, err := f.store.Setting(pluginEnabledKey); err != nil || ok {
t.Fatal("插件启用状态未重置")
}
settings := decode[map[string]json.RawMessage](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil))
if len(settings) != 1 || string(settings["pinnedTabs"]) != "[]" {
t.Fatalf("重置必须清空固定标签并阻止旧设备再次迁移: %v", settings)
}
if value, ok, err := f.store.Setting("plugin.data.example"); err != nil || !ok || value != `{"important":"content"}` {
t.Fatal("插件数据丢失")
+12 -1
View File
@@ -481,7 +481,7 @@
"bearer": []
}
],
"description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.",
"description": "Merge top-level keys: supplied values replace existing values, and null removes a key. On success, broadcast a settings event over WebSocket. pinnedTabs stores vault-relative Markdown paths shared across devices; an empty array explicitly clears pins, while an absent key allows the browser to migrate legacy local pins. Concurrent replacements use the last server write. trashRetentionDays is a nonnegative safe integer (default 30; 0 disables automatic deletion); null restores the default. Retention applies to existing and new recycle-bin entries. Startup and hourly cleanup permanently delete expired entries based on their original deletion times.",
"requestBody": {
"required": true,
"content": {
@@ -490,6 +490,16 @@
"type": "object",
"additionalProperties": true,
"properties": {
"pinnedTabs": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "Ordered vault-relative Markdown paths. Empty means no pins; null removes the setting."
},
"trashRetentionDays": {
"type": [
"integer",
@@ -3789,6 +3799,7 @@
"/maintenance/settings/reset": {
"post": {
"summary": "Reset confirmed preferences while retaining plugin storage",
"description": "Reset application preferences and plugin activation while preserving plugin data and credentials. Store pinnedTabs as an explicit empty array so legacy browser caches cannot migrate cleared pins back to the server.",
"tags": [
"Maintenance"
],
+13 -2
View File
@@ -56,8 +56,19 @@ func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error {
return tx.Commit()
}
func (s *Store) ResetPreferences() error {
_, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`)
return err
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`); err != nil {
return err
}
// An explicit empty list prevents older devices from migrating their local pins after a reset.
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ('app.settings',?)`, `{"pinnedTabs":[]}`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err }
+28
View File
@@ -48,3 +48,31 @@ func TestOpenExistingUsesNativeEscapedPathWithoutCreating(t *testing.T) {
t.Fatalf("missing database was created: %v", err)
}
}
func TestResetPreferencesRollsBackIfPinResetFails(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
original := map[string]string{
"app.settings": `{"fontSize":24,"pinnedTabs":["note.md"]}`,
"plugins.enabled": `{"example":true}`,
}
for key, value := range original {
if err := st.SetSetting(key, value); err != nil {
t.Fatal(err)
}
}
if _, err := st.db.Exec(`CREATE TRIGGER reject_pin_reset BEFORE INSERT ON settings WHEN NEW.key='app.settings' BEGIN SELECT RAISE(ABORT,'intentional reset failure'); END`); err != nil {
t.Fatal(err)
}
if err := st.ResetPreferences(); err == nil {
t.Fatal("固定标签重置失败必须返回错误")
}
for key, value := range original {
if got, ok, err := st.Setting(key); err != nil || !ok || got != value {
t.Fatalf("重置失败未回滚 %s: %q, %v", key, got, err)
}
}
}