@@ -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+
10371065func 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 ("\n Use `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
11501342func 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
12731515var relayCmd = & cobra.Command {
0 commit comments