11"""Share recordings between computers using Magic Wormhole.
22
3+ ``send`` scrubs a copy of the recording first and sends only the copy (see
4+ :mod:`openadapt_capture.share_scrub`). Sending the raw recording needs
5+ ``--unscrubbed --reason ...``, and that choice is recorded.
6+
37Usage:
48 capture share send ./my_recording
9+ capture share send ./my_recording --unscrubbed --reason "synthetic demo"
510 capture share receive 7-guitarist-revenge
611"""
712
@@ -79,15 +84,82 @@ def _ensure_wormhole() -> str | None:
7984 return _install_wormhole ()
8085
8186
82- def send (recording_dir : str ) -> str | None :
87+ OPT_OUT_LEDGER = "share-unscrubbed.jsonl"
88+ OPT_OUT_MARKER = "UNSCRUBBED_SHARE.json"
89+
90+
91+ def opt_out_ledger_path () -> Path :
92+ """Local file where every unscrubbed share is recorded."""
93+ from openadapt_capture .ffmpeg_runtime import data_root
94+
95+ return data_root () / OPT_OUT_LEDGER
96+
97+
98+ def _record_opt_out (recording_path : Path , reason : str ) -> dict :
99+ """Append an unscrubbed-share record to the local ledger and return it."""
100+ import getpass
101+ import json
102+ import time
103+
104+ files = [f for f in recording_path .rglob ("*" ) if f .is_file ()]
105+ try :
106+ user = getpass .getuser ()
107+ except Exception : # noqa: BLE001 - no login name in some sandboxes
108+ user = None
109+ record = {
110+ "schema" : "openadapt.capture.share-unscrubbed/v1" ,
111+ "recorded_at" : time .time (),
112+ "user" : user ,
113+ "recording" : str (recording_path .resolve ()),
114+ "file_count" : len (files ),
115+ "size_bytes" : sum (f .stat ().st_size for f in files ),
116+ "reason" : reason ,
117+ }
118+ ledger = opt_out_ledger_path ()
119+ ledger .parent .mkdir (parents = True , exist_ok = True )
120+ with ledger .open ("a" , encoding = "utf-8" ) as handle :
121+ handle .write (json .dumps (record , sort_keys = True ) + "\n " )
122+ return record
123+
124+
125+ def _zip_folder (folder : Path , zip_path : Path , extra : dict | None = None ) -> None :
126+ import json
127+
128+ with ZipFile (zip_path , "w" , ZIP_DEFLATED , compresslevel = 6 ) as zf :
129+ for file in folder .rglob ("*" ):
130+ if file .is_file () and not file .is_symlink ():
131+ zf .write (file , file .relative_to (folder .parent ))
132+ if extra is not None :
133+ zf .writestr (
134+ f"{ folder .name } /{ OPT_OUT_MARKER } " ,
135+ json .dumps (extra , indent = 2 , sort_keys = True ),
136+ )
137+
138+
139+ def send (
140+ recording_dir : str ,
141+ unscrubbed : bool = False ,
142+ reason : str | None = None ,
143+ ) -> str | None :
83144 """Send a recording via Magic Wormhole.
84145
146+ By default the recording is scrubbed first: a scrubbed copy is built in a
147+ temporary folder and only that copy is sent. Scrubbing needs the
148+ ``privacy`` extra; without it nothing is sent.
149+
85150 Args:
86151 recording_dir: Path to the recording directory.
152+ unscrubbed: Send the raw recording without scrubbing. Requires
153+ ``reason``. The choice is written to the local ledger and to an
154+ ``UNSCRUBBED_SHARE.json`` file inside the archive.
155+ reason: Why the raw recording must be sent (required with
156+ ``unscrubbed``).
87157
88158 Returns:
89- The wormhole code if successful , None otherwise.
159+ ``"sent"`` if the transfer finished , None otherwise.
90160 """
161+ from openadapt_capture .share_scrub import ShareScrubError , scrub_recording
162+
91163 recording_path = Path (recording_dir )
92164
93165 if not recording_path .exists ():
@@ -98,22 +170,38 @@ def send(recording_dir: str) -> str | None:
98170 print (f"✗ Not a directory: { recording_path } " )
99171 return None
100172
173+ if unscrubbed and not (reason and str (reason ).strip ()):
174+ print ("✗ --unscrubbed needs --reason explaining why the raw recording" )
175+ print (" must leave this computer. The reason is recorded." )
176+ return None
177+
178+ # Check the transfer tool first so a scrub is not wasted.
101179 wormhole_path = _ensure_wormhole ()
102180 if not wormhole_path :
103181 return None
104182
105- # Create a temporary zip file
106- zip_name = f"{ recording_path .name } .zip"
107-
108183 with tempfile .TemporaryDirectory () as tmpdir :
109- zip_path = Path (tmpdir ) / zip_name
184+ tmp = Path (tmpdir )
185+ opt_out : dict | None = None
186+ if unscrubbed :
187+ folder = recording_path
188+ opt_out = _record_opt_out (recording_path , str (reason ).strip ())
189+ print ("! Sending the RAW recording without scrubbing." )
190+ print (f" Recorded in { opt_out_ledger_path ()} " )
191+ else :
192+ print (f"Scrubbing a copy of { recording_path .name } ..." )
193+ try :
194+ folder = scrub_recording (recording_path , tmp / "scrubbed" )
195+ except ShareScrubError as exc :
196+ print (f"✗ { exc } " )
197+ print (" Nothing was sent. To send the raw recording anyway, run" )
198+ print (" capture share send PATH --unscrubbed --reason '...'" )
199+ return None
200+ _print_scrub_summary (folder )
110201
111- print (f"Compressing { recording_path .name } ..." )
112- with ZipFile (zip_path , "w" , ZIP_DEFLATED , compresslevel = 6 ) as zf :
113- for file in recording_path .rglob ("*" ):
114- if file .is_file ():
115- arcname = file .relative_to (recording_path .parent )
116- zf .write (file , arcname )
202+ zip_path = tmp / f"{ folder .name } .zip"
203+ print (f"Compressing { folder .name } ..." )
204+ _zip_folder (folder , zip_path , extra = opt_out )
117205
118206 size_mb = zip_path .stat ().st_size / (1024 * 1024 )
119207 print (f"Compressed to { size_mb :.1f} MB" )
@@ -140,6 +228,22 @@ def send(recording_dir: str) -> str | None:
140228 return None
141229
142230
231+ def _print_scrub_summary (folder : Path ) -> None :
232+ import json
233+
234+ from openadapt_capture .share_scrub import MANIFEST_NAME
235+
236+ manifest = json .loads ((folder / MANIFEST_NAME ).read_text (encoding = "utf-8" ))
237+ counts = manifest ["counts" ]
238+ print (
239+ f"Scrubbed { counts ['text_values' ]} text values and "
240+ f"{ counts ['images' ]} images."
241+ )
242+ for item in manifest ["withheld" ]:
243+ print (f" withheld { item ['path' ]} ({ item ['reason' ]} )" )
244+ print ("Scrubbing is automatic and can miss data. Review before sharing further." )
245+
246+
143247def receive (code : str , output_dir : str = "." ) -> Path | None :
144248 """Receive a recording via Magic Wormhole.
145249
0 commit comments