title: Clipper Network Clipboard v3 tags: python net clipboard net-clipboard This is a saner rewrite of [NetworkClipboard2](/lang/python/net/tcp/NetworkClipboard2). The server uses the same [MariaDB]() tables. All in all, it's just under 1400 lines of PHP and Python combined. (Small enough not to need software engineering skills and practices in order to manage the codebase.) # Usage ``` clipclient put garden:turnip myfile # copy myfile to clipboard named turnip in namespace garden echo hello world | clipclient put hello:world # from stdin echo hello world | clipclient put hello:world myfile - # read from myfile, then from stdin clipclient get garden:turnip # get a clip clipclient history garden:turnip # list all versions of a clip clipclient list 'garden:^t.*p$' # list all clips in namespace garden matching regex ^t.*p$ clipclient listall '^t.*$' # like list, but across all namespaces ``` I then make shorthand commands like e.g. `cget` ```bash #!/bin/bash export BACKEND=http://me.com/path/to/clip.php clipclient get "$@" ``` # Server ## clip.php Here `bouncer.php` checks auth credentials and bails out with an error if the user doesn't have the right credentials, else does nothing. Naturally I'm not including its contents here. But essentially it has one line: `if( !userIsAuthorised() ) { die(); }` ```php go(); ``` ## Clipper.php ```php name_re,$string); } static function isRegularExpression($string) { set_error_handler(function() {}, E_WARNING); $isRegularExpression = preg_match($string, "") !== FALSE; restore_error_handler(); return $isRegularExpression; } function __construct($host,$port,$user,$pass,$db,$prefix,$name_re) { $this->host = $host; $this->port = $port; $this->user = $user; $this->pass = $pass; $this->db = $db; $this->prefix = $prefix; $this->name_re = $name_re; $this->responsecode = 500; $this->result = ["ouch" => "result not set"]; } function set_result($result,$responsecode = 200) { $this->result = $result; $this->responsecode = $responsecode; } function go() { must_post(); $conn = $this->conn = new \mysqli($this->host, $this->user, $this->pass, $this->db); if ($conn->connect_error) { serve_error_json("connerror","SQL Connect Error",500); } $req = get_json_from_post(); if( !isset($req['request']) ) { return serve_error_json("norequest","No request",400); } $request = $req['request']; $method = "req_".$request; // see if req_method exists, if so, call it, passing the req object if( !method_exists($this,$method) ) { return serve_error_json("invalidrequest","Invalid request '$request'",400); } $this->$method($req); $this->conn->close(); $this->result["request"] = $req; serve_json($this->result,$this->responsecode); } # cfind2j.php { request: find, pattern, namespace? } // find clips containing X using match( pattern ) against in sql function req_find($req) { if( !isset($req["pattern"]) ) { $pattern = ""; } else { $pattern = $req["pattern"]; } if( !isset($req["namespace"]) ) { $namespace = null; } else { $namespace = $req["namespace"]; } if( !is_null($namespace) && !$this->isValidName($namespace) ) { serve_error_json("invalidnamespace","Invalid namespace",400); } if( !isset($req["limit"]) ) { $limit = 20; } else { $limit = intval($req["limit"]); $limit = min(max(1,$limit),1000); } $conn = $this->conn; $prefix = $this->prefix; if( is_null($namespace) ) { $sql = "SELECT id, namespace, name FROM {$prefix}_clips WHERE MATCH( value ) AGAINST ( ? ) LIMIT $limit"; $stmt = $conn->prepare($sql); $stmt->bind_param("s",$pattern); } else { $sql = "SELECT id, namespace, name FROM {$prefix}_clips WHERE MATCH( value ) AGAINST ( ? ) AND namespace = ? LIMIT $limit"; $stmt = $conn->prepare($sql); $stmt->bind_param("ss",$pattern,$namespace); } $error = false; $found = false; $output = []; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt->get_result(); while($row = $result->fetch_assoc()) { array_push($output,$row); } if( count($output) == 0 ) { $this->set_result([ "namespace" => $namespace, "pattern" => $pattern, "message" => "not found", ],404); } $this->set_result([ "namespace" => $namespace, "pattern" => $pattern, "message" => "success", "matches" => $output ],200); } function get_name_ns($req) { if( !isset($req["name"]) ) { serve_error_json("noname","No name",400); } $this->name = $req["name"]; if( !$this->isValidName($this->name) ) { serve_error_json("invalidname","Invalid name",400); } if( !isset($req["namespace"]) ) { $this->namespace = "_"; } else { $this->namespace = $req["namespace"]; } if( !$this->isValidName($this->namespace) ) { serve_error_json("invalidnamespace","Invalid namespace",400); } } # cget2j.php { request: get, name, namespace? } // get content function req_get($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $conn = $this->conn; $prefix = $this->prefix; $sql = "SELECT {$prefix}_pointers.clipid, {$prefix}_clips.value, {$prefix}_clips.time FROM {$prefix}_pointers JOIN {$prefix}_clips ON ({$prefix}_pointers.clipid = {$prefix}_clips.id) WHERE ( {$prefix}_pointers.namespace = ? AND {$prefix}_pointers.name = ? )"; $stmt = $conn->prepare($sql); $stmt->bind_param("ss", $namespace,$name); $error = false; $found = false; $output = null; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt->get_result(); while($row = $result->fetch_assoc()) { $output = $row['value']; break; } if( $output == null ) { return $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "not found"], 404); } $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "success", "value" => $output ]); } # getlast ns:name num_items char_limit [-r] function req_getlast($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $char_limit = 1024; $num_items = 20; if( isset($req["char_limit"]) ) { $char_limit = intval($req["char_limit"]); } if( $char_limit < 1 ) { serve_error_json("invalidcharlimit","Invalid character limit (<1)",400); } if( isset($req["num_items"]) ) { $num_items = intval($req["num_items"]); } if( $num_items < 1 ) { serve_error_json("invalidnumitems","Invalid number of items (<1)",400); } $conn = $this->conn; $prefix = $this->prefix; $sql = "SELECT name, SUBSTRING(value FROM 1 FOR ?), time FROM {$prefix}_clips WHERE ( namespace = ? AND name = ? ) ORDER BY time DESC LIMIT ?"; $stmt = $conn->prepare($sql); $stmt->bind_param("ssss", $char_limit, $namespace, $name, $num_items); $error = false; $found = false; $output = []; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt->get_result(); while($row = $result->fetch_row()) { array_push($output,$row); } if( count($output) == 0 ) { return $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "not found"], 404); } $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "success", "items" => $output ]); } # cput2j.php { request: put, name, namespace?, value } function req_put($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $conn = $this->conn; $prefix = $this->prefix; if( $name === "" ) { serve_error_json("emptyname","Name must not be empty string",400); } $append = false; if( isset($_GET['append']) ) { $append = $_GET['append'] == "y"; } if( !isset($req["value"]) ) { serve_error_json("novalue","No value",400); } $value = $req["value"]; $prev = ""; if( $append ) { $sql = "SELECT {$prefix}_pointers.clipid, {$prefix}_clips.value, {$prefix}_clips.time FROM {$prefix}_pointers JOIN {$prefix}_clips ON ({$prefix}_pointers.clipid = {$prefix}_clips.id) WHERE ( {$prefix}_pointers.namespace = ? AND {$prefix}_pointers.name = ? )"; $stmt = $conn->prepare($sql); $stmt->bind_param("ss", $namespace,$name); $output = null; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt->get_result(); while($row = $result->fetch_assoc()) { $output = $row['value']; break; } if( !is_null($output) ) { $prev = rtrim($output)."\n"; } } $value = $prev.$value; $sql1 = $conn->prepare("START TRANSACTION"); $sql2 = $conn->prepare("INSERT INTO {$prefix}_clips (namespace,name,value) VALUES ( ?, ?, ? )"); $sql2->bind_param("sss",$namespace,$name,$value); $sql3 = $conn->prepare("SET @a = (SELECT LAST_INSERT_ID())"); $sql4 = $conn->prepare("INSERT INTO {$prefix}_pointers (namespace,name,clipid) VALUES ( ?, ?, @a ) ON DUPLICATE KEY UPDATE clipid=(@a)"); $sql4->bind_param("ss",$namespace,$name); $sql5 = $conn->prepare("COMMIT"); if( !$sql1->execute() ) { serve_error_json("sqlerror","SQL Error put sql1",500); } if( !$sql2->execute() ) { serve_error_json("sqlerror","SQL Error put sql2",500); } if( !$sql3->execute() ) { serve_error_json("sqlerror","SQL Error put sql3",500); } if( !$sql4->execute() ) { serve_error_json("sqlerror","SQL Error put sql4",500); } if( !$sql5->execute() ) { serve_error_json("sqlerror","SQL Error put sql5",500); } $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "inserted" ]); } function req_listall($req) { if( !isset($req["pattern"]) ) { $pattern = ""; } else { $pattern = $req["pattern"]; } if( !self::isRegularExpression('/'.$pattern.'/') ) { serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400); } $conn = $this->conn; $prefix = $this->prefix; $sql = "SELECT name, namespace, time FROM {$prefix}_clips ORDER BY time"; $stmt = $conn->prepare($sql); $error = false; $found = false; $names = []; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error listall sql1",500); } $result = $stmt->get_result(); $already = []; while($row = $result->fetch_assoc()) { $x = $row['name']; $namespace = $row['namespace']; $time = $row['time']; $nns = "$namespace:$x"; if( array_key_exists($nns,$already) ) { continue; } $already[$nns] = true; if( @preg_match("/$pattern/",$x) ) { array_push($names,[$nns,$time]); } } if( count($names) == 0 ) { return $this->set_result([ "name" => $pattern, "message" => "not found", ],404); } $this->set_result([ "pattern" => $pattern, "message" => "success", "names" => $names ],200); } # clist2j.php { request: list, pattern, namespace? } function req_list($req) { if( !isset($req["pattern"]) ) { $pattern = ""; //serve_error_json("nopattern","No pattern",400); } else { $pattern = $req["pattern"]; } if( !self::isRegularExpression('/'.$pattern.'/') ) { serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400); } if( !isset($req["namespace"]) ) { $namespace = "_"; } else { $namespace = $req["namespace"]; } if( !$this->isValidName($namespace) ) { serve_error_json("invalidnamespace","Invalid namespace",400); } $conn = $this->conn; $prefix = $this->prefix; $sql = "SELECT name, namespace, time FROM {$prefix}_clips WHERE ( namespace = ? ) ORDER BY time"; $stmt = $conn->prepare($sql); $stmt->bind_param("s", $namespace); $error = false; $found = false; $names = []; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error list sql1",500); } $result = $stmt->get_result(); $already = []; while($row = $result->fetch_assoc()) { $x = $row['name']; $namespace = $row['namespace']; $time = $row['time']; $nns = "$x"; if( array_key_exists($nns,$already) ) { continue; } $already[$nns] = true; if( @preg_match("/$pattern/",$x) ) { array_push($names,[$nns,$time]); } } if( count($names) == 0 ) { return $this->set_result([ "name" => $pattern, "message" => "not found", ],404); } $this->set_result([ "pattern" => $pattern, "message" => "success", "names" => $names ],200); } # cgeth2j.php { request: getbyindex, name, namespace? } // clips indexed in chrono order from 0..n function req_getbyindex($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $conn = $this->conn; $prefix = $this->prefix; } # cgetn2j.php { request: getbyid, id } // get content by id not namespace:name function req_getbyid($req) { if( !isset($req["id"]) ) { serve_error_json("noclipid","No clip id",400); } $clipid = $req["id"]; if( !preg_match('/^\d+$/',$clipid) ) { serve_error_json("invalidclipid","Clip id is not a nonnegative integer",500); } $clipid = intval($clipid); $conn = $this->conn; $prefix = $this->prefix; $sql = "SELECT namespace,name, value FROM {$prefix}_clips WHERE id = ?"; $stmt = $conn->prepare($sql); $stmt->bind_param("s", $clipid); $found = false; if( !$stmt->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt->get_result(); while($row = $result->fetch_assoc()) { $namespace = $row['namespace']; $name = $row['name']; $value = $row['value']; $found = true; } if( !$found ) { $this->set_result([ "clipid" => $clipid, "message" => "not found", ],404); } $this->set_result([ "clipid" => $clipid, "namespace" => $namespace, "name" => $name, "message" => "success", "value" => $value ],200); } # chist2j.php { request: history, name, namespace? } function req_history_regex($req) { if( !isset($req["pattern"]) ) { $pattern = ""; //serve_error_json("nopattern","No pattern",400); } else { $pattern = $req["pattern"]; } if( !self::isRegularExpression('/'.$pattern.'/') ) { serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400); } if( !isset($req["namespace"]) ) { $namespace = "_"; } else { $namespace = $req["namespace"]; } if( !$this->isValidName($namespace) ) { serve_error_json("invalidnamespace","Invalid namespace",400); } $conn = $this->conn; $prefix = $this->prefix; $sql1 = "SELECT name, clipid FROM {$prefix}_pointers WHERE namespace = ?"; $sql2 = "SELECT id, time FROM {$prefix}_clips WHERE ( namespace = ? AND name = ? )"; $name = ""; $stmt1 = $conn->prepare($sql1); $stmt2 = $conn->prepare($sql2); $stmt1->bind_param("s", $namespace); $stmt2->bind_param("ss", $namespace, $name); $error = false; $found = false; $output = []; if( !$stmt1->execute() ) { serve_error_json("sqlerror","SQL Error",500); } $result = $stmt1->get_result(); $names = []; $currents = []; while($row = $result->fetch_assoc()) { $x = $row['name']; if( @preg_match("/$pattern/",$x) ) { $currents[$namespace.":".$x] = $row["clipid"]; array_push($names,$x); } } foreach($names as $name) { if( !$stmt2->execute() ) { serve_error_json("sqlerror","SQL Error history2",500); } $result = $stmt2->get_result(); while($row = $result->fetch_assoc()) { array_push($output,[ $row['id'], $namespace, $name, $row['time']]); } } if( count($output) == 0 ) { $this->set_result([ "namespace" => $namespace, "pattern" => $pattern, "message" => "not found", ],404); } $this->set_result([ "namespace" => $namespace, "name" => $pattern, "message" => "success", "clips" => $output, "currents" => $currents ],200); } function req_history($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $conn = $this->conn; $prefix = $this->prefix; $sql1 = "SELECT clipid FROM {$prefix}_pointers WHERE ( namespace = ? AND name = ? )"; $sql2 = "SELECT id, time FROM {$prefix}_clips WHERE ( namespace = ? AND name = ? ) ORDER BY time "; $stmt1 = $conn->prepare($sql1); $stmt1->bind_param("ss", $namespace, $name); $stmt2 = $conn->prepare($sql2); $stmt2->bind_param("ss", $namespace, $name); $error = false; $found = false; $output = []; if( !$stmt1->execute() ) { serve_error_json("sqlerror","SQL Error history2",500); } $result = $stmt1->get_result(); if( $result->num_rows > 0 ) { $row = $result->fetch_assoc(); $current = $row['clipid']; if( !$stmt2->execute() ) { serve_error_json("sqlerror","SQL Error history2",500); } $result = $stmt2->get_result(); while($row = $result->fetch_assoc()) { array_push($output,[ $row['id'], $namespace, $name, $row['time']]); } } if( count($output) == 0 ) { $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "not found", ],404); return; } $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "success", "clips" => $output, "current" => $current ],200); } # clistns2j.php { request: listns, pattern } // list all namespaces whose name matches pattern function req_listns($req) { if( !isset($req["pattern"]) ) { $pattern = ""; //serve_error_json("nopattern","No pattern",400); } else { $pattern = $req["pattern"]; } if( !self::isRegularExpression('/'.$pattern.'/') ) { serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400); } $conn = $this->conn; $prefix = $this->prefix; $sql1 = "SELECT DISTINCT namespace FROM {$prefix}_pointers"; $stmt1 = $conn->prepare($sql1); $error = false; $found = false; $namespaces = []; if( !$stmt1->execute() ) { serve_error_json("sqlerror","SQL Error listns sql1",500); } $result = $stmt1->get_result(); while($row = $result->fetch_assoc()) { $x = $row['namespace']; if( @preg_match("/$pattern/",$x) ) { array_push($namespaces,"$x"); } } if( count($namespaces) == 0 ) { $this->set_result([ "pattern" => $pattern, "message" => "not found", ],404); } $this->set_result([ "pattern" => $pattern, "message" => "success", "namespaces" => $namespaces ],200); } # crec2j.php { request: recent, pattern, namespace?, limit? } // list most recent matching pattern (and namespace?) return limit items function req_recent($req) { if( !isset($req["pattern"]) ) { $pattern = ""; //serve_error_json("nopattern","No pattern",400); } else { $pattern = $req["pattern"]; } if( !self::isRegularExpression('/'.$pattern.'/') ) { serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400); } if( !isset($req["namespace"]) ) { $namespace = "_"; } else { $namespace = $req["namespace"]; } if( !$this->isValidName($namespace) ) { serve_error_json("invalidnamespace","Invalid namespace",400); } if( !isset($req["limit"]) ) { $limit = 20; } else { $limit = intval($req["limit"]); $limit = min(max(1,$limit),100); } $conn = $this->conn; $prefix = $this->prefix; if( is_null($namespace) ) { $sql1 = "SELECT id, name, namespace, time FROM {$prefix}_clips ORDER BY id DESC LIMIT 20"; $stmt1 = $conn->prepare($sql1); } else { $sql1 = "SELECT id, name, namespace, time FROM {$prefix}_clips WHERE namespace = ? ORDER BY id DESC LIMIT $limit"; $stmt1 = $conn->prepare($sql1); $stmt1->bind_param("s", $namespace); } $error = false; $found = false; $names = []; if( !$stmt1->execute() ) { serve_error_json("sqlerror","SQL Error recent",500); } $result = $stmt1->get_result(); while($row = $result->fetch_assoc()) { $x = $row['name']; $id = $row['id']; $time = $row['time']; $ns = $row['namespace']; if( @preg_match("/$pattern/",$x) ) { array_push($names,[$id,$ns,$x,$time]); } } if( count($names) == 0 ) { $this->set_result([ "namespace" => $namespace, "pattern" => $pattern, "message" => "not found", ],404); } $this->set_result([ "namespace" => $namespace, "pattern" => $pattern, "message" => "success", "names" => $names ],200); } # cset2j.php { request: set, name, namespace?, id } // set current clip for namespace:name to id function req_set($req) { $this->get_name_ns($req); $name = $this->name; $namespace = $this->namespace; $conn = $this->conn; $prefix = $this->prefix; if( !isset($req["id"]) ) { serve_error_json("noclipid","No clip id",400); } $clipid = $req["id"]; if( !preg_match('/^\d+$/',$clipid) ) { serve_error_json("invalidclipid","Clip id is not a nonnegative integer",500); } $clipid = intval($clipid); $sql_find = "SELECT id FROM {$prefix}_clips WHERE ( namespace = ? AND name = ? )"; $stmt_find=$conn->prepare($sql_find); $stmt_find->bind_param("ss",$namespace,$name); if( !$stmt_find->execute() ) { serve_error_json("sqlerror","SQL Error sql_set find",500); } # validate clipid to namespace # later add a ccp to copy from one namespace:name or id # to another. Two commands: ccp namespace:name and ccpn id namespace:name $result = $stmt_find->get_result(); $clipids = []; while($row = $result->fetch_assoc()) { $x = $row['id']; array_push($clipids,$x); } $clipid_valid = in_array($clipid,$clipids); if( $clipid_valid ) { $sql_set = "UPDATE {$prefix}_pointers SET clipid = ? WHERE namespace = ? AND name = ?"; $stmt_set=$conn->prepare($sql_set); $stmt_set->bind_param("sss",$clipid,$namespace,$name); if( !$stmt_set->execute() ) { serve_error_json("sqlerror","SQL Error sql_set set",500); } } $conn->commit(); if( $clipid_valid ) { $this->set_result([ "namespace" => $namespace, "name" => $name, "clipid" => $clipid, "message" => "clipid for $namespace:$name now $clipid" ],200); } else if ( count($clipids) == 0 ) { $this->set_result([ "namespace" => $namespace, "name" => $name, "message" => "clip $namespace:$name not found", ],404); } else { $this->set_result([ "namespace" => $namespace, "name" => $name, "clipid" => $clipid, "message" => "not valid clipid for $namespace:$name", ],400); } } } ``` ## jsonetc.php A couple of convenience methods for dealing with json. ```php $body]); } } function serve_error_json($type,$message,$response_code,$additional_data = null) { $message = str_replace("\\","\\\\",$message); $message = str_replace('"',"\\\"",$message); $data = [ "status" => "error", "errorType" => $type, "error" => $message ]; if( ! is_null($additional_data) ) { $data = array_merge($data,$additional_data); } serve_json($data, $response_code); } function serve_json($data, $response_code) { http_response_code($response_code); header("Content-type: application/json"); echo json_encode($data); exit(); } function must_post($response_code = 400) { if( $_SERVER["REQUEST_METHOD"] !== "POST" ) { serve_error_json("mustpost","Must POST",400); exit(); } } ``` # Client This is a single Python script, called `clipclient`. Note that the formatting code for output isn't there yet for most methods. Only `get` and `getbyid` work properly. The others just pass the returned json to `icecream` for pretty printed output. So basically replace the `ic(...)` calls with your own formatting code. ```py #!/usr/bin/env python import os,sys,json import requests from clipclient_auth import cookies # auth cookies go in here from icecream import ic; ic.configureOutput(includeContext=True) BACKEND=os.getenv("CLIP_BACKEND","http://localhost/clipboard/clip.php") def eprint(*xs,**kw): kw['file'] = sys.stderr print(*xs,**kw) def main(): args = sys.argv[1:] clip_client = ClipClient(BACKEND,cookies) clip_client.go(args) class ClipClient: def __init__(self,backend,cookies=None): self.backend = backend self.cookies = cookies if cookies is not None else {} def helpexit(self,rc=0): eprint(f"Clip Client --- {self.backend}") keys = [ x for x in self.__class__.__dict__.keys() if x.startswith("do_") ] for k in sorted(keys): method_name = k[3:] method = getattr(self,k) print(f"{method_name}: {method.__doc__}") exit(rc) def go(self,args): if len(args) == 0: self.helpexit(1) return cmd, *params = args method_name = "do_" + cmd try: method = getattr(self,method_name) except AttributeError: eprint(f"No command: {cmd}") self.helpexit(1) try: return method(*params) except Exception as e: eprint(f"Exception {e} ({type(e)}) executing command {cmd}") raise def get_name_ns(self,nsname): "Turns namespace:name to name,namespace, namespace default to _" if ":" in nsname: namespace, name = nsname.split(":",1) return name, namespace else: return nsname, "_" def do_put(self,*args): """put clip put [filenames...] filename "." will paste from clipboar filename "-" will read from stdin""" append = False if "-a" in args: append = True args.pop(args.index("-a")) if len(args) == 0: eprint("Put requires a name") return 1 nsname, *fns = args name, namespace = self.get_name_ns(nsname) content = [] stdin = None if len(fns) == 0: fns = ["-"] for fn in fns: if fn == "-": if stdin is None: stdin = sys.stdin.read() content.append(stdin) elif fn == ".": content.append(paste()) else: try: with open(fn) as f: content.append(f.read()) except Exception as e: eprint(f"Exception {e} ({type(e)}) reading {fn}") continue value = "\n".join(content) request = { "request": "put", "name": name, "namespace": namespace, "value": value } if append: request["append"] = True request_json = json.dumps(request) print(request_json) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: eprint(f"Inserted {namespace}:{name} ({len(value)} bytes)") elif r.status_code == 400: eprint(f"Invalid request",file=sys.stderr) exit(1) elif r.status_code == 500: eprint(f"Server error",file=sys.stderr) eprint(r.text,file=sys.stderr) exit(1) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) exit(1) else: eprint("WTF",r.status_code,r.text) exit(1) def do_get(self,*args): """get clip get [...]""" for arg in args: name, namespace = self.get_name_ns(arg) request = { "request": "get", "namespace": namespace, "name": name } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) value = b["value"] value = value.replace("\r","") print(value) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{namespace}:{name} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 return 0 def do_listall(self,*args): """list all clips matching pattern across all namespaces listall pattern""" if len(args) == 0: pattern = "" else: pattern = args[0] if len(args) > 1: eprint(f"Excess arguments for listall: {args[1:]}") request = { "request": "listall", "pattern": pattern } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_list(self,*args): """list all clips matching pattern within a namespace (default _) list namespace:pattern""" if len(args) == 0: pattern = "" namespace = "_" else: pattern, namespace = self.get_name_ns(args[0]) if namespace == "": namespace = "_" if len(args) > 1: eprint(f"Excess arguments for list: {args[1:]}") request = { "request": "list", "namespace": namespace, "pattern": pattern } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_history(self,*args): """list history of named clip history namespace:name""" if len(args) == 0: name = "" namespace = "_" else: name, namespace = self.get_name_ns(args[0]) if namespace == "": namespace = "_" if len(args) > 1: eprint(f"Excess arguments for history: {args[1:]}") request = { "request": "history", "namespace": namespace, "name": name } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_history_regex(self,*args): """list history of clips whose name matches regex history_regex namespace:pattern""" if len(args) == 0: pattern = "" namespace = "_" else: pattern, namespace = self.get_name_ns(args[0]) if namespace == "": namespace = "_" if len(args) > 1: eprint(f"Excess arguments for history_regex: {args[1:]}") request = { "request": "history_regex", "namespace": namespace, "pattern": pattern } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_listns(self,*args): """list namespaces whose name matches regex listns pattern""" if len(args) == 0: pattern = "" else: pattern = args[0] request = { "request": "listns", "pattern": pattern } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_recent(self,*args): """list recent clips in chrono order whose name matches regex recent namespace:pattern""" if len(args) == 0: pattern = "" namespace = "_" limit = 20 else: pattern, namespace = self.get_name_ns(args[0]) if len(args) >= 2: try: limit = int(args[1]) if limit < 1: raise ValueError() except ValueError: eprint(f"Invalid limit: {args[1]}") return 1 else: limit = 20 if namespace == "": namespace = "_" if len(args) > 1: eprint(f"Excess arguments for recent: {args[1:]}") request = { "request": "recent", "namespace": namespace, "pattern": pattern, "limit": limit } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{pattern} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_getlast(self,*args): """get last n entries for name ns:name, limit to m chars getlast namespace:name num_items(=20) char_limit(=1024) use -r to match by regex""" nsname, num_items, char_limit, xs = (lambda x="",y=20,z=1024,*xs: (x,y,z,xs))(*args) name, namespace = self.get_name_ns(args[0]) try: num_items = int(num_items) char_limit = int(char_limit) if not (num_items > 0 and char_limit > 0): raise ValueError() except ValueError: eprint("num_items and char_limit must be positive integers") return 1 if namespace == "": namespace = "_" if len(xs) > 0: eprint("Excess arguments for getlast") request = { "request": "getlast", "namespace": namespace, "name": name, "num_items": num_items, "char_limit": char_limit } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{name} not found",file=sys.stderr) print(r.text) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 def do_find(self,*args): """find clip containing text find pattern limit namespace""" if len(args) < 1: eprint("No pattern to find for") return 1 elif len(args) < 2: pattern = args[0] limit = 20 namespace = "_" elif len(args) < 3: pattern = args[0] limit = int(args[1]) namespace = "_" else: pattern = args[0] limit = int(args[1]) namespace = args[2] if len(args) >= 4: eprint("Excess arguments for find") try: limit = int(limit) if limit < 1: eprint("Invalid limit (<1)") return 1 except ValueError: eprint(f"Invalid limit '{args[1]}'") return 1 request = { "request": "find", "pattern": pattern, "limit": limit } if len(namespace) > 0: request["namespace"] = namespace request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{name} not found",file=sys.stderr) print(r.text) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 pass def do_getbyid(self,*args): """get clip by id get [...]""" for arg in args: try: clipid = int(arg) if clipid < 0: raise ValueError() except ValueError: eprint("Invalid clip id (must be positive integer): {arg}") continue request = { "request": "getbyid", "id": clipid } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) value = b["value"] value = value.replace("\r","") print(value) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{namespace}:{name} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 return 0 def do_set(self,*args): """set current clip id set id id must be id for named clip""" if len(args) < 2: eprint("Set requires a ns:name and index") return 1 nsname, clipid, *xs = args name, namespace = self.get_name_ns(nsname) if len(xs) > 0: eprint(f"Excess parameters for set: {xs}") request = { "request": "set", "namespace": namespace, "name": name, "id": clipid } request_json = json.dumps(request) r = requests.post(self.backend,data=request_json,cookies=self.cookies) if r.status_code == 200: try: b = json.loads(r.text) ic(b) except Exception as e: eprint("error",r,r.text,e,file=sys.stderr) elif r.status_code == 400: eprint(f"Error 400: {r.text}") return 1 elif r.status_code == 404: eprint(f"{namespace}:{name} not found",file=sys.stderr) elif r.status_code == 403: eprint(f"Access denied",file=sys.stderr) return 1 if __name__ == "__main__": main() ``` # Fuse FS The above code needs slight modification, which I'll put here later. But this is a simple FUSE filesystem that wraps clipclient. Very ugly, but my first quick play with FUSE in python. I took the hello world example and hacked it around a bit to get this result. That hello exmaple is LGPL, so this is too. ```py #!/usr/bin/env python # Based on the hello example which is: # Copyright (C) 2006 Andrew Straw # # Everything else # Copyright (C) 2024 John Allsup # # This program can be distributed under the terms of the GNU LGPL. # import os, stat, errno, fuse, json, subprocess from collections import defaultdict from fuse import Fuse from datetime import datetime if not hasattr(fuse, '__version__'): raise RuntimeError("your fuse-py doesn't know of fuse.__version__, probably it's too old.") fuse.fuse_python_api = (0, 2) class MyStat(fuse.Stat): def __init__(self): self.st_mode = 0 self.st_ino = 0 self.st_dev = 0 self.st_nlink = 0 self.st_uid = 0 self.st_gid = 0 self.st_size = 0 self.st_atime = 0 self.st_mtime = 0 self.st_ctime = 0 def update_clip_data(): global clipdata m = subprocess.run(["clipclient","-j","listall"],capture_output=True) a = json.loads(m.stdout.decode()) names = defaultdict(dict) for line in a: nsname, when, count, length = line ns, name = nsname.split(":") when = datetime.strptime(when,"%Y-%m-%d %H:%M:%S") uwhen = int(when.timestamp()) names[ns][name] = (uwhen,length) clipdata = names update_clip_data() refreshed_msg = b"Refreshed\n" class HelloFS(Fuse): def getattr(self, path): st = MyStat() if path == '/': st.st_mode = stat.S_IFDIR | 0o755 st.st_nlink = 2 return st elif path == "/.refresh": st.st_mode = stat.S_IFREG | 0o444 st.st_nlink = 1 st.st_size = len(refreshed_msg) return st else: xs = path.split("/") if len(xs) == 2: _, ns = xs if ns in clipdata: st.st_mode = stat.S_IFDIR | 0o755 st.st_nlink = 2 return st else: return -errno.ENOENT elif len(xs) == 3: _, ns, name = xs if ns in clipdata: names = clipdata[ns] if name in names: when, length = names[name] st.st_mode = stat.S_IFREG | 0o444 st.st_nlink = 1 st.st_size = length st.st_atime = when st.st_mtime = when st.st_ctime = when return st else: return -errno.ENOENT else: return -errno.ENOENT else: return -errno.ENOENT return -errno.ENOENT def readdir(self, path, offset): if path == "/": for r in '.', '..', '.refresh': yield fuse.Direntry(r) for r in clipdata: yield fuse.Direntry(r) else: xs = path.split("/") if len(xs) == 2: _, ns = xs names = clipdata[ns] for r in '.', '..': yield fuse.Direntry(r) for r in names: yield fuse.Direntry(r) def open(self, path, flags): if path == "/.refresh": return xs = path.split("/") if len(xs) != 3: return -errno.ENOENT _, ns, name = xs if not ns in clipdata and name in clipdata[ns]: return -errno.ENOENT accmode = os.O_RDONLY | os.O_WRONLY | os.O_RDWR if (flags & accmode) != os.O_RDONLY: return -errno.EACCES def read(self, path, size, offset): if path == "/.refresh": update_clip_data() return refreshed_msg xs = path.split("/") if len(xs) != 3: return -errno.ENOENT _, ns, name = xs if not ns in clipdata and name in clipdata[ns]: return -errno.ENOENT nsname = f"{ns}:{name}" m = subprocess.run(["clipclient","get",nsname],capture_output=True) data = m.stdout slen = len(data) if offset < slen: if offset + size > slen: size = slen - offset buf = data[offset:offset+size] else: buf = b'' return buf def main(): usage=""" Clipclient fs Set backend by exporting CLIP_BACKEND variable. """ + Fuse.fusage server = HelloFS(version="%prog " + fuse.__version__, usage=usage, dash_s_do='setsingle') server.parse(errex=1) server.main() if __name__ == '__main__': main() ```