obsideo-drive 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
obsideo/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The general Obsideo CLI front-end (REPL + browse + sync) on obsideo_core."""
obsideo/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from obsideo.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
obsideo/cli.py ADDED
@@ -0,0 +1,568 @@
1
+ """obsideo - the general Obsideo CLI.
2
+
3
+ Save, browse, and sync whatever you want - encrypted on your machine before it
4
+ leaves, so Obsideo can't read it. An interactive shell plus one-shot commands.
5
+
6
+ obsideo login sign up / log in (email -> 3 GB free)
7
+ obsideo start the interactive shell
8
+ obsideo ls / put / get ... run a single command
9
+ """
10
+
11
+ import cmd
12
+ import os
13
+ import shlex
14
+ import sys
15
+ import urllib.error
16
+ import urllib.request
17
+ import json
18
+ from pathlib import Path
19
+
20
+ from obsideo_core import config, crypto, identity, login, storage
21
+
22
+
23
+ def _unquote(s: str) -> str:
24
+ """Strip one layer of matching surrounding quotes."""
25
+ s = s.strip()
26
+ if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
27
+ return s[1:-1]
28
+ return s
29
+
30
+
31
+ def _tokens(arg: str) -> list[str]:
32
+ """Tokenize a command line respecting quotes, Windows-path-safe (backslashes
33
+ are not escape characters). 'put "C:\\a b\\f.png" name' -> ['C:\\a b\\f.png','name']."""
34
+ try:
35
+ toks = shlex.split(arg, posix=False)
36
+ except ValueError:
37
+ toks = arg.split()
38
+ return [_unquote(t) for t in toks]
39
+
40
+
41
+ def _human(n: int | None) -> str:
42
+ if n is None:
43
+ return "?"
44
+ f = float(n)
45
+ for unit in ("B", "KB", "MB", "GB", "TB"):
46
+ if f < 1024 or unit == "TB":
47
+ return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}"
48
+ f /= 1024
49
+
50
+
51
+ # ── Operator notices (server-driven broadcasts) ──────────────────────────────
52
+
53
+ _SEEN_FILE = config.CONFIG_DIR / "seen_notices"
54
+ _SEV = { # severity -> (marker, ansi); ansi only emitted on a TTY
55
+ "info": ("·", "\033[36m"), # cyan
56
+ "action": ("!", "\033[33m"), # yellow
57
+ "urgent": ("!!", "\033[1;31m"), # bold red
58
+ }
59
+ _RESET = "\033[0m"
60
+
61
+
62
+ def _load_seen() -> set:
63
+ try:
64
+ return set(_SEEN_FILE.read_text().split())
65
+ except OSError:
66
+ return set()
67
+
68
+
69
+ def _mark_seen(ids: list) -> None:
70
+ if not ids:
71
+ return
72
+ try:
73
+ config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
74
+ with open(_SEEN_FILE, "a") as f:
75
+ f.write("\n".join(ids) + "\n")
76
+ except OSError:
77
+ pass
78
+
79
+
80
+ def show_notices() -> None:
81
+ """Print any unseen operator broadcasts to stderr, once each. Strictly
82
+ best-effort: only on an interactive TTY, never touches stdout, and swallows
83
+ every error so it can never break or slow a real command in a script."""
84
+ if not sys.stdout.isatty() or os.environ.get("OBSIDEO_NO_NOTICES"):
85
+ return
86
+ try:
87
+ req = urllib.request.Request(
88
+ f"{config.signup_url()}/v1/notices",
89
+ headers={"User-Agent": config.USER_AGENT},
90
+ )
91
+ with urllib.request.urlopen(req, timeout=4) as resp:
92
+ notices = json.loads(resp.read().decode()).get("notices", [])
93
+ except Exception:
94
+ return
95
+ if not notices:
96
+ return
97
+ seen = _load_seen()
98
+ shown = []
99
+ for n in notices:
100
+ nid = str(n.get("id"))
101
+ if nid in seen:
102
+ continue
103
+ marker, color = _SEV.get((n.get("severity") or "info").lower(), _SEV["info"])
104
+ print(f"{color}{marker} Obsideo:{_RESET} {n.get('body', '').strip()}", file=sys.stderr)
105
+ shown.append(nid)
106
+ _mark_seen(shown)
107
+
108
+
109
+ # ── Operator tooling: broadcast a message to all users ────────────────────────
110
+
111
+ def run_admin(argv: list) -> int:
112
+ """`obsideo admin broadcast [--severity info|action|urgent] [--ttl SECONDS] "message"`
113
+ Authors a notice all users will see in their CLI. Requires the coord admin
114
+ secret in OBSIDEO_ADMIN_SECRET (operator-only; never shipped or stored)."""
115
+ if not argv or argv[0] != "broadcast":
116
+ print('Usage: obsideo admin broadcast [--severity info|action|urgent] '
117
+ '[--ttl SECONDS] "message"', file=sys.stderr)
118
+ return 2
119
+ severity, ttl, words, rest = "info", None, [], argv[1:]
120
+ i = 0
121
+ while i < len(rest):
122
+ if rest[i] == "--severity" and i + 1 < len(rest):
123
+ severity, i = rest[i + 1], i + 2
124
+ elif rest[i] == "--ttl" and i + 1 < len(rest):
125
+ ttl, i = rest[i + 1], i + 2
126
+ else:
127
+ words.append(rest[i]); i += 1
128
+ body = " ".join(words).strip()
129
+ if not body:
130
+ print("A message body is required.", file=sys.stderr)
131
+ return 2
132
+ secret = os.environ.get("OBSIDEO_ADMIN_SECRET", "").strip()
133
+ if not secret:
134
+ print("Set OBSIDEO_ADMIN_SECRET (the coord admin secret) to broadcast.", file=sys.stderr)
135
+ return 2
136
+ payload = {"body": body, "severity": severity}
137
+ if ttl is not None:
138
+ try:
139
+ payload["ttl_seconds"] = int(ttl)
140
+ except ValueError:
141
+ print("--ttl must be an integer number of seconds.", file=sys.stderr)
142
+ return 2
143
+ req = urllib.request.Request(
144
+ f"{config.signup_url()}/internal/messages",
145
+ data=json.dumps(payload).encode(),
146
+ headers={"Content-Type": "application/json", "User-Agent": config.USER_AGENT,
147
+ "X-Admin-Secret": secret},
148
+ method="POST",
149
+ )
150
+ try:
151
+ with urllib.request.urlopen(req, timeout=15) as resp:
152
+ out = json.loads(resp.read().decode())
153
+ except urllib.error.HTTPError as e:
154
+ detail = e.read().decode()[:200]
155
+ print(f"Broadcast failed: HTTP {e.code} {detail}", file=sys.stderr)
156
+ return 1
157
+ except urllib.error.URLError as e:
158
+ print(f"Broadcast failed: {e.reason}", file=sys.stderr)
159
+ return 1
160
+ print(f"Broadcast sent (id {out.get('id')}, severity {severity}).")
161
+ return 0
162
+
163
+
164
+ def run_login(url: str | None = None) -> bool:
165
+ """Interactive email-OTP login. Returns True on success."""
166
+ url = url or config.signup_url()
167
+ email = input("Enter your email: ").strip()
168
+ if not email:
169
+ print("Email is required.")
170
+ return False
171
+ print("Sending a verification code...", end="", flush=True)
172
+ try:
173
+ login.start(email, url)
174
+ except login.LoginError as e:
175
+ print(f"\nCouldn't start signup: {e}")
176
+ return False
177
+ print(" sent.")
178
+ print(f"Check {email} for a verification code (it may be in spam).")
179
+ code = input("Enter verification code: ").strip()
180
+ print("Verifying + provisioning storage...", end="", flush=True)
181
+ try:
182
+ creds = login.verify(email, code, url)
183
+ except login.LoginError as e:
184
+ print(f"\nVerification failed: {e}")
185
+ return False
186
+ print(" done.")
187
+ storage.reset_client()
188
+ # Make sure the data key exists + nudge the user to back it up.
189
+ crypto.data_key()
190
+ print(f"\nYou're all set. {creds.get('quota_gb', 3)} GB free.")
191
+ if not creds.get("gateway_registered", True):
192
+ print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
193
+ print("Your files are encrypted with a local key. Back it up:")
194
+ print(f" {crypto.DATA_KEY_FILE}")
195
+ print("Type 'obsideo' to open the shell, or 'obsideo put <file>' to store something.")
196
+ return True
197
+
198
+
199
+ class ObsideoShell(cmd.Cmd):
200
+ intro = ("\n Obsideo - encrypted storage we can't read.\n"
201
+ " Type 'help' for commands, 'exit' to quit.\n")
202
+ prompt = "obsideo:/ "
203
+
204
+ def __init__(self):
205
+ super().__init__()
206
+ self._cwd = "" # S3 key prefix; "" = root
207
+ self._refresh_prompt()
208
+
209
+ # ── path helpers ────────────────────────────────────────────────────────
210
+ def _refresh_prompt(self):
211
+ self.prompt = f"obsideo:/{self._cwd} "
212
+
213
+ def _resolve(self, name: str) -> str:
214
+ if name.startswith("/"):
215
+ return name.lstrip("/")
216
+ return f"{self._cwd}{name}"
217
+
218
+ def _require_login(self) -> bool:
219
+ if not config.is_logged_in():
220
+ print("You're not logged in. Run 'login' (or 'obsideo login').")
221
+ return False
222
+ return True
223
+
224
+ # ── login ───────────────────────────────────────────────────────────────
225
+ def do_login(self, arg):
226
+ """Sign up / log in with your email (email -> 3 GB free)."""
227
+ run_login()
228
+ self._cwd = ""
229
+ self._refresh_prompt()
230
+
231
+ # ── ls ──────────────────────────────────────────────────────────────────
232
+ def do_ls(self, arg):
233
+ """List files and folders. Usage: ls [path]"""
234
+ if not self._require_login():
235
+ return
236
+ target = _unquote(arg.strip())
237
+ prefix = self._resolve(target) if target else self._cwd
238
+ try:
239
+ resp = storage.list_prefix(prefix)
240
+ except Exception as e:
241
+ print(f"Error: {e}")
242
+ return
243
+ for d in resp["folders"]:
244
+ print(f" [dir] {d}/")
245
+ for f in resp["files"]:
246
+ print(f" [file] {f['name']} {_human(f['size'])}")
247
+ if not resp["folders"] and not resp["files"]:
248
+ print(" (empty)")
249
+
250
+ # ── cd / pwd ──────────────────────────────────────────────────────────────
251
+ def do_cd(self, arg):
252
+ """Change directory. Usage: cd <path> | cd .. | cd /"""
253
+ path = _unquote(arg.strip())
254
+ if not path or path == "/":
255
+ self._cwd = ""
256
+ elif path == "..":
257
+ trimmed = self._cwd.rstrip("/")
258
+ self._cwd = trimmed[:trimmed.rfind("/") + 1] if "/" in trimmed else ""
259
+ elif path.startswith("/"):
260
+ self._cwd = path.lstrip("/")
261
+ if self._cwd and not self._cwd.endswith("/"):
262
+ self._cwd += "/"
263
+ else:
264
+ self._cwd = f"{self._cwd}{path}"
265
+ if not self._cwd.endswith("/"):
266
+ self._cwd += "/"
267
+ self._refresh_prompt()
268
+ print(f" /{self._cwd}")
269
+
270
+ def do_pwd(self, arg):
271
+ """Print current directory."""
272
+ print(f" /{self._cwd}")
273
+
274
+ # ── put / upload ──────────────────────────────────────────────────────────
275
+ def do_put(self, arg):
276
+ """Upload a file, or a whole folder recursively.
277
+
278
+ Each file is encrypted on your machine (AES-256-GCM) before upload, so
279
+ Obsideo only ever stores ciphertext. A folder uploads all of its files,
280
+ preserving structure under <name>/.
281
+
282
+ Usage:
283
+ put <local_path> [remote_name] [--no-encrypt]
284
+
285
+ Examples:
286
+ put report.pdf store as report.pdf
287
+ put report.pdf q3.pdf store under a different name
288
+ put ./photos upload the whole folder -> photos/...
289
+ put notes.txt --no-encrypt upload as-is (NOT encrypted)
290
+ """
291
+ if not self._require_login():
292
+ return
293
+ parts = _tokens(arg)
294
+ if not parts:
295
+ print("Usage: put <local_path> [remote_name] [--no-encrypt]")
296
+ return
297
+ no_encrypt = "--no-encrypt" in parts
298
+ parts = [p for p in parts if p != "--no-encrypt"]
299
+ local = Path(parts[0]).expanduser()
300
+ if not local.exists():
301
+ print(f"Not found: {local}")
302
+ return
303
+ base = parts[1] if len(parts) > 1 else local.name
304
+ do_encrypt = config.load_config().get("encrypt", True) and not no_encrypt
305
+
306
+ if local.is_dir():
307
+ self._put_folder(local, base, do_encrypt)
308
+ else:
309
+ self._put_file(local, self._resolve(base), do_encrypt)
310
+
311
+ do_upload = do_put
312
+
313
+ def _put_file(self, local: Path, key: str, do_encrypt: bool):
314
+ try:
315
+ raw = local.read_bytes()
316
+ except OSError as e:
317
+ print(f" Error reading {local}: {e}")
318
+ return
319
+ body = crypto.encrypt(raw) if do_encrypt else raw
320
+ verb = "Encrypting + uploading" if do_encrypt else "Uploading"
321
+ print(f" {verb} {key.rsplit('/', 1)[-1]} ({_human(len(raw))})...")
322
+ try:
323
+ storage.put(key, body)
324
+ print(f" Stored: /{key}")
325
+ except Exception as e:
326
+ print(f" Error: {e}")
327
+
328
+ def _put_folder(self, folder: Path, base: str, do_encrypt: bool):
329
+ files = [f for f in sorted(folder.rglob("*")) if f.is_file()]
330
+ if not files:
331
+ print(f" (empty folder: {folder})")
332
+ return
333
+ verb = "Encrypting + uploading" if do_encrypt else "Uploading"
334
+ print(f" {verb} folder {base}/ ({len(files)} file(s))...")
335
+ ok = 0
336
+ for f in files:
337
+ rel = f.relative_to(folder).as_posix()
338
+ key = self._resolve(f"{base}/{rel}")
339
+ try:
340
+ raw = f.read_bytes()
341
+ body = crypto.encrypt(raw) if do_encrypt else raw
342
+ storage.put(key, body)
343
+ ok += 1
344
+ print(f" {rel} ({_human(len(raw))})")
345
+ except Exception as e:
346
+ print(f" {rel} - FAILED: {e}")
347
+ print(f" Stored {ok}/{len(files)} file(s) under /{self._resolve(base)}/")
348
+
349
+ # ── get / download ────────────────────────────────────────────────────────
350
+ def do_get(self, arg):
351
+ """Download a file. Usage: get <remote_file> [local_path]"""
352
+ if not self._require_login():
353
+ return
354
+ parts = _tokens(arg)
355
+ if not parts:
356
+ print("Usage: get <remote_file> [local_path]")
357
+ return
358
+ key = self._resolve(parts[0])
359
+ local = Path(parts[1]).expanduser() if len(parts) > 1 else Path(Path(parts[0]).name)
360
+ print(f" Downloading /{key}...")
361
+ try:
362
+ blob = storage.get(key)
363
+ except Exception as e:
364
+ print(f" Error: {e}")
365
+ return
366
+ try:
367
+ raw = crypto.decrypt(blob)
368
+ except Exception:
369
+ raw = blob # stored unencrypted, or wrong key
370
+ local.parent.mkdir(parents=True, exist_ok=True)
371
+ local.write_bytes(raw)
372
+ print(f" Saved to: {local} ({_human(len(raw))})")
373
+
374
+ do_download = do_get
375
+
376
+ # ── rm ──────────────────────────────────────────────────────────────────
377
+ def do_rm(self, arg):
378
+ """Delete a file. Usage: rm <remote_file>"""
379
+ if not self._require_login():
380
+ return
381
+ name = _unquote(arg.strip())
382
+ if not name:
383
+ print("Usage: rm <remote_file>")
384
+ return
385
+ key = self._resolve(name)
386
+ try:
387
+ storage.delete(key)
388
+ print(f" Deleted: /{key}")
389
+ except Exception as e:
390
+ print(f" Error: {e}")
391
+
392
+ # ── mkdir ─────────────────────────────────────────────────────────────────
393
+ def do_mkdir(self, arg):
394
+ """Create a folder. Usage: mkdir <name>"""
395
+ if not self._require_login():
396
+ return
397
+ name = _unquote(arg.strip())
398
+ if not name:
399
+ print("Usage: mkdir <name>")
400
+ return
401
+ try:
402
+ created = storage.mkdir(self._resolve(name))
403
+ print(f" Created: /{created}")
404
+ except Exception as e:
405
+ print(f" Error: {e}")
406
+
407
+ # ── info ──────────────────────────────────────────────────────────────────
408
+ def do_info(self, arg):
409
+ """Show object metadata. Usage: info <remote_file>"""
410
+ if not self._require_login():
411
+ return
412
+ name = _unquote(arg.strip())
413
+ if not name:
414
+ print("Usage: info <remote_file>")
415
+ return
416
+ meta = storage.head(self._resolve(name))
417
+ if not meta:
418
+ print(" Not found.")
419
+ return
420
+ print(f" size: {_human(meta['size'])}")
421
+ if meta.get("last_modified"):
422
+ print(f" modified: {meta['last_modified']}")
423
+
424
+ # ── account ───────────────────────────────────────────────────────────────
425
+ def do_account(self, arg):
426
+ """Show your plan: storage used vs. your free quota."""
427
+ if not self._require_login():
428
+ return
429
+ usage = _fetch_usage()
430
+ print()
431
+ print(" -- Obsideo account --------------------------")
432
+ print(" Plan: Free")
433
+ if usage:
434
+ used, quota = usage["used_bytes"], usage["quota_bytes"]
435
+ pct = usage.get("percent_used", (used / quota if quota else 0))
436
+ print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
437
+ bar_len = 30
438
+ filled = int(bar_len * min(pct, 1.0))
439
+ print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
440
+ if pct >= 0.8:
441
+ print(" You're near your limit - reply to any Obsideo email to upgrade.")
442
+ else:
443
+ print(" (usage unavailable - is the account service reachable?)")
444
+ print(" ---------------------------------------------")
445
+ print()
446
+
447
+ # ── sync ──────────────────────────────────────────────────────────────────
448
+ def do_sync(self, arg):
449
+ """Sync your local folder with Obsideo. Usage: sync push|pull|status"""
450
+ if not self._require_login():
451
+ return
452
+ from obsideo import sync as sync_mod
453
+ sub = arg.strip().lower()
454
+ if sub == "push":
455
+ n = sync_mod.push()
456
+ print(f" Done. {n} file(s) pushed.")
457
+ elif sub == "pull":
458
+ n = sync_mod.pull()
459
+ print(f" Done. {n} file(s) pulled.")
460
+ elif sub == "status":
461
+ s = sync_mod.sync_status()
462
+ for f in s["to_push"]:
463
+ print(f" + {f} (push)")
464
+ for f in s["to_pull"]:
465
+ print(f" - {f} (pull)")
466
+ for f in s["synced"]:
467
+ print(f" = {f}")
468
+ if not any(s.values()):
469
+ print(" Nothing to sync.")
470
+ else:
471
+ print("Usage: sync push|pull|status")
472
+
473
+ # ── config ────────────────────────────────────────────────────────────────
474
+ def do_config(self, arg):
475
+ """Show or set config. Usage: config | config set <key> <value>"""
476
+ parts = arg.strip().split(None, 2)
477
+ if not parts:
478
+ for k, v in config.load_config().items():
479
+ print(f" {k}: {v}")
480
+ print(f" config_dir: {config.CONFIG_DIR}")
481
+ return
482
+ if parts[0] == "set" and len(parts) == 3:
483
+ key, value = parts[1], parts[2]
484
+ cfg = config.load_config()
485
+ if key == "encrypt":
486
+ value = value.lower() in ("true", "1", "yes", "on")
487
+ cfg[key] = value
488
+ config.save_config(cfg)
489
+ print(f" {key} = {value}")
490
+ else:
491
+ print("Usage: config | config set <key> <value>")
492
+
493
+ # ── exit ──────────────────────────────────────────────────────────────────
494
+ def do_exit(self, arg):
495
+ """Exit."""
496
+ print("Bye.")
497
+ return True
498
+
499
+ do_quit = do_exit
500
+
501
+ def do_EOF(self, arg):
502
+ print()
503
+ return True
504
+
505
+ def emptyline(self):
506
+ pass
507
+
508
+
509
+ def _fetch_usage() -> dict | None:
510
+ token = config.account_token()
511
+ if not token:
512
+ return None
513
+ try:
514
+ req = urllib.request.Request(
515
+ f"{config.signup_url()}/v1/account/usage",
516
+ headers={"Authorization": f"Bearer {token}", "User-Agent": config.USER_AGENT},
517
+ )
518
+ with urllib.request.urlopen(req, timeout=15) as resp:
519
+ return json.loads(resp.read().decode())
520
+ except Exception:
521
+ return None
522
+
523
+
524
+ def main():
525
+ argv = sys.argv[1:]
526
+
527
+ # Standard --help / -h (cmd.Cmd would otherwise read "--help" as a command).
528
+ if argv and argv[0] in ("-h", "--help", "help"):
529
+ ObsideoShell().onecmd("help")
530
+ return
531
+
532
+ # `obsideo login` is interactive and handled specially.
533
+ if argv and argv[0] == "login":
534
+ ok = run_login()
535
+ sys.exit(0 if ok else 1)
536
+
537
+ # `obsideo admin ...` is operator tooling, not a shell command.
538
+ if argv and argv[0] == "admin":
539
+ sys.exit(run_admin(argv[1:]))
540
+
541
+ # Surface any pending operator broadcasts (no-op unless interactive).
542
+ show_notices()
543
+
544
+ shell = ObsideoShell()
545
+
546
+ # One-shot: `obsideo ls`, `obsideo put file.txt`, etc.
547
+ if argv:
548
+ shell.onecmd(" ".join(argv))
549
+ return
550
+
551
+ # First-run nudge: not logged in -> offer login.
552
+ if not config.is_logged_in():
553
+ print("Welcome to Obsideo - encrypted storage we can't read.")
554
+ if input("Log in / sign up now? (Y/n): ").strip().lower() in ("", "y", "yes"):
555
+ if not run_login():
556
+ return
557
+ else:
558
+ print("Run 'obsideo login' when you're ready.")
559
+ return
560
+
561
+ try:
562
+ shell.cmdloop()
563
+ except KeyboardInterrupt:
564
+ print("\nBye.")
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()
obsideo/manifest.py ADDED
@@ -0,0 +1,59 @@
1
+ """Local manifest for tracking synced files (lifted from Cloud_Terminal,
2
+ re-homed under ~/.obsideo)."""
3
+
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime, timezone
7
+
8
+ from obsideo_core import config
9
+
10
+ _MANIFEST_FILE = config.CONFIG_DIR / "manifest.json"
11
+
12
+
13
+ def _load() -> dict:
14
+ if not _MANIFEST_FILE.exists():
15
+ return {"files": {}}
16
+ try:
17
+ data = json.loads(_MANIFEST_FILE.read_text())
18
+ if not isinstance(data, dict):
19
+ return {"files": {}}
20
+ data.setdefault("files", {})
21
+ return data
22
+ except Exception:
23
+ return {"files": {}}
24
+
25
+
26
+ def _save(m: dict) -> None:
27
+ _MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
28
+ _MANIFEST_FILE.write_text(json.dumps(m, indent=2, sort_keys=True))
29
+
30
+
31
+ def upsert(name: str, remote_key: str, local_hash: str | None = None,
32
+ size: int | None = None, encrypted: bool = True) -> None:
33
+ m = _load()
34
+ m["files"][name] = {
35
+ "remote_key": remote_key,
36
+ "local_hash": local_hash,
37
+ "size": size,
38
+ "encrypted": encrypted,
39
+ "last_synced": datetime.now(timezone.utc).isoformat(),
40
+ }
41
+ _save(m)
42
+
43
+
44
+ def remove(name: str) -> None:
45
+ m = _load()
46
+ m["files"].pop(name, None)
47
+ _save(m)
48
+
49
+
50
+ def get_all() -> dict:
51
+ return _load()["files"]
52
+
53
+
54
+ def file_sha256(filepath) -> str:
55
+ h = hashlib.sha256()
56
+ with open(filepath, "rb") as f:
57
+ for chunk in iter(lambda: f.read(8192), b""):
58
+ h.update(chunk)
59
+ return f"sha256:{h.hexdigest()}"