feat(ui): streamline workspace settings and remove sharing
Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI. Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback. Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
1 parent
a716794e41
commit
19aab631ec
63 files changed
+1077
-3343
No files matched your search
@@ -0,0 +1,109 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveSharingPreservesApplicationData(t *testing.T) {
|
||||
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, layout := range []string{"current", "legacy", "fresh"} {
|
||||
t.Run(layout, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rawmark.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if _, err := st.db.Exec(`
|
||||
INSERT INTO settings VALUES ('auth.password','password-hash'), ('plugin.setting','keep'),
|
||||
('share.secret','old-secret'),
|
||||
('app.settings','{"fontSize":18,"shortcuts":{"note.share":["Alt+S"],"app.shares":[],"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}');
|
||||
INSERT INTO sessions VALUES ('session-hash',123,456);
|
||||
INSERT INTO api_tokens VALUES ('token-id','automation','token-hash','read',123,456);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch layout {
|
||||
case "current":
|
||||
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
|
||||
CREATE TABLE share_items(share_token TEXT REFERENCES shares(token),item_id INTEGER,path TEXT);
|
||||
CREATE INDEX share_items_path ON share_items(path);
|
||||
INSERT INTO shares VALUES ('old-token','hash',123,0);
|
||||
INSERT INTO share_items VALUES ('old-token',0,'note.md');`)
|
||||
case "legacy":
|
||||
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
|
||||
CREATE INDEX shares_path ON shares(path);
|
||||
INSERT INTO shares VALUES ('old-token','note.md','hash',123,0);`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if _, err := st.db.Exec(string(script)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var remaining int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name IN ('shares','share_items','share_items_path','shares_path')`).Scan(&remaining); err != nil || remaining != 0 {
|
||||
t.Fatalf("重启后不应创建分享结构:%d %v", remaining, err)
|
||||
}
|
||||
if _, ok, err := st.Setting("share.secret"); err != nil || ok {
|
||||
t.Fatalf("分享密钥未清除:%v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"auth.password": "password-hash", "plugin.setting": "keep",
|
||||
"app.settings": `{"fontSize":18,"shortcuts":{"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}`,
|
||||
} {
|
||||
if got, ok, err := st.Setting(key); err != nil || !ok || got != want {
|
||||
t.Fatalf("非分享设置被改变:%s %v", key, err)
|
||||
}
|
||||
}
|
||||
var sessions, tokens int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash='session-hash' AND created_at=123 AND expires_at=456`).Scan(&sessions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM api_tokens WHERE id='token-id' AND name='automation' AND token_hash='token-hash' AND scope='read' AND created_at=123 AND last_used_at=456`).Scan(&tokens); err != nil || sessions != 1 || tokens != 1 {
|
||||
t.Fatalf("会话或 API 令牌被改变:%d %d %v", sessions, tokens, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveSharingRollsBackOnInvalidSettings(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "rawmark.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.db.Exec(`CREATE TABLE shares(token TEXT); INSERT INTO shares VALUES ('old-token'); INSERT INTO settings VALUES ('share.secret','old-secret'),('app.settings','invalid-json');`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(string(script)); err == nil {
|
||||
t.Fatal("设置损坏时必须报告错误")
|
||||
}
|
||||
if _, err := st.db.Exec("ROLLBACK"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM shares WHERE token='old-token'`).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("失败后分享数据未恢复:%v", err)
|
||||
}
|
||||
if got, ok, err := st.Setting("share.secret"); err != nil || !ok || got != "old-secret" {
|
||||
t.Fatalf("失败后分享密钥未恢复:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package store
|
||||
|
||||
import "database/sql"
|
||||
|
||||
const shareSchema = `
|
||||
-- 分享令牌是可复制的访问凭据,与笔记同等敏感。
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
token TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS share_items (
|
||||
share_token TEXT NOT NULL REFERENCES shares(token) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (share_token,item_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS share_items_path ON share_items(path);
|
||||
`
|
||||
|
||||
// 仅迁移项目已有的单路径表;事务保证中断或失败时仍保留原格式。
|
||||
func migrateShares(db *sql.DB) error {
|
||||
var legacy bool
|
||||
if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pragma_table_info('shares') WHERE name = 'path')`).Scan(&legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
if !legacy {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec(`ALTER TABLE shares RENAME TO shares_legacy;` + shareSchema + `
|
||||
INSERT INTO shares(token,password_hash,created_at,expires_at) SELECT token,password_hash,created_at,expires_at FROM shares_legacy;
|
||||
INSERT INTO share_items(share_token,item_id,path) SELECT token,0,path FROM shares_legacy;
|
||||
DROP TABLE shares_legacy;`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateSingleSharesAndPreserveLinks(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "app.db")
|
||||
legacy, err := sql.Open("sqlite", file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = legacy.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT NOT NULL,password_hash TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE INDEX shares_path ON shares(path);
|
||||
INSERT INTO shares VALUES ('old-token','资料/原文.md','hash',123,456);`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.Close()
|
||||
st, err := Open(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh, ok, err := st.GetShare("old-token")
|
||||
if err != nil || !ok || sh.Path != "资料/原文.md" || len(sh.Items) != 1 || sh.Items[0].ID != 0 || sh.PasswordHash != "hash" || sh.CreatedAt != 123 || sh.ExpiresAt != 456 {
|
||||
t.Fatalf("迁移改变了旧分享:%+v %v", sh, err)
|
||||
}
|
||||
if err := st.MoveShares("资料", "归档"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
st, err = Open(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
sh, ok, err = st.GetShare("old-token")
|
||||
if err != nil || !ok || sh.Path != "归档/原文.md" {
|
||||
t.Fatalf("重启或移动未保留链接:%+v %v", sh, err)
|
||||
}
|
||||
if _, err := st.DeleteShare(sh.Token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := st.db.QueryRow(`SELECT count(*) FROM share_items`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("撤销未清理主体:%d %v", count, err)
|
||||
}
|
||||
}
|
||||
+1
-126
@@ -1,4 +1,4 @@
|
||||
// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。
|
||||
// Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
` + shareSchema + `
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -38,10 +37,6 @@ func Open(path string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateShares(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("迁移分享数据失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
||||
@@ -108,126 +103,6 @@ func (s *Store) DeleteExpiredSessions() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 分享 ----
|
||||
|
||||
// Share 为一个只读分享链接,Items 标识各个独立跟随移动的主体。
|
||||
// Path 仅用于已有单主体调用与响应,不单独存储。
|
||||
type Share struct {
|
||||
Token string `json:"token"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Items []ShareItem `json:"items"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
type ShareItem struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateShare(sh Share) error {
|
||||
items := sh.Items
|
||||
if len(items) == 0 && sh.Path != "" {
|
||||
items = []ShareItem{{ID: 0, Path: sh.Path}}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("分享缺少主体")
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec(`INSERT INTO shares (token, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?)`, sh.Token, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err = tx.Exec(`INSERT INTO share_items (share_token, item_id, path) VALUES (?, ?, ?)`, sh.Token, item.ID, item.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) GetShare(token string) (sh Share, ok bool, err error) {
|
||||
err = s.db.QueryRow(`SELECT token, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token).Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Share{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sh, false, err
|
||||
}
|
||||
err = s.shareItems(&sh)
|
||||
return sh, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) shareItems(sh *Share) error {
|
||||
rows, err := s.db.Query(`SELECT item_id, path FROM share_items WHERE share_token = ? ORDER BY item_id`, sh.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item ShareItem
|
||||
if err := rows.Scan(&item.ID, &item.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
sh.Items = append(sh.Items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sh.Items) == 0 {
|
||||
return fmt.Errorf("分享缺少主体")
|
||||
}
|
||||
if len(sh.Items) == 1 {
|
||||
sh.Path = sh.Items[0].Path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Shares() ([]Share, error) {
|
||||
rows, err := s.db.Query(`SELECT token, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC, token`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Share{}
|
||||
for rows.Next() {
|
||||
var sh Share
|
||||
if err := rows.Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sh)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
if err := s.shareItems(&out[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteShare(token string) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) MoveShares(from, to string) error {
|
||||
_, err := s.db.Exec(`UPDATE share_items SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END
|
||||
WHERE path = ?1 OR substr(path,1,length(?1)+1) = ?1 || '/'`, from, to)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- API 令牌 ----
|
||||
|
||||
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write
|
||||
|
||||
Reference in new issue
Block a user