Skip to content

Commit 2c22403

Browse files
feat(cli): add local clipboard history manager (Slice 1)
1 parent 8d0a531 commit 2c22403

11 files changed

Lines changed: 920 additions & 2 deletions

File tree

cmd/pastelocal-remote/main.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ func main() {
4444
searchLimit := flag.Int("limit", 5, "max results for --search (1-20)")
4545
historyID := flag.String("id", "", "fetch specific history entry by stable ID (preferred over --index)")
4646

47+
// DirectPaste v1 test flag (for protocol validation during development)
48+
testDirectPaste := flag.Bool("test-direct-paste", false, "send a test DirectPasteRequest (development / protocol validation only)")
49+
4750
// Snippet mode: fetch a named snippet.
4851
snippet := flag.String("snippet", "", "fetch a named snippet by name")
4952

@@ -55,7 +58,7 @@ func main() {
5558

5659
flag.Parse()
5760

58-
os.Exit(run(*port, expandHome(*outDir), *timeout, expandHome(*tokenFile), *send, *sendFormat, *watch, *list, *index, *snippet, *relayURL, *relayPeer, *search, *searchLimit, *historyID))
61+
os.Exit(run(*port, expandHome(*outDir), *timeout, expandHome(*tokenFile), *send, *sendFormat, *watch, *list, *index, *snippet, *relayURL, *relayPeer, *search, *searchLimit, *historyID, *testDirectPaste))
5962
}
6063

6164
// expandHome replaces a leading ~ with the user's home directory.
@@ -79,7 +82,7 @@ func readToken(path string) (string, error) {
7982
return strings.TrimSpace(string(data)), nil
8083
}
8184

82-
func run(port int, outDir string, timeout time.Duration, tokenFile string, sendPath string, sendFormat string, doWatch bool, doList bool, index int, snippetName string, relayURL string, relayPeer string, searchQuery string, searchLimit int, historyID string) int {
85+
func run(port int, outDir string, timeout time.Duration, tokenFile string, sendPath string, sendFormat string, doWatch bool, doList bool, index int, snippetName string, relayURL string, relayPeer string, searchQuery string, searchLimit int, historyID string, testDirectPaste bool) int {
8386
token, err := readToken(tokenFile)
8487
if err != nil {
8588
fmt.Fprintf(os.Stderr, "error reading token: %v\n", err)
@@ -132,6 +135,11 @@ func run(port int, outDir string, timeout time.Duration, tokenFile string, sendP
132135
return runHistoryFetchByID(client, baseURL, token, outDir, historyID)
133136
}
134137

138+
// DirectPaste v1 test mode
139+
if testDirectPaste {
140+
return runTestDirectPaste(client, baseURL, token)
141+
}
142+
135143
// Default: fetch clipboard (read mode).
136144
// Check protocol version on first call.
137145
if code := checkVersion(client, baseURL, token); code != 0 {
@@ -1122,3 +1130,47 @@ func runHistoryFetchByID(client *http.Client, baseURL, token, outDir, id string)
11221130
}
11231131
return 0
11241132
}
1133+
1134+
// runTestDirectPaste is a development helper that exercises the DirectPaste v1 protocol.
1135+
// It sends a PasteRequest and prints the sequence of responses.
1136+
func runTestDirectPaste(client *http.Client, baseURL, token string) int {
1137+
reqBody := proto.DirectPasteRequest{
1138+
Type: "paste_request",
1139+
RequestID: "test-" + time.Now().Format("150405"),
1140+
ClientName: "pastelocal-remote-test",
1141+
}
1142+
1143+
bodyBytes, _ := json.Marshal(reqBody)
1144+
req, err := http.NewRequest(http.MethodPost, baseURL+"/clipboard/direct-paste", bytes.NewReader(bodyBytes))
1145+
if err != nil {
1146+
fmt.Fprintf(os.Stderr, "error creating request: %v\n", err)
1147+
return 10
1148+
}
1149+
req.Header.Set("Authorization", "Bearer "+token)
1150+
req.Header.Set("Content-Type", "application/json")
1151+
1152+
resp, err := client.Do(req)
1153+
if err != nil {
1154+
fmt.Fprintf(os.Stderr, "direct paste request failed: %v\n", err)
1155+
return 10
1156+
}
1157+
defer resp.Body.Close()
1158+
1159+
if resp.StatusCode != http.StatusOK {
1160+
fmt.Fprintf(os.Stderr, "daemon returned %d\n", resp.StatusCode)
1161+
return 10
1162+
}
1163+
1164+
dec := json.NewDecoder(resp.Body)
1165+
for dec.More() {
1166+
var raw map[string]interface{}
1167+
if err := dec.Decode(&raw); err != nil {
1168+
fmt.Fprintf(os.Stderr, "decode error: %v\n", err)
1169+
break
1170+
}
1171+
pretty, _ := json.MarshalIndent(raw, "", " ")
1172+
fmt.Println(string(pretty))
1173+
fmt.Println("---")
1174+
}
1175+
return 0
1176+
}

cmd/pastelocal/main.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os/exec"
1111
"path/filepath"
1212
"runtime"
13+
"strconv"
1314
"strings"
1415
"time"
1516

@@ -1034,6 +1035,33 @@ func init() {
10341035
rootCmd.AddCommand(snippetsCmd)
10351036
}
10361037

1038+
// ── history (Slice 1: list + get<index> only; modeled exactly on snippets group) ──
1039+
var historyCmd = &cobra.Command{
1040+
Use: "history",
1041+
Short: "Browse and retrieve local clipboard history entries",
1042+
GroupID: "daemon",
1043+
}
1044+
1045+
var historyListCmd = &cobra.Command{
1046+
Use: "list",
1047+
Short: "List recent clipboard history (reuse remote display style; 1-based index with 1=most recent)",
1048+
Args: cobra.NoArgs,
1049+
RunE: runHistoryList,
1050+
}
1051+
1052+
var historyGetCmd = &cobra.Command{
1053+
Use: "get <index>",
1054+
Short: "Fetch entry by 1-based index (most recent first), write to ~/.cache/pastelocal/ (0600), print abs path + .analysis.txt sidecar if present",
1055+
Args: cobra.ExactArgs(1),
1056+
RunE: runHistoryGet,
1057+
}
1058+
1059+
func init() {
1060+
historyCmd.AddCommand(historyListCmd)
1061+
historyCmd.AddCommand(historyGetCmd)
1062+
rootCmd.AddCommand(historyCmd)
1063+
}
1064+
10371065
func runSnippetSave(cmd *cobra.Command, args []string) error {
10381066
name := args[0]
10391067

@@ -1145,6 +1173,170 @@ func runSnippetRemove(cmd *cobra.Command, args []string) error {
11451173
return nil
11461174
}
11471175

1176+
func runHistoryList(cmd *cobra.Command, args []string) error {
1177+
cfg, err := loadConfig()
1178+
if err != nil {
1179+
return fail("%v", err)
1180+
}
1181+
1182+
if !isDaemonRunning(cfg.Port) {
1183+
return fail("daemon is not running. Start it with: pastelocal start")
1184+
}
1185+
1186+
token, err := getToken()
1187+
if err != nil {
1188+
return fail("failed to get token: %v", err)
1189+
}
1190+
1191+
items, err := listHistory(cfg.Port, token)
1192+
if err != nil {
1193+
return fail("failed to list history: %v", err)
1194+
}
1195+
1196+
if len(items) == 0 {
1197+
fmt.Println("No history entries found. Take a screenshot or copy some text first.")
1198+
return nil
1199+
}
1200+
1201+
// Reuse *exact* display style (headers + format) from remote runHistoryList (cmd/pastelocal-remote/main.go:548)
1202+
// Note server List() returns oldest-first; display index calc makes 1=most recent (matches remote).
1203+
// %d for ByteCount (int64) is valid (fmt promotion + identical in remote).
1204+
fmt.Printf("%-5s %-20s %-10s %s\n", "INDEX", "CAPTURED_AT", "FORMAT", "SIZE")
1205+
for i, item := range items {
1206+
displayIdx := len(items) - i
1207+
fmt.Printf("%-5d %-20s %-10s %d bytes\n", displayIdx, item.CapturedAt, item.Format, item.ByteCount)
1208+
}
1209+
fmt.Println("\nUse `pastelocal history get <index>` to retrieve (e.g. `get 1` for most recent)")
1210+
return nil
1211+
}
1212+
1213+
func runHistoryGet(cmd *cobra.Command, args []string) error {
1214+
indexStr := args[0]
1215+
index, parseErr := strconv.Atoi(strings.TrimSpace(indexStr))
1216+
if parseErr != nil || index < 1 {
1217+
return fail("invalid index %q (must be positive integer; 1 = most recent)", indexStr)
1218+
}
1219+
1220+
cfg, err := loadConfig()
1221+
if err != nil {
1222+
return fail("%v", err)
1223+
}
1224+
1225+
if !isDaemonRunning(cfg.Port) {
1226+
return fail("daemon is not running. Start it with: pastelocal start")
1227+
}
1228+
1229+
token, err := getToken()
1230+
if err != nil {
1231+
return fail("failed to get token: %v", err)
1232+
}
1233+
1234+
items, err := listHistory(cfg.Port, token)
1235+
if err != nil {
1236+
return fail("failed to list history: %v", err)
1237+
}
1238+
1239+
if len(items) == 0 {
1240+
return fail("no history entries found")
1241+
}
1242+
1243+
if index < 1 || index > len(items) {
1244+
return fail("invalid index %d (valid range: 1-%d)", index, len(items))
1245+
}
1246+
1247+
// 1=most recent = last in items (per server List + remote runHistoryFetch:606)
1248+
entryIdx := len(items) - index
1249+
entry := items[entryIdx]
1250+
1251+
clipResp, err := fetchHistoryEntry(cfg.Port, token, entry.ID)
1252+
if err != nil {
1253+
return fail("failed to fetch history entry: %v", err)
1254+
}
1255+
1256+
// Write to standard cache dir using exact pattern from local relay fetch (1731) + remote writeFile (488,501): 0700 dir, 0600 file.
1257+
// Filename style inspired by remote; uses entry ID prefix + ts for uniqueness (no new rand dep).
1258+
cacheDir := expandHome("~/.cache/pastelocal")
1259+
if err := os.MkdirAll(cacheDir, 0700); err != nil {
1260+
return fail("creating cache directory: %v", err)
1261+
}
1262+
1263+
var data []byte
1264+
ext := "bin"
1265+
switch clipResp.Format {
1266+
case "png":
1267+
var decErr error
1268+
data, decErr = base64.StdEncoding.DecodeString(clipResp.Image)
1269+
if decErr != nil {
1270+
return fail("decoding image: %v", decErr)
1271+
}
1272+
ext = "png"
1273+
case "jpeg", "jpg":
1274+
var decErr error
1275+
data, decErr = base64.StdEncoding.DecodeString(clipResp.Image)
1276+
if decErr != nil {
1277+
return fail("decoding image: %v", decErr)
1278+
}
1279+
ext = "jpg"
1280+
// Defensive fallback per review (latent server shape for non-png images in history fetch):
1281+
// handler populates Image only for png; other image formats would use Text field.
1282+
if len(data) == 0 && clipResp.Text != "" {
1283+
data = []byte(clipResp.Text)
1284+
}
1285+
case "gif":
1286+
var decErr error
1287+
data, decErr = base64.StdEncoding.DecodeString(clipResp.Image)
1288+
if decErr != nil {
1289+
return fail("decoding image: %v", decErr)
1290+
}
1291+
ext = "gif"
1292+
if len(data) == 0 && clipResp.Text != "" {
1293+
data = []byte(clipResp.Text)
1294+
}
1295+
case "text":
1296+
data = []byte(clipResp.Text)
1297+
ext = "txt"
1298+
default:
1299+
return fail("unknown clipboard format: %s", clipResp.Format)
1300+
}
1301+
1302+
ts := time.Now().Unix()
1303+
idPrefix := entry.ID
1304+
if len(entry.ID) >= 6 {
1305+
idPrefix = entry.ID[:6]
1306+
}
1307+
filename := fmt.Sprintf("pastelocal-%d-%s.%s", ts, idPrefix, ext)
1308+
path := filepath.Join(cacheDir, filename)
1309+
1310+
if err := os.WriteFile(path, data, 0600); err != nil {
1311+
return fail("writing file: %v", err)
1312+
}
1313+
1314+
absPath, err := filepath.Abs(path)
1315+
if err != nil {
1316+
absPath = path // fall back, do not leak extra
1317+
}
1318+
1319+
// Write .analysis.txt sidecar if present (exact reuse of remote logic at 661/670 and 1117)
1320+
if clipResp.Analysis != nil {
1321+
var analysisText string
1322+
if clipResp.Analysis.OCRText != "" {
1323+
analysisText += "OCR Text:\n" + clipResp.Analysis.OCRText + "\n\n"
1324+
}
1325+
if clipResp.Analysis.Description != "" {
1326+
analysisText += "Description:\n" + clipResp.Analysis.Description + "\n"
1327+
}
1328+
if analysisText != "" {
1329+
analysisPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".analysis.txt"
1330+
if writeErr := os.WriteFile(analysisPath, []byte(analysisText), 0600); writeErr != nil {
1331+
fmt.Fprintf(os.Stderr, "warning: failed to write analysis sidecar %s: %v\n", analysisPath, writeErr)
1332+
}
1333+
}
1334+
}
1335+
1336+
fmt.Println(absPath)
1337+
return nil
1338+
}
1339+
11481340
// Helper functions.
11491341

11501342
func isValidSnippetName(name string) bool {
@@ -1268,6 +1460,56 @@ func removeSnippet(port int, token, name string) error {
12681460
return nil
12691461
}
12701462

1463+
func listHistory(port int, token string) ([]proto.HistoryEntry, error) {
1464+
// Simple local-only adaptation of remote runHistoryList (509) + listSnippets (1232) pattern.
1465+
client := &http.Client{Timeout: 5 * time.Second}
1466+
req, _ := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/clipboard/history", port), nil)
1467+
req.Header.Set("Authorization", "Bearer "+token)
1468+
1469+
resp, err := client.Do(req)
1470+
if err != nil {
1471+
return nil, err
1472+
}
1473+
defer resp.Body.Close()
1474+
1475+
if resp.StatusCode != http.StatusOK {
1476+
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
1477+
}
1478+
1479+
var histResp proto.HistoryResponse
1480+
if err := json.NewDecoder(resp.Body).Decode(&histResp); err != nil {
1481+
return nil, err
1482+
}
1483+
if !histResp.OK {
1484+
return nil, fmt.Errorf("history request failed")
1485+
}
1486+
return histResp.Items, nil
1487+
}
1488+
1489+
func fetchHistoryEntry(port int, token, id string) (*proto.ClipboardResponse, error) {
1490+
// Simple local adaptation of fetchClipboard + remote /history/{id} fetch (610).
1491+
client := &http.Client{Timeout: 5 * time.Second}
1492+
url := fmt.Sprintf("http://127.0.0.1:%d/clipboard/history/%s", port, id)
1493+
req, _ := http.NewRequest("GET", url, nil)
1494+
req.Header.Set("Authorization", "Bearer "+token)
1495+
1496+
resp, err := client.Do(req)
1497+
if err != nil {
1498+
return nil, err
1499+
}
1500+
defer resp.Body.Close()
1501+
1502+
if resp.StatusCode != http.StatusOK {
1503+
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
1504+
}
1505+
1506+
var clipResp proto.ClipboardResponse
1507+
if err := json.NewDecoder(resp.Body).Decode(&clipResp); err != nil {
1508+
return nil, err
1509+
}
1510+
return &clipResp, nil
1511+
}
1512+
12711513
// ── relay / device pairing ───────────────────────────────────────────────────
12721514

12731515
var relayCmd = &cobra.Command{

0 commit comments

Comments
 (0)