diff --git a/db/migrations/000004_bookmarks.down.sql b/db/migrations/000004_bookmarks.down.sql index 5e6d5c79..13248684 100644 --- a/db/migrations/000004_bookmarks.down.sql +++ b/db/migrations/000004_bookmarks.down.sql @@ -1,3 +1,6 @@ DROP TABLE bookmark; DROP TABLE bookmark_order; DROP TABLE bookmark_cmd; + +ALTER TABLE line DROP COLUMN bookmarked; +ALTER TABLE line DROP COLUMN pinned; diff --git a/db/migrations/000004_bookmarks.up.sql b/db/migrations/000004_bookmarks.up.sql index 100caf01..35d3589e 100644 --- a/db/migrations/000004_bookmarks.up.sql +++ b/db/migrations/000004_bookmarks.up.sql @@ -16,6 +16,11 @@ CREATE TABLE bookmark_order ( CREATE TABLE bookmark_cmd ( bookmarkid varchar(36) NOT NULL, + sessionid varchar(36) NOT NULL, cmdid varchar(36) NOT NULL, - PRIMARY KEY (bookmarkid, cmdid) + PRIMARY KEY (bookmarkid, sessionid, cmdid) ); + +ALTER TABLE line ADD COLUMN bookmarked boolean NOT NULL DEFAULT 0; +ALTER TABLE line ADD COLUMN pinned boolean NOT NULL DEFAULT 0; + diff --git a/db/schema.sql b/db/schema.sql index 1a03c50a..8236fc20 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -97,7 +97,7 @@ CREATE TABLE line ( ephemeral boolean NOT NULL, contentheight int NOT NULL, star int NOT NULL, - archived boolean NOT NULL, renderer varchar(50) NOT NULL DEFAULT '', + archived boolean NOT NULL, renderer varchar(50) NOT NULL DEFAULT '', bookmarked boolean NOT NULL DEFAULT 0, pinned boolean NOT NULL DEFAULT 0, PRIMARY KEY (sessionid, windowid, lineid) ); CREATE TABLE remote ( @@ -181,6 +181,7 @@ CREATE TABLE bookmark_order ( ); CREATE TABLE bookmark_cmd ( bookmarkid varchar(36) NOT NULL, + sessionid varchar(36) NOT NULL, cmdid varchar(36) NOT NULL, - PRIMARY KEY (bookmarkid, cmdid) + PRIMARY KEY (bookmarkid, sessionid, cmdid) ); diff --git a/pkg/cmdrunner/cmdrunner.go b/pkg/cmdrunner/cmdrunner.go index 8ead6891..8fb53381 100644 --- a/pkg/cmdrunner/cmdrunner.go +++ b/pkg/cmdrunner/cmdrunner.go @@ -157,6 +157,8 @@ func init() { registerCmdFn("line", LineCommand) registerCmdFn("line:show", LineShowCommand) registerCmdFn("line:star", LineStarCommand) + registerCmdFn("line:bookmark", LineBookmarkCommand) + registerCmdFn("line:pin", LinePinCommand) registerCmdFn("line:archive", LineArchiveCommand) registerCmdFn("line:purge", LinePurgeCommand) registerCmdFn("line:setheight", LineSetHeightCommand) @@ -1900,6 +1902,58 @@ func LineSetHeightCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) return nil, nil } +func LineBookmarkCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_Window) + if err != nil { + return nil, err + } + if len(pk.Args) == 0 { + return nil, fmt.Errorf("/line:bookmark requires an argument (line number or id)") + } + lineArg := pk.Args[0] + lineId, err := sstore.FindLineIdByArg(ctx, ids.SessionId, ids.WindowId, lineArg) + if err != nil { + return nil, fmt.Errorf("error looking up lineid: %v", err) + } + if lineId == "" { + return nil, fmt.Errorf("line %q not found", lineArg) + } + lineObj, cmdObj, err := sstore.GetLineCmdByLineId(ctx, ids.SessionId, ids.WindowId, lineId) + if err != nil { + return nil, fmt.Errorf("/line:bookmark error getting line: %v", err) + } + if cmdObj == nil { + return nil, fmt.Errorf("cannot bookmark non-cmd line") + } + ck := base.MakeCommandKey(lineObj.SessionId, cmdObj.CmdId) + bm := &sstore.BookmarkType{ + BookmarkId: uuid.New().String(), + CreatedTs: time.Now().UnixMilli(), + CmdStr: cmdObj.CmdStr, + Alias: "", + Tags: nil, + Description: "", + CmdIds: []base.CommandKey{ck}, + } + err = sstore.InsertBookmark(ctx, bm) + if err != nil { + return nil, fmt.Errorf("cannot insert bookmark: %v", err) + } + newLineObj, err := sstore.GetLineById(ctx, ids.SessionId, ids.WindowId, lineId) + if err != nil { + return nil, fmt.Errorf("/line:bookmark error getting line: %v", err) + } + if newLineObj == nil { + // no line (which is strange given we checked for it above). just return a nop. + return nil, nil + } + return sstore.ModelUpdate{Line: newLineObj}, nil +} + +func LinePinCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + return nil, nil +} + func LineStarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_Window) if err != nil { @@ -1930,7 +1984,7 @@ func LineStarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sst if err != nil { return nil, fmt.Errorf("/line:star error updating star value: %v", err) } - lineObj, err := sstore.GetLineById(ctx, lineId) + lineObj, err := sstore.GetLineById(ctx, ids.SessionId, ids.WindowId, lineId) if err != nil { return nil, fmt.Errorf("/line:star error getting line: %v", err) } @@ -1965,7 +2019,7 @@ func LineArchiveCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) ( if err != nil { return nil, fmt.Errorf("/line:archive error updating hidden status: %v", err) } - lineObj, err := sstore.GetLineById(ctx, lineId) + lineObj, err := sstore.GetLineById(ctx, ids.SessionId, ids.WindowId, lineId) if err != nil { return nil, fmt.Errorf("/line:archive error getting line: %v", err) } diff --git a/pkg/sstore/dbops.go b/pkg/sstore/dbops.go index 13d137d3..af644338 100644 --- a/pkg/sstore/dbops.go +++ b/pkg/sstore/dbops.go @@ -719,8 +719,8 @@ func InsertLine(ctx context.Context, line *LineType, cmd *CmdType) error { query = `SELECT nextlinenum FROM window WHERE sessionid = ? AND windowid = ?` nextLineNum := tx.GetInt(query, line.SessionId, line.WindowId) line.LineNum = int64(nextLineNum) - query = `INSERT INTO line ( sessionid, windowid, userid, lineid, ts, linenum, linenumtemp, linelocal, linetype, text, cmdid, renderer, ephemeral, contentheight, star, archived) - VALUES (:sessionid,:windowid,:userid,:lineid,:ts,:linenum,:linenumtemp,:linelocal,:linetype,:text,:cmdid,:renderer,:ephemeral,:contentheight,:star,:archived)` + query = `INSERT INTO line ( sessionid, windowid, userid, lineid, ts, linenum, linenumtemp, linelocal, linetype, text, cmdid, renderer, ephemeral, contentheight, star, archived, bookmarked, pinned) + VALUES (:sessionid,:windowid,:userid,:lineid,:ts,:linenum,:linenumtemp,:linelocal,:linetype,:text,:cmdid,:renderer,:ephemeral,:contentheight,:star,:archived,:bookmarked,:pinned)` tx.NamedExec(query, line) query = `UPDATE window SET nextlinenum = ? WHERE sessionid = ? AND windowid = ?` tx.Exec(query, nextLineNum+1, line.SessionId, line.WindowId) @@ -899,6 +899,8 @@ func cleanSessionCmds(ctx context.Context, sessionId string) error { removedCmds = tx.SelectStrings(query, sessionId, sessionId) query = `DELETE FROM cmd WHERE sessionid = ? AND cmdid NOT IN (SELECT cmdid FROM line WHERE sessionid = ?)` tx.Exec(query, sessionId, sessionId) + query = `DELETE FROM bookmark_cmd WHERE sessionid = ? AND cmdid NOT IN (SELECT cmdid FROM cmd WHERE sessionid = ?)` + tx.Exec(query, sessionId, sessionId) return nil }) if txErr != nil { @@ -1391,6 +1393,8 @@ func DeleteSession(ctx context.Context, sessionId string) (UpdatePacket, error) tx.Exec(query, sessionId) query = `DELETE FROM cmd WHERE sessionid = ?` tx.Exec(query, sessionId) + query = `DELETE FROM bookmark_cmd WHERE sessionid = ?` + tx.Exec(query, sessionId) newActiveSessionId, _ = fixActiveSessionId(tx.Context()) return nil }) @@ -1825,12 +1829,12 @@ func UpdateLineHeight(ctx context.Context, lineId string, heightVal int) error { } // can return nil, nil if line is not found -func GetLineById(ctx context.Context, lineId string) (*LineType, error) { +func GetLineById(ctx context.Context, sessionId string, windowId string, lineId string) (*LineType, error) { var rtn *LineType txErr := WithTx(ctx, func(tx *TxWrap) error { var line LineType - query := `SELECT * FROM line WHERE lineid = ?` - found := tx.Get(&line, query, lineId) + query := `SELECT * FROM line WHERE sessionid = ? AND windowid = ? AND lineid = ?` + found := tx.Get(&line, query, sessionId, windowId, lineId) if found { rtn = &line } @@ -2033,3 +2037,67 @@ func GetDBVersion(ctx context.Context) (int, error) { }) return version, txErr } + +func InsertBookmark(ctx context.Context, bm *BookmarkType) error { + if bm == nil || bm.BookmarkId == "" { + return fmt.Errorf("invalid empty bookmark id") + } + txErr := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT bookmarkid FROM bookmark WHERE bookmarkid = ?` + if tx.Exists(query, bm.BookmarkId) { + return fmt.Errorf("bookmarkid already exists") + } + query = `INSERT INTO bookmark ( bookmarkid, createdts, cmdstr, alias, tags, description) + VALUES (:bookmarkid,:createdts,:cmdstr,:alias,:tags,:description)` + tx.NamedExec(query, bm.ToMap()) + for _, tag := range append(bm.Tags, "") { + query = `SELECT COALESCE(max(orderidx), 0) FROM bookmark_order WHERE tag = ?` + maxOrder := tx.GetInt(query, tag) + query = `INSERT INTO bookmark_order (tag, bookmarkid, orderidx) VALUES (?, ?, ?)` + tx.Exec(query, tag, bm.BookmarkId, maxOrder+1) + } + query = `INSERT INTO bookmark_cmd (bookmarkid, sessionid, cmdid) VALUES (?, ?, ?)` + for _, ck := range bm.CmdIds { + tx.Exec(query, bm.BookmarkId, ck.GetSessionId(), ck.GetCmdId()) + } + query = `UPDATE line SET bookmarked = 1 WHERE sessionid = ? AND cmdid = ?` + for _, ck := range bm.CmdIds { + tx.Exec(query, ck.GetSessionId(), ck.GetCmdId()) + } + return nil + }) + return txErr +} + +func fixupBookmarkOrder(tx *TxWrap) { + query := ` +WITH new_order AS ( + SELECT tag, bookmarkid, row_number() OVER (PARTITION BY tag ORDER BY orderidx) AS newidx FROM bookmark_order +) +UPDATE bookmark_order +SET orderidx = new_order.newidx +FROM new_order +WHERE bookmark_order.tag = new_order.tag AND bookmark_order.bookmarkid = new_order.bookmarkid +` + tx.Exec(query) +} + +func DeleteBookmark(ctx context.Context, bookmarkId string) error { + txErr := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT bookmarkid FROM bookmark WHERE bookmarkid = ?` + if !tx.Exists(query, bookmarkId) { + return fmt.Errorf("bookmark not found") + } + query = `DELETE FROM bookmark WHERE bookmarkid = ?` + tx.Exec(query, bookmarkId) + query = `DELETE FROM bookmark_order WHERE bookmarkid = ?` + tx.Exec(query, bookmarkId) + query = `UPDATE line SET bookmarked = 0 WHERE bookmarked AND cmdid <> '' AND (sessionid||cmdid) IN (SELECT sessionid||cmdid FROM bookmark_cmd WHERE bookmarkid = ?) ` + tx.Exec(query, bookmarkId) + query = `DELETE FROM bookmark_cmd WHERE bookmarkid = ?` + tx.Exec(query, bookmarkId) + fixupBookmarkOrder(tx) + return nil + }) + return txErr +} diff --git a/pkg/sstore/sstore.go b/pkg/sstore/sstore.go index 5683b168..4099dbda 100644 --- a/pkg/sstore/sstore.go +++ b/pkg/sstore/sstore.go @@ -650,10 +650,47 @@ type LineType struct { Ephemeral bool `json:"ephemeral,omitempty"` ContentHeight int64 `json:"contentheight,omitempty"` Star bool `json:"star,omitempty"` + Bookmarked bool `json:"bookmarked,omitempty"` + Pinned bool `json:"pinned,omitempty"` Archived bool `json:"archived,omitempty"` Remove bool `json:"remove,omitempty"` } +type BookmarkType struct { + BookmarkId string `json:"bookmarkid"` + CreatedTs int64 `json:"createdts"` + CmdStr string `json:"cmdstr"` + Alias string `json:"alias,omitempty"` + Tags []string `json:"tags"` + Description string `json:"description"` + CmdIds []base.CommandKey `json:"cmdids"` +} + +func (bm *BookmarkType) ToMap() map[string]interface{} { + rtn := make(map[string]interface{}) + rtn["bookmarkid"] = bm.BookmarkId + rtn["createdts"] = bm.CreatedTs + rtn["cmdstr"] = bm.CmdStr + rtn["alias"] = bm.Alias + rtn["description"] = bm.Description + rtn["tags"] = quickJsonArr(bm.Tags) + return rtn +} + +func BookmarkFromMap(m map[string]interface{}) *BookmarkType { + if len(m) == 0 { + return nil + } + var bm BookmarkType + quickSetStr(&bm.BookmarkId, m, "bookmarkid") + quickSetInt64(&bm.CreatedTs, m, "createdts") + quickSetStr(&bm.Alias, m, "alias") + quickSetStr(&bm.CmdStr, m, "cmdstr") + quickSetStr(&bm.Description, m, "description") + quickSetJsonArr(&bm.Tags, m, "tags") + return &bm +} + type ResolveItem struct { Name string Num int