td-task 0.2.5__tar.gz → 0.2.6__tar.gz
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.
- {td_task-0.2.5 → td_task-0.2.6}/PKG-INFO +38 -2
- {td_task-0.2.5 → td_task-0.2.6}/README.md +37 -1
- {td_task-0.2.5 → td_task-0.2.6}/pyproject.toml +1 -1
- {td_task-0.2.5 → td_task-0.2.6}/src/td/CHANGELOG.md +7 -0
- {td_task-0.2.5 → td_task-0.2.6}/src/td/__main__.py +100 -0
- td_task-0.2.6/src/td/backup.py +304 -0
- td_task-0.2.6/src/td/config.py +55 -0
- {td_task-0.2.5 → td_task-0.2.6}/src/td/tui.py +197 -2
- td_task-0.2.6/tests/test_backup.py +129 -0
- {td_task-0.2.5 → td_task-0.2.6}/tests/test_cli_notes.py +7 -0
- {td_task-0.2.5 → td_task-0.2.6}/tests/test_tui_notes.py +17 -0
- {td_task-0.2.5 → td_task-0.2.6}/uv.lock +1 -1
- {td_task-0.2.5 → td_task-0.2.6}/.env.enc +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/.github/workflows/pages.yml +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/.github/workflows/publish.yml +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/.gitignore +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/.python-version +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/assets/demo.gif +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/demo.tape +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/index.html +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/src/td/__init__.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/src/td/db.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/src/td/terminal.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/tests/test_notes.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/tests/test_resize.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/tests/test_update.py +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/website/README.md +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/website/favicon.svg +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/website/main.js +0 -0
- {td_task-0.2.5 → td_task-0.2.6}/website/style.css +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: td-task
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Fast TUI and CLI manager for tasks and Markdown notes
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: cryptography>=42.0.0
|
|
@@ -66,6 +66,8 @@ td [command] [--list <name>]
|
|
|
66
66
|
| `td archive` | Open the archive screen |
|
|
67
67
|
| `td export [file]` | Export database to JSON (stdout if no file) |
|
|
68
68
|
| `td import <file>` | Merge a JSON backup into the database |
|
|
69
|
+
| `td backup github [repo]` | Encrypt and upload a backup to a private GitHub repo |
|
|
70
|
+
| `td restore github [repo]` | Download and restore an encrypted GitHub backup |
|
|
69
71
|
| `td update` | Upgrade to the latest version |
|
|
70
72
|
| `td --version` | Print the installed version |
|
|
71
73
|
| `td --help` | Print the help menu |
|
|
@@ -207,7 +209,8 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
207
209
|
| `q` / `Esc` | Return |
|
|
208
210
|
|
|
209
211
|
Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
|
|
210
|
-
encryption, current-list encryption,
|
|
212
|
+
encryption, current-list encryption, backup export / import, GitHub backup repo,
|
|
213
|
+
GitHub backup now, auto-sync, and GitHub backup encryption.
|
|
211
214
|
|
|
212
215
|
## Agent-friendly
|
|
213
216
|
|
|
@@ -292,9 +295,42 @@ td export > backup.json
|
|
|
292
295
|
|
|
293
296
|
# restore on another machine
|
|
294
297
|
td import backup.json
|
|
298
|
+
|
|
299
|
+
# encrypt and upload to a private GitHub repo (creates td-backup if missing)
|
|
300
|
+
td backup github --save-password
|
|
301
|
+
td backup github lucaspon/my-td-backup --save-password
|
|
302
|
+
|
|
303
|
+
# optional: store plain JSON instead of an encrypted backup
|
|
304
|
+
td backup github --no-encrypt
|
|
305
|
+
|
|
306
|
+
# restore and merge from GitHub
|
|
307
|
+
td restore github
|
|
295
308
|
```
|
|
296
309
|
|
|
297
310
|
You can encrypt the whole database or only the current list from settings (`/`).
|
|
298
311
|
Each encrypted list uses its own password-derived key. Tasks and note bodies stay
|
|
299
312
|
encrypted in JSON backups. Whole-database and per-list encryption cannot run
|
|
300
313
|
together. If you forget a password, there is no recovery.
|
|
314
|
+
|
|
315
|
+
By default, GitHub backups add a separate password-based encryption layer around
|
|
316
|
+
the full JSON export. In encrypted mode, `td` sends only the encrypted envelope
|
|
317
|
+
to GitHub. The `gh` CLI must be
|
|
318
|
+
installed and authenticated. `td backup github` creates the target repository as
|
|
319
|
+
private when it does not exist, and refuses to write to a public repository.
|
|
320
|
+
|
|
321
|
+
Auto-sync uses `~/.config/td/config.toml`:
|
|
322
|
+
|
|
323
|
+
```toml
|
|
324
|
+
[github_backup]
|
|
325
|
+
auto_sync = true
|
|
326
|
+
repo = "owner/td-backup"
|
|
327
|
+
encrypt = true
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Auto-sync runs after TUI sessions and CLI writes. Encrypted auto-sync needs a
|
|
331
|
+
password saved in macOS Keychain with `td backup github --save-password`.
|
|
332
|
+
Set `encrypt = false` to
|
|
333
|
+
store plain JSON. The repository remains private, but GitHub can then read the
|
|
334
|
+
backup. Encrypted mode remains the default. `td` stores a digest of the last
|
|
335
|
+
successful export and skips the GitHub update when the data has not changed.
|
|
336
|
+
Manual backups update the same digest.
|
|
@@ -55,6 +55,8 @@ td [command] [--list <name>]
|
|
|
55
55
|
| `td archive` | Open the archive screen |
|
|
56
56
|
| `td export [file]` | Export database to JSON (stdout if no file) |
|
|
57
57
|
| `td import <file>` | Merge a JSON backup into the database |
|
|
58
|
+
| `td backup github [repo]` | Encrypt and upload a backup to a private GitHub repo |
|
|
59
|
+
| `td restore github [repo]` | Download and restore an encrypted GitHub backup |
|
|
58
60
|
| `td update` | Upgrade to the latest version |
|
|
59
61
|
| `td --version` | Print the installed version |
|
|
60
62
|
| `td --help` | Print the help menu |
|
|
@@ -196,7 +198,8 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
|
|
|
196
198
|
| `q` / `Esc` | Return |
|
|
197
199
|
|
|
198
200
|
Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
|
|
199
|
-
encryption, current-list encryption,
|
|
201
|
+
encryption, current-list encryption, backup export / import, GitHub backup repo,
|
|
202
|
+
GitHub backup now, auto-sync, and GitHub backup encryption.
|
|
200
203
|
|
|
201
204
|
## Agent-friendly
|
|
202
205
|
|
|
@@ -281,9 +284,42 @@ td export > backup.json
|
|
|
281
284
|
|
|
282
285
|
# restore on another machine
|
|
283
286
|
td import backup.json
|
|
287
|
+
|
|
288
|
+
# encrypt and upload to a private GitHub repo (creates td-backup if missing)
|
|
289
|
+
td backup github --save-password
|
|
290
|
+
td backup github lucaspon/my-td-backup --save-password
|
|
291
|
+
|
|
292
|
+
# optional: store plain JSON instead of an encrypted backup
|
|
293
|
+
td backup github --no-encrypt
|
|
294
|
+
|
|
295
|
+
# restore and merge from GitHub
|
|
296
|
+
td restore github
|
|
284
297
|
```
|
|
285
298
|
|
|
286
299
|
You can encrypt the whole database or only the current list from settings (`/`).
|
|
287
300
|
Each encrypted list uses its own password-derived key. Tasks and note bodies stay
|
|
288
301
|
encrypted in JSON backups. Whole-database and per-list encryption cannot run
|
|
289
302
|
together. If you forget a password, there is no recovery.
|
|
303
|
+
|
|
304
|
+
By default, GitHub backups add a separate password-based encryption layer around
|
|
305
|
+
the full JSON export. In encrypted mode, `td` sends only the encrypted envelope
|
|
306
|
+
to GitHub. The `gh` CLI must be
|
|
307
|
+
installed and authenticated. `td backup github` creates the target repository as
|
|
308
|
+
private when it does not exist, and refuses to write to a public repository.
|
|
309
|
+
|
|
310
|
+
Auto-sync uses `~/.config/td/config.toml`:
|
|
311
|
+
|
|
312
|
+
```toml
|
|
313
|
+
[github_backup]
|
|
314
|
+
auto_sync = true
|
|
315
|
+
repo = "owner/td-backup"
|
|
316
|
+
encrypt = true
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Auto-sync runs after TUI sessions and CLI writes. Encrypted auto-sync needs a
|
|
320
|
+
password saved in macOS Keychain with `td backup github --save-password`.
|
|
321
|
+
Set `encrypt = false` to
|
|
322
|
+
store plain JSON. The repository remains private, but GitHub can then read the
|
|
323
|
+
backup. Encrypted mode remains the default. `td` stores a digest of the last
|
|
324
|
+
successful export and skips the GitHub update when the data has not changed.
|
|
325
|
+
Manual backups update the same digest.
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.6] - 2026-11-04
|
|
4
|
+
- Back up and restore data through private GitHub repositories using the gh CLI.
|
|
5
|
+
- Encrypt backups by default, with optional plain JSON storage.
|
|
6
|
+
- Add configurable auto-sync that skips unchanged data and supports macOS Keychain passwords.
|
|
7
|
+
- Add Sync settings, backup progress screens, and a link to open the GitHub repository.
|
|
8
|
+
- Support backups to new and existing empty repositories.
|
|
9
|
+
|
|
3
10
|
## [0.2.5] - 2026-11-04
|
|
4
11
|
- Redraw automatically when terminal dimensions change, without waiting for a keypress.
|
|
5
12
|
- Preserve selection, unsaved edits, note cursor, and confirmations while resizing.
|
|
@@ -72,6 +72,10 @@ def main() -> None:
|
|
|
72
72
|
_run_export(args)
|
|
73
73
|
elif len(args) > 1 and args[1] in ("import", "-import", "--import"):
|
|
74
74
|
_run_import(args)
|
|
75
|
+
elif len(args) > 1 and args[1] in ("backup", "-backup", "--backup"):
|
|
76
|
+
_run_backup(args)
|
|
77
|
+
elif len(args) > 1 and args[1] in ("restore", "-restore", "--restore"):
|
|
78
|
+
_run_restore(args)
|
|
75
79
|
else:
|
|
76
80
|
from . import db
|
|
77
81
|
lists = db.get_all_lists()
|
|
@@ -123,6 +127,8 @@ def _run_help() -> None:
|
|
|
123
127
|
commands_table.add_row(" archive", "Open TUI directly in the completed archive screen")
|
|
124
128
|
commands_table.add_row(" export [file]", "Export database to JSON file or print to stdout")
|
|
125
129
|
commands_table.add_row(" import <file>", "Import and merge database records from a JSON file")
|
|
130
|
+
commands_table.add_row(" backup github [repo]", "Encrypt and upload a backup to a private GitHub repo")
|
|
131
|
+
commands_table.add_row(" restore github [repo]", "Download and restore an encrypted GitHub backup")
|
|
126
132
|
commands_table.add_row(" update", "Upgrade the `td` package to the latest version")
|
|
127
133
|
|
|
128
134
|
console.print(commands_table)
|
|
@@ -136,6 +142,8 @@ def _run_help() -> None:
|
|
|
136
142
|
|
|
137
143
|
options_table.add_row(" -l, --list <name>", "Specify/create list context (TUI list-lock, CLI scope)")
|
|
138
144
|
options_table.add_row(" --dev", "Watch source code directory and restart interactive TUI on changes")
|
|
145
|
+
options_table.add_row(" --no-encrypt", "Store a GitHub backup as plain JSON (private repo required)")
|
|
146
|
+
options_table.add_row(" --save-password", "Save encrypted backup password in macOS Keychain for auto-sync")
|
|
139
147
|
options_table.add_row(" -h, --help", "Print this detailed, LLM-friendly help menu and exit")
|
|
140
148
|
|
|
141
149
|
console.print(options_table)
|
|
@@ -256,6 +264,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
|
|
|
256
264
|
print(json.dumps(note, indent=2))
|
|
257
265
|
else:
|
|
258
266
|
print(f"✓ Note added to '{list_name}' (note ID: {note['id']}, task ID: {note['task_id']})")
|
|
267
|
+
_auto_sync()
|
|
259
268
|
return
|
|
260
269
|
|
|
261
270
|
if action == "list":
|
|
@@ -315,6 +324,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
|
|
|
315
324
|
print(json.dumps(updated, indent=2))
|
|
316
325
|
else:
|
|
317
326
|
print(f"✓ Note {note_id} updated.")
|
|
327
|
+
_auto_sync()
|
|
318
328
|
return
|
|
319
329
|
|
|
320
330
|
deleted = db.delete_note(note_id)
|
|
@@ -322,6 +332,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
|
|
|
322
332
|
print(json.dumps({"id": note_id, "deleted": deleted}))
|
|
323
333
|
else:
|
|
324
334
|
print(f"✓ Note {note_id} deleted.")
|
|
335
|
+
_auto_sync()
|
|
325
336
|
|
|
326
337
|
|
|
327
338
|
def _run_add(list_name: str, args: list[str]) -> None:
|
|
@@ -338,6 +349,7 @@ def _run_add(list_name: str, args: list[str]) -> None:
|
|
|
338
349
|
print("✗ Failed to add task (maximum active tasks reached).")
|
|
339
350
|
sys.exit(1)
|
|
340
351
|
print(f"✓ Task added successfully to list '{list_name}' (ID: {result['id']})")
|
|
352
|
+
_auto_sync()
|
|
341
353
|
|
|
342
354
|
|
|
343
355
|
def _run_list(list_name: str) -> None:
|
|
@@ -571,10 +583,98 @@ def _run_import(args: list[str]) -> None:
|
|
|
571
583
|
json_str = f.read()
|
|
572
584
|
db.import_from_json(json_str)
|
|
573
585
|
print(f"✓ Database successfully imported and merged from {filepath}")
|
|
586
|
+
_auto_sync()
|
|
574
587
|
except Exception as e:
|
|
575
588
|
print(f"✗ Import failed: {e}")
|
|
576
589
|
sys.exit(1)
|
|
577
590
|
|
|
578
591
|
|
|
592
|
+
def _github_backup_repo(args: list[str]) -> str:
|
|
593
|
+
repo = _option_value(args, "--repo")
|
|
594
|
+
if repo is None and len(args) > 3 and not args[3].startswith("-"):
|
|
595
|
+
repo = args[3]
|
|
596
|
+
return (repo or "td-backup").strip()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _backup_password(*, confirm: bool) -> str:
|
|
600
|
+
import getpass
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
password = getpass.getpass("Backup password: ")
|
|
604
|
+
if confirm:
|
|
605
|
+
repeated = getpass.getpass("Confirm backup password: ")
|
|
606
|
+
if password != repeated:
|
|
607
|
+
raise ValueError("Backup passwords do not match")
|
|
608
|
+
if not password:
|
|
609
|
+
raise ValueError("Backup password cannot be empty")
|
|
610
|
+
return password
|
|
611
|
+
except (KeyboardInterrupt, EOFError):
|
|
612
|
+
print("\nCancelled.")
|
|
613
|
+
sys.exit(0)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _auto_sync() -> None:
|
|
617
|
+
from . import backup, config, db
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
repo = backup.sync_if_configured(db.export_to_json())
|
|
621
|
+
if repo:
|
|
622
|
+
print(
|
|
623
|
+
f"✓ auto-synced backup to {repo}/{backup.BACKUP_FILENAME}",
|
|
624
|
+
file=sys.stderr,
|
|
625
|
+
)
|
|
626
|
+
except Exception as error:
|
|
627
|
+
print(f"✗ auto-sync failed: {error}", file=sys.stderr)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _run_backup(args: list[str]) -> None:
|
|
631
|
+
if len(args) < 3 or args[2] != "github":
|
|
632
|
+
print("Usage: td backup github [repo|--repo <owner/name>] [--no-encrypt] [--save-password]")
|
|
633
|
+
sys.exit(1)
|
|
634
|
+
|
|
635
|
+
from . import backup, config, db
|
|
636
|
+
|
|
637
|
+
_cli_ensure_unlocked()
|
|
638
|
+
try:
|
|
639
|
+
repo_name = _github_backup_repo(args)
|
|
640
|
+
should_encrypt = "--no-encrypt" not in args
|
|
641
|
+
if not should_encrypt and "--save-password" in args:
|
|
642
|
+
raise ValueError("--save-password requires encrypted backup mode")
|
|
643
|
+
password = _backup_password(confirm=True) if should_encrypt else None
|
|
644
|
+
data = db.export_to_json()
|
|
645
|
+
payload = backup.encrypt_backup(data, password) if password else data
|
|
646
|
+
repo = backup.upload_backup(repo_name, payload)
|
|
647
|
+
config.set_github_backup(
|
|
648
|
+
repo=repo,
|
|
649
|
+
last_sync_digest=backup.backup_digest(data, should_encrypt),
|
|
650
|
+
)
|
|
651
|
+
if password and "--save-password" in args:
|
|
652
|
+
backup.store_password(repo, password)
|
|
653
|
+
mode = "encrypted" if password else "unencrypted"
|
|
654
|
+
print(f"✓ {mode} backup uploaded to {repo}/{backup.BACKUP_FILENAME}")
|
|
655
|
+
except Exception as error:
|
|
656
|
+
print(f"✗ GitHub backup failed: {error}")
|
|
657
|
+
sys.exit(1)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _run_restore(args: list[str]) -> None:
|
|
661
|
+
if len(args) < 3 or args[2] != "github":
|
|
662
|
+
print("Usage: td restore github [repo|--repo <owner/name>]")
|
|
663
|
+
sys.exit(1)
|
|
664
|
+
|
|
665
|
+
from . import backup, db
|
|
666
|
+
|
|
667
|
+
_cli_ensure_unlocked()
|
|
668
|
+
try:
|
|
669
|
+
payload = backup.download_backup(_github_backup_repo(args))
|
|
670
|
+
password = _backup_password(confirm=False) if backup.is_encrypted_backup(payload) else None
|
|
671
|
+
db.import_from_json(backup.decode_backup(payload, password))
|
|
672
|
+
mode = "encrypted" if password else "unencrypted"
|
|
673
|
+
print(f"✓ {mode} GitHub backup restored and merged")
|
|
674
|
+
except Exception as error:
|
|
675
|
+
print(f"✗ GitHub restore failed: {error}")
|
|
676
|
+
sys.exit(1)
|
|
677
|
+
|
|
678
|
+
|
|
579
679
|
if __name__ == "__main__":
|
|
580
680
|
main()
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from typing import Any
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
15
|
+
from cryptography.hazmat.primitives import hashes
|
|
16
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
BACKUP_FILENAME = "td-backup.tdenc"
|
|
20
|
+
BACKUP_FORMAT = "td-github-backup-v1"
|
|
21
|
+
KEYCHAIN_SERVICE = "td-github-backup"
|
|
22
|
+
PBKDF2_ITERATIONS = 600_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _derive_key(password: str, salt: bytes, iterations: int) -> bytes:
|
|
26
|
+
if not password:
|
|
27
|
+
raise ValueError("Backup password cannot be empty")
|
|
28
|
+
return base64.urlsafe_b64encode(
|
|
29
|
+
PBKDF2HMAC(
|
|
30
|
+
algorithm=hashes.SHA256(),
|
|
31
|
+
length=32,
|
|
32
|
+
salt=salt,
|
|
33
|
+
iterations=iterations,
|
|
34
|
+
).derive(password.encode("utf-8"))
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encrypt_backup(json_data: str, password: str) -> str:
|
|
39
|
+
"""Encrypt a JSON export into a portable backup envelope."""
|
|
40
|
+
salt = os.urandom(16)
|
|
41
|
+
key = _derive_key(password, salt, PBKDF2_ITERATIONS)
|
|
42
|
+
ciphertext = Fernet(key).encrypt(json_data.encode("utf-8"))
|
|
43
|
+
envelope = {
|
|
44
|
+
"format": BACKUP_FORMAT,
|
|
45
|
+
"kdf": "pbkdf2-sha256",
|
|
46
|
+
"iterations": PBKDF2_ITERATIONS,
|
|
47
|
+
"salt": base64.b64encode(salt).decode("ascii"),
|
|
48
|
+
"ciphertext": ciphertext.decode("ascii"),
|
|
49
|
+
}
|
|
50
|
+
return json.dumps(envelope, indent=2) + "\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def backup_digest(json_data: str, encrypted: bool) -> str:
|
|
54
|
+
"""Return a stable digest for the exported data and storage mode."""
|
|
55
|
+
marker = "encrypted" if encrypted else "plain"
|
|
56
|
+
return hashlib.sha256(f"{marker}\0{json_data}".encode("utf-8")).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_encrypted_backup(data: str) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(data).get("format") == BACKUP_FORMAT
|
|
62
|
+
except (AttributeError, json.JSONDecodeError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decrypt_backup(encrypted_data: str, password: str) -> str:
|
|
67
|
+
"""Decrypt and validate a portable backup envelope."""
|
|
68
|
+
try:
|
|
69
|
+
envelope = json.loads(encrypted_data)
|
|
70
|
+
if envelope.get("format") != BACKUP_FORMAT:
|
|
71
|
+
raise ValueError("Unsupported backup format")
|
|
72
|
+
if envelope.get("kdf") != "pbkdf2-sha256":
|
|
73
|
+
raise ValueError("Unsupported backup KDF")
|
|
74
|
+
iterations = int(envelope["iterations"])
|
|
75
|
+
if iterations < 100_000 or iterations > 10_000_000:
|
|
76
|
+
raise ValueError("Unsupported backup KDF cost")
|
|
77
|
+
salt = base64.b64decode(envelope["salt"])
|
|
78
|
+
ciphertext = envelope["ciphertext"].encode("ascii")
|
|
79
|
+
key = _derive_key(password, salt, iterations)
|
|
80
|
+
return Fernet(key).decrypt(ciphertext).decode("utf-8")
|
|
81
|
+
except ValueError as error:
|
|
82
|
+
if str(error) in ("Unsupported backup format", "Unsupported backup KDF"):
|
|
83
|
+
raise
|
|
84
|
+
raise ValueError("Invalid backup password or corrupted backup") from error
|
|
85
|
+
except (KeyError, TypeError, InvalidToken, UnicodeDecodeError, binascii.Error) as error:
|
|
86
|
+
raise ValueError("Invalid backup password or corrupted backup") from error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def decode_backup(data: str, password: str | None = None) -> str:
|
|
90
|
+
"""Return plaintext for either encrypted or intentionally plain backups."""
|
|
91
|
+
if not is_encrypted_backup(data):
|
|
92
|
+
return data
|
|
93
|
+
if password is None:
|
|
94
|
+
raise ValueError("Backup password required")
|
|
95
|
+
return decrypt_backup(data, password)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_gh(
|
|
99
|
+
args: list[str], *, input_text: str | None = None
|
|
100
|
+
) -> subprocess.CompletedProcess[str]:
|
|
101
|
+
try:
|
|
102
|
+
return subprocess.run(
|
|
103
|
+
["gh", *args],
|
|
104
|
+
input=input_text,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
except FileNotFoundError as error:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
"gh CLI not found. Install it from https://cli.github.com/"
|
|
112
|
+
) from error
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _require_gh(result: subprocess.CompletedProcess[str]) -> str:
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown gh error"
|
|
118
|
+
raise RuntimeError(detail)
|
|
119
|
+
return result.stdout
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _private_repo(repo: str, *, create: bool) -> tuple[str, bool]:
|
|
123
|
+
_require_gh(_run_gh(["auth", "status"]))
|
|
124
|
+
viewed = _run_gh(["repo", "view", repo, "--json", "nameWithOwner,isPrivate,isEmpty"])
|
|
125
|
+
if viewed.returncode != 0:
|
|
126
|
+
if not create:
|
|
127
|
+
raise RuntimeError(f'GitHub repository "{repo}" not found or inaccessible')
|
|
128
|
+
created = _run_gh([
|
|
129
|
+
"repo",
|
|
130
|
+
"create",
|
|
131
|
+
repo,
|
|
132
|
+
"--private",
|
|
133
|
+
"--add-readme",
|
|
134
|
+
"--description",
|
|
135
|
+
"Encrypted td backup",
|
|
136
|
+
])
|
|
137
|
+
_require_gh(created)
|
|
138
|
+
viewed = _run_gh(["repo", "view", repo, "--json", "nameWithOwner,isPrivate,isEmpty"])
|
|
139
|
+
|
|
140
|
+
details = json.loads(_require_gh(viewed))
|
|
141
|
+
if not details.get("isPrivate"):
|
|
142
|
+
raise RuntimeError("Backup repository must be private")
|
|
143
|
+
return details["nameWithOwner"], details.get("isEmpty") is True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _contents_endpoint(repo: str) -> str:
|
|
147
|
+
return f"repos/{repo}/contents/{BACKUP_FILENAME}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def repo_url(repo: str) -> str:
|
|
151
|
+
"""Resolve a configured repository to its web URL through gh."""
|
|
152
|
+
result = _run_gh(["repo", "view", repo, "--json", "url", "--jq", ".url"])
|
|
153
|
+
return _require_gh(result).strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
157
|
+
try:
|
|
158
|
+
return subprocess.run(
|
|
159
|
+
["git", *args],
|
|
160
|
+
cwd=cwd,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
check=False,
|
|
164
|
+
)
|
|
165
|
+
except FileNotFoundError as error:
|
|
166
|
+
raise RuntimeError("git CLI not found") from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _require_git(result: subprocess.CompletedProcess[str]) -> str:
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown git error"
|
|
172
|
+
raise RuntimeError(detail)
|
|
173
|
+
return result.stdout
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _initialize_empty_repo(repo: str, data: str) -> None:
|
|
177
|
+
"""Create the first commit in an existing empty GitHub repository."""
|
|
178
|
+
with tempfile.TemporaryDirectory(prefix="td-github-backup-") as temp_dir:
|
|
179
|
+
clone_path = Path(temp_dir) / "repo"
|
|
180
|
+
_require_gh(_run_gh(["repo", "clone", repo, str(clone_path)]))
|
|
181
|
+
backup_path = clone_path / BACKUP_FILENAME
|
|
182
|
+
backup_path.write_text(data, encoding="utf-8")
|
|
183
|
+
_require_git(_run_git(["add", BACKUP_FILENAME], clone_path))
|
|
184
|
+
_require_git(_run_git([
|
|
185
|
+
"-c", "user.name=td-backup",
|
|
186
|
+
"-c", "user.email=td-backup@users.noreply.github.com",
|
|
187
|
+
"commit", "-m", "Create td backup",
|
|
188
|
+
], clone_path))
|
|
189
|
+
_require_git(_run_git(["push", "-u", "origin", "HEAD:main"], clone_path))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def store_password(repo: str, password: str) -> None:
|
|
193
|
+
"""Store a backup password in the macOS Keychain for auto-sync."""
|
|
194
|
+
if sys.platform != "darwin":
|
|
195
|
+
raise RuntimeError("automatic backup passwords require macOS Keychain")
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
[
|
|
198
|
+
"security",
|
|
199
|
+
"add-generic-password",
|
|
200
|
+
"-U",
|
|
201
|
+
"-a",
|
|
202
|
+
repo,
|
|
203
|
+
"-s",
|
|
204
|
+
KEYCHAIN_SERVICE,
|
|
205
|
+
"-w",
|
|
206
|
+
password,
|
|
207
|
+
],
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
check=False,
|
|
211
|
+
)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
detail = result.stderr.strip() or "Keychain update failed"
|
|
214
|
+
raise RuntimeError(detail)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_password(repo: str) -> str | None:
|
|
218
|
+
"""Load a stored backup password, if one exists."""
|
|
219
|
+
if sys.platform != "darwin":
|
|
220
|
+
return None
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
[
|
|
223
|
+
"security",
|
|
224
|
+
"find-generic-password",
|
|
225
|
+
"-w",
|
|
226
|
+
"-a",
|
|
227
|
+
repo,
|
|
228
|
+
"-s",
|
|
229
|
+
KEYCHAIN_SERVICE,
|
|
230
|
+
],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
check=False,
|
|
234
|
+
)
|
|
235
|
+
if result.returncode != 0:
|
|
236
|
+
return None
|
|
237
|
+
password = result.stdout.strip()
|
|
238
|
+
return password or None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def sync_if_configured(json_data: str) -> str | None:
|
|
242
|
+
"""Upload a configured auto-sync backup. Return repo, or None if disabled."""
|
|
243
|
+
from . import config
|
|
244
|
+
|
|
245
|
+
settings = config.github_backup()
|
|
246
|
+
if settings.get("auto_sync") is not True:
|
|
247
|
+
return None
|
|
248
|
+
repo = settings.get("repo")
|
|
249
|
+
if not isinstance(repo, str) or not repo.strip():
|
|
250
|
+
return None
|
|
251
|
+
repo = repo.strip()
|
|
252
|
+
should_encrypt = settings.get("encrypt", True) is not False
|
|
253
|
+
digest = backup_digest(json_data, should_encrypt)
|
|
254
|
+
if settings.get("last_sync_digest") == digest:
|
|
255
|
+
return None
|
|
256
|
+
if should_encrypt:
|
|
257
|
+
password = load_password(repo)
|
|
258
|
+
if password is None:
|
|
259
|
+
return None
|
|
260
|
+
payload = encrypt_backup(json_data, password)
|
|
261
|
+
else:
|
|
262
|
+
payload = json_data
|
|
263
|
+
resolved_repo = upload_backup(repo, payload)
|
|
264
|
+
config.set_github_backup(
|
|
265
|
+
repo=resolved_repo,
|
|
266
|
+
last_sync_digest=digest,
|
|
267
|
+
)
|
|
268
|
+
return resolved_repo
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def upload_backup(repo: str, encrypted_data: str) -> str:
|
|
272
|
+
"""Create or update the encrypted backup file in a private GitHub repo."""
|
|
273
|
+
name_with_owner, is_empty = _private_repo(repo, create=True)
|
|
274
|
+
if is_empty:
|
|
275
|
+
_initialize_empty_repo(name_with_owner, encrypted_data)
|
|
276
|
+
return name_with_owner
|
|
277
|
+
endpoint = _contents_endpoint(name_with_owner)
|
|
278
|
+
existing = _run_gh(["api", endpoint, "--jq", ".sha"])
|
|
279
|
+
body: dict[str, Any] = {
|
|
280
|
+
"message": "Update encrypted td backup",
|
|
281
|
+
"content": base64.b64encode(encrypted_data.encode("utf-8")).decode("ascii"),
|
|
282
|
+
}
|
|
283
|
+
if existing.returncode == 0 and existing.stdout.strip():
|
|
284
|
+
body["sha"] = existing.stdout.strip()
|
|
285
|
+
elif existing.returncode != 0 and "not found" not in existing.stderr.lower():
|
|
286
|
+
_require_gh(existing)
|
|
287
|
+
|
|
288
|
+
uploaded = _run_gh(
|
|
289
|
+
["api", endpoint, "--method", "PUT", "--input", "-"],
|
|
290
|
+
input_text=json.dumps(body),
|
|
291
|
+
)
|
|
292
|
+
_require_gh(uploaded)
|
|
293
|
+
return name_with_owner
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def download_backup(repo: str) -> str:
|
|
297
|
+
"""Download the encrypted backup file from a private GitHub repo."""
|
|
298
|
+
name_with_owner, _ = _private_repo(repo, create=False)
|
|
299
|
+
result = _run_gh(["api", _contents_endpoint(name_with_owner), "--jq", ".content"])
|
|
300
|
+
encoded = _require_gh(result)
|
|
301
|
+
try:
|
|
302
|
+
return base64.b64decode("".join(encoded.split())).decode("utf-8")
|
|
303
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
304
|
+
raise RuntimeError("GitHub backup file is not valid UTF-8") from error
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def path() -> Path:
|
|
10
|
+
return Path(
|
|
11
|
+
os.environ.get(
|
|
12
|
+
"TD_CONFIG_PATH",
|
|
13
|
+
Path.home() / ".config" / "td" / "config.toml",
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def github_backup() -> dict[str, object]:
|
|
19
|
+
"""Read GitHub backup settings without making config errors fatal."""
|
|
20
|
+
try:
|
|
21
|
+
with path().open("rb") as config_file:
|
|
22
|
+
section = tomllib.load(config_file).get("github_backup", {})
|
|
23
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
24
|
+
return {}
|
|
25
|
+
return section if isinstance(section, dict) else {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_github_backup(
|
|
29
|
+
*,
|
|
30
|
+
repo: str | None = None,
|
|
31
|
+
auto_sync: bool | None = None,
|
|
32
|
+
encrypt: bool | None = None,
|
|
33
|
+
last_sync_digest: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Update GitHub backup settings while preserving the other values."""
|
|
36
|
+
current = github_backup()
|
|
37
|
+
if repo is not None:
|
|
38
|
+
current["repo"] = repo
|
|
39
|
+
if auto_sync is not None:
|
|
40
|
+
current["auto_sync"] = auto_sync
|
|
41
|
+
if encrypt is not None:
|
|
42
|
+
current["encrypt"] = encrypt
|
|
43
|
+
if last_sync_digest is not None:
|
|
44
|
+
current["last_sync_digest"] = last_sync_digest
|
|
45
|
+
|
|
46
|
+
config_path = path()
|
|
47
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
lines = ["[github_backup]"]
|
|
49
|
+
if isinstance(current.get("repo"), str) and current["repo"].strip():
|
|
50
|
+
lines.append(f"repo = {json.dumps(current['repo'].strip())}")
|
|
51
|
+
lines.append(f"auto_sync = {str(current.get('auto_sync', False)).lower()}")
|
|
52
|
+
lines.append(f"encrypt = {str(current.get('encrypt', True)).lower()}")
|
|
53
|
+
if isinstance(current.get("last_sync_digest"), str):
|
|
54
|
+
lines.append(f"last_sync_digest = {json.dumps(current['last_sync_digest'])}")
|
|
55
|
+
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
@@ -9,6 +9,7 @@ from rich.console import Console
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
from . import db
|
|
12
|
+
from . import config
|
|
12
13
|
from . import terminal as term
|
|
13
14
|
|
|
14
15
|
console = Console()
|
|
@@ -136,6 +137,100 @@ def _copy_to_clipboard(text: str) -> bool:
|
|
|
136
137
|
return False
|
|
137
138
|
|
|
138
139
|
|
|
140
|
+
def _auto_sync() -> None:
|
|
141
|
+
from . import backup
|
|
142
|
+
|
|
143
|
+
settings = config.github_backup()
|
|
144
|
+
if settings.get("auto_sync") is not True:
|
|
145
|
+
return
|
|
146
|
+
repo = settings.get("repo", "td-backup")
|
|
147
|
+
if not isinstance(repo, str) or not repo.strip():
|
|
148
|
+
return
|
|
149
|
+
should_encrypt = settings.get("encrypt", True) is not False
|
|
150
|
+
if should_encrypt and backup.load_password(repo.strip()) is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
_render_github_backup_loading(repo.strip(), should_encrypt)
|
|
155
|
+
repo = backup.sync_if_configured(db.export_to_json())
|
|
156
|
+
if repo:
|
|
157
|
+
print(
|
|
158
|
+
f"✓ auto-synced backup to {repo}/{backup.BACKUP_FILENAME}",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
except Exception as error:
|
|
162
|
+
print(f"✗ auto-sync failed: {error}", file=sys.stderr)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@_buffered_frame
|
|
166
|
+
def _render_github_backup_loading(repo: str, encrypted: bool) -> None:
|
|
167
|
+
header = Text("GitHub backup • ", style="bold")
|
|
168
|
+
header.append(Text("working", style="bold yellow"))
|
|
169
|
+
console.print(header, end="\033[K\n")
|
|
170
|
+
console.print(Text("─" * min(len(_normal_hint_text(False)), console.width or 80), style="dim"), end="\033[K\n")
|
|
171
|
+
console.print(end="\033[K\n")
|
|
172
|
+
console.print(Text(f" Uploading {'encrypted ' if encrypted else ''}backup to {repo}...", style="yellow bold"), end="\033[K\n")
|
|
173
|
+
console.print(Text(" Please wait.", style="dim"), end="\033[K\n")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _github_backup_now() -> str:
|
|
177
|
+
from . import backup
|
|
178
|
+
|
|
179
|
+
settings = config.github_backup()
|
|
180
|
+
repo = settings.get("repo", "td-backup")
|
|
181
|
+
if not isinstance(repo, str) or not repo.strip():
|
|
182
|
+
repo = "td-backup"
|
|
183
|
+
should_encrypt = settings.get("encrypt", True) is not False
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
password: str | None = None
|
|
187
|
+
if should_encrypt:
|
|
188
|
+
password = prompt_password("Backup password: ")
|
|
189
|
+
if not password:
|
|
190
|
+
return "✗ backup password cannot be empty"
|
|
191
|
+
confirm = prompt_password("Confirm backup password: ")
|
|
192
|
+
if password != confirm:
|
|
193
|
+
return "✗ backup passwords do not match"
|
|
194
|
+
|
|
195
|
+
_render_github_backup_loading(repo.strip(), should_encrypt)
|
|
196
|
+
data = db.export_to_json()
|
|
197
|
+
payload = backup.encrypt_backup(data, password) if password else data
|
|
198
|
+
resolved_repo = backup.upload_backup(repo.strip(), payload)
|
|
199
|
+
config.set_github_backup(
|
|
200
|
+
repo=resolved_repo,
|
|
201
|
+
last_sync_digest=backup.backup_digest(data, should_encrypt),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if password:
|
|
205
|
+
key = _read_message([
|
|
206
|
+
Text(" Backup uploaded. Save password for auto-sync? [y/N]", style="yellow bold")
|
|
207
|
+
])
|
|
208
|
+
if key.lower() == "y":
|
|
209
|
+
backup.store_password(resolved_repo, password)
|
|
210
|
+
return f"✓ encrypted backup uploaded to {resolved_repo}"
|
|
211
|
+
mode = "encrypted" if password else "unencrypted"
|
|
212
|
+
return f"✓ {mode} backup uploaded to {resolved_repo}"
|
|
213
|
+
except Exception as error:
|
|
214
|
+
return f"✗ GitHub backup failed: {error}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _open_github_repo() -> str:
|
|
218
|
+
import webbrowser
|
|
219
|
+
from . import backup
|
|
220
|
+
|
|
221
|
+
settings = config.github_backup()
|
|
222
|
+
repo = settings.get("repo", "td-backup")
|
|
223
|
+
if not isinstance(repo, str) or not repo.strip():
|
|
224
|
+
repo = "td-backup"
|
|
225
|
+
try:
|
|
226
|
+
url = backup.repo_url(repo.strip())
|
|
227
|
+
if not webbrowser.open_new_tab(url):
|
|
228
|
+
return "✗ could not open browser"
|
|
229
|
+
return f"✓ opened {url}"
|
|
230
|
+
except Exception as error:
|
|
231
|
+
return f"✗ could not open GitHub repo: {error}"
|
|
232
|
+
|
|
233
|
+
|
|
139
234
|
def prompt_password(
|
|
140
235
|
prompt_text: str = "Enter password: ", allow_back: bool = False
|
|
141
236
|
) -> str | None:
|
|
@@ -2161,6 +2256,13 @@ def _render_settings(
|
|
|
2161
2256
|
) -> None:
|
|
2162
2257
|
max_tasks = db.get_max_tasks(list_name)
|
|
2163
2258
|
max_starred = db.get_max_starred_tasks()
|
|
2259
|
+
github_settings = config.github_backup()
|
|
2260
|
+
github_repo = github_settings.get("repo", "td-backup")
|
|
2261
|
+
if not isinstance(github_repo, str) or not github_repo.strip():
|
|
2262
|
+
github_repo = "td-backup"
|
|
2263
|
+
github_auto_sync = github_settings.get("auto_sync") is True
|
|
2264
|
+
github_encrypt = github_settings.get("encrypt", True) is not False
|
|
2265
|
+
|
|
2164
2266
|
header = Text("settings • ", style="bold")
|
|
2165
2267
|
header.append(Text(f"preferences ({list_name})", style="dim"))
|
|
2166
2268
|
console.print(header, end="\033[K\n")
|
|
@@ -2176,6 +2278,7 @@ def _render_settings(
|
|
|
2176
2278
|
divider_width = min(len(_normal_hint_text(False)), console.width or 80)
|
|
2177
2279
|
console.print(Text("─" * divider_width, style="dim"), end="\033[K\n")
|
|
2178
2280
|
console.print(end="\033[K\n")
|
|
2281
|
+
console.print(Text("Limits", style="bold yellow"), end="\033[K\n")
|
|
2179
2282
|
|
|
2180
2283
|
# Max tasks row
|
|
2181
2284
|
is_hovered_max = hover == 0
|
|
@@ -2217,6 +2320,9 @@ def _render_settings(
|
|
|
2217
2320
|
starred_line.append(Text(str(max_starred), style="dim"))
|
|
2218
2321
|
console.print(starred_line, end="\033[K\n")
|
|
2219
2322
|
|
|
2323
|
+
console.print(end="\033[K\n")
|
|
2324
|
+
console.print(Text("Security", style="bold yellow"), end="\033[K\n")
|
|
2325
|
+
|
|
2220
2326
|
# Database encryption row
|
|
2221
2327
|
is_hovered_enc = hover == 2
|
|
2222
2328
|
prefix_enc = "▸ " if is_hovered_enc else " "
|
|
@@ -2246,6 +2352,9 @@ def _render_settings(
|
|
|
2246
2352
|
list_enc_line.append(Text(list_enc_status, style="dim"))
|
|
2247
2353
|
console.print(list_enc_line, end="\033[K\n")
|
|
2248
2354
|
|
|
2355
|
+
console.print(end="\033[K\n")
|
|
2356
|
+
console.print(Text("Application", style="bold yellow"), end="\033[K\n")
|
|
2357
|
+
|
|
2249
2358
|
# Update row
|
|
2250
2359
|
is_hovered_update = hover == 4
|
|
2251
2360
|
prefix2 = "▸ " if is_hovered_update else " "
|
|
@@ -2256,6 +2365,9 @@ def _render_settings(
|
|
|
2256
2365
|
update_line.append(Text("update td", style="dim"))
|
|
2257
2366
|
console.print(update_line, end="\033[K\n")
|
|
2258
2367
|
|
|
2368
|
+
console.print(end="\033[K\n")
|
|
2369
|
+
console.print(Text("Data", style="bold yellow"), end="\033[K\n")
|
|
2370
|
+
|
|
2259
2371
|
# Export row
|
|
2260
2372
|
is_hovered_export = hover == 5
|
|
2261
2373
|
prefix_exp = "▸ " if is_hovered_export else " "
|
|
@@ -2290,6 +2402,63 @@ def _render_settings(
|
|
|
2290
2402
|
import_line.append(Text("import database", style="dim"))
|
|
2291
2403
|
console.print(import_line, end="\033[K\n")
|
|
2292
2404
|
|
|
2405
|
+
console.print(end="\033[K\n")
|
|
2406
|
+
console.print(Text("Sync", style="bold yellow"), end="\033[K\n")
|
|
2407
|
+
|
|
2408
|
+
# GitHub backup repo row
|
|
2409
|
+
is_hovered_github_repo = hover == 7
|
|
2410
|
+
prefix_github_repo = "▸ " if is_hovered_github_repo else " "
|
|
2411
|
+
github_repo_line = Text(prefix_github_repo)
|
|
2412
|
+
if mode == "edit" and hover == 7:
|
|
2413
|
+
github_repo_line.append(Text("GitHub backup repo: ", style="cyan bold"))
|
|
2414
|
+
github_repo_line.append(Text(edit_text[:edit_cursor], style="yellow bold"))
|
|
2415
|
+
char_under = edit_text[edit_cursor] if edit_cursor < len(edit_text) else " "
|
|
2416
|
+
github_repo_line.append(Text(char_under, style="reverse yellow bold"))
|
|
2417
|
+
if edit_cursor < len(edit_text):
|
|
2418
|
+
github_repo_line.append(Text(edit_text[edit_cursor + 1:], style="yellow bold"))
|
|
2419
|
+
elif is_hovered_github_repo:
|
|
2420
|
+
github_repo_line.append(Text("GitHub backup repo: ", style="cyan bold"))
|
|
2421
|
+
github_repo_line.append(Text(github_repo, style="bold"))
|
|
2422
|
+
else:
|
|
2423
|
+
github_repo_line.append(Text("GitHub backup repo: ", style="dim"))
|
|
2424
|
+
github_repo_line.append(Text(github_repo, style="dim"))
|
|
2425
|
+
console.print(github_repo_line, end="\033[K\n")
|
|
2426
|
+
|
|
2427
|
+
# GitHub backup action row
|
|
2428
|
+
is_hovered_github_backup = hover == 8
|
|
2429
|
+
prefix_github_backup = "▸ " if is_hovered_github_backup else " "
|
|
2430
|
+
github_backup_line = Text(prefix_github_backup)
|
|
2431
|
+
github_backup_line.append(Text(
|
|
2432
|
+
"backup to GitHub now",
|
|
2433
|
+
style="cyan bold" if is_hovered_github_backup else "dim",
|
|
2434
|
+
))
|
|
2435
|
+
console.print(github_backup_line, end="\033[K\n")
|
|
2436
|
+
|
|
2437
|
+
# GitHub auto-sync row
|
|
2438
|
+
is_hovered_auto_sync = hover == 9
|
|
2439
|
+
prefix_auto_sync = "▸ " if is_hovered_auto_sync else " "
|
|
2440
|
+
auto_sync_line = Text(prefix_auto_sync)
|
|
2441
|
+
auto_sync_line.append(Text("GitHub auto-sync: ", style="cyan bold" if is_hovered_auto_sync else "dim"))
|
|
2442
|
+
auto_sync_line.append(Text("on" if github_auto_sync else "off", style="bold" if is_hovered_auto_sync else "dim"))
|
|
2443
|
+
console.print(auto_sync_line, end="\033[K\n")
|
|
2444
|
+
|
|
2445
|
+
# GitHub backup encryption row
|
|
2446
|
+
is_hovered_github_encrypt = hover == 10
|
|
2447
|
+
prefix_github_encrypt = "▸ " if is_hovered_github_encrypt else " "
|
|
2448
|
+
github_encrypt_line = Text(prefix_github_encrypt)
|
|
2449
|
+
github_encrypt_line.append(Text("GitHub backup encryption: ", style="cyan bold" if is_hovered_github_encrypt else "dim"))
|
|
2450
|
+
github_encrypt_line.append(Text("on" if github_encrypt else "off", style="bold" if is_hovered_github_encrypt else "dim"))
|
|
2451
|
+
console.print(github_encrypt_line, end="\033[K\n")
|
|
2452
|
+
|
|
2453
|
+
is_hovered_open_repo = hover == 11
|
|
2454
|
+
prefix_open_repo = "▸ " if is_hovered_open_repo else " "
|
|
2455
|
+
open_repo_line = Text(prefix_open_repo)
|
|
2456
|
+
open_repo_line.append(Text(
|
|
2457
|
+
"open GitHub repo on web",
|
|
2458
|
+
style="cyan bold" if is_hovered_open_repo else "dim",
|
|
2459
|
+
))
|
|
2460
|
+
console.print(open_repo_line, end="\033[K\n")
|
|
2461
|
+
|
|
2293
2462
|
if status_msg:
|
|
2294
2463
|
console.print(end="\033[K\n")
|
|
2295
2464
|
console.print(Text(f" {status_msg}", style="yellow bold"), end="\033[K\n")
|
|
@@ -2310,7 +2479,7 @@ def _run_settings_loop(list_name: str) -> None:
|
|
|
2310
2479
|
edit_text = ""
|
|
2311
2480
|
edit_cursor = 0
|
|
2312
2481
|
status_msg = ""
|
|
2313
|
-
num_items =
|
|
2482
|
+
num_items = 12
|
|
2314
2483
|
|
|
2315
2484
|
while True:
|
|
2316
2485
|
_render_settings(
|
|
@@ -2356,6 +2525,23 @@ def _run_settings_loop(list_name: str) -> None:
|
|
|
2356
2525
|
mode = "edit"
|
|
2357
2526
|
edit_text = "backup.json"
|
|
2358
2527
|
edit_cursor = len(edit_text)
|
|
2528
|
+
elif hover == 7:
|
|
2529
|
+
mode = "edit"
|
|
2530
|
+
github_repo = config.github_backup().get("repo", "td-backup")
|
|
2531
|
+
edit_text = github_repo if isinstance(github_repo, str) else "td-backup"
|
|
2532
|
+
edit_cursor = len(edit_text)
|
|
2533
|
+
elif hover == 8:
|
|
2534
|
+
status_msg = _github_backup_now()
|
|
2535
|
+
elif hover == 9:
|
|
2536
|
+
enabled = config.github_backup().get("auto_sync") is True
|
|
2537
|
+
config.set_github_backup(auto_sync=not enabled)
|
|
2538
|
+
status_msg = f"✓ GitHub auto-sync {'enabled' if not enabled else 'disabled'}"
|
|
2539
|
+
elif hover == 10:
|
|
2540
|
+
encrypted = config.github_backup().get("encrypt", True) is not False
|
|
2541
|
+
config.set_github_backup(encrypt=not encrypted)
|
|
2542
|
+
status_msg = f"✓ GitHub backup encryption {'enabled' if not encrypted else 'disabled'}"
|
|
2543
|
+
elif hover == 11:
|
|
2544
|
+
status_msg = _open_github_repo()
|
|
2359
2545
|
elif hover == 2:
|
|
2360
2546
|
# Toggle encryption
|
|
2361
2547
|
if not db.is_encryption_enabled() and db.get_encrypted_lists():
|
|
@@ -2504,6 +2690,13 @@ def _run_settings_loop(list_name: str) -> None:
|
|
|
2504
2690
|
status_msg = f"✓ database imported from {filename}"
|
|
2505
2691
|
except Exception as e:
|
|
2506
2692
|
status_msg = f"✗ import failed: {e}"
|
|
2693
|
+
elif hover == 7:
|
|
2694
|
+
repo = edit_text.strip()
|
|
2695
|
+
if not repo:
|
|
2696
|
+
status_msg = "✗ GitHub backup repo cannot be empty"
|
|
2697
|
+
else:
|
|
2698
|
+
config.set_github_backup(repo=repo)
|
|
2699
|
+
status_msg = f"✓ GitHub backup repo set to {repo}"
|
|
2507
2700
|
mode = "normal"
|
|
2508
2701
|
edit_text = ""
|
|
2509
2702
|
edit_cursor = 0
|
|
@@ -2555,7 +2748,7 @@ def _run_settings_loop(list_name: str) -> None:
|
|
|
2555
2748
|
edit_cursor = 0
|
|
2556
2749
|
elif key == term.KEY_END:
|
|
2557
2750
|
edit_cursor = len(edit_text)
|
|
2558
|
-
elif len(key) == 1 and (key.isdigit() or (hover in (5, 6) and ord(key) >= 32)):
|
|
2751
|
+
elif len(key) == 1 and (key.isdigit() or (hover in (5, 6, 7) and ord(key) >= 32)):
|
|
2559
2752
|
edit_text = edit_text[:edit_cursor] + key + edit_text[edit_cursor:]
|
|
2560
2753
|
edit_cursor += 1
|
|
2561
2754
|
|
|
@@ -2582,6 +2775,7 @@ def run_main(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
2582
2775
|
if not _ensure_unlocked():
|
|
2583
2776
|
return
|
|
2584
2777
|
_run_main_loop(list_name, lock_list)
|
|
2778
|
+
_auto_sync()
|
|
2585
2779
|
except KeyboardInterrupt:
|
|
2586
2780
|
pass
|
|
2587
2781
|
finally:
|
|
@@ -2598,6 +2792,7 @@ def run_archive(list_name: str = "main", lock_list: bool = False) -> None:
|
|
|
2598
2792
|
if not _ensure_list_unlocked(list_name):
|
|
2599
2793
|
return
|
|
2600
2794
|
_run_archive_loop(list_name, lock_list)
|
|
2795
|
+
_auto_sync()
|
|
2601
2796
|
except KeyboardInterrupt:
|
|
2602
2797
|
pass
|
|
2603
2798
|
finally:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
from td import backup, config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackupTests(unittest.TestCase):
|
|
14
|
+
def test_encrypt_round_trip_hides_plaintext(self) -> None:
|
|
15
|
+
source = json.dumps({"tasks": [{"text": "secret task"}]})
|
|
16
|
+
|
|
17
|
+
encrypted = backup.encrypt_backup(source, "backup-password")
|
|
18
|
+
|
|
19
|
+
self.assertNotIn("secret task", encrypted)
|
|
20
|
+
self.assertEqual(backup.decrypt_backup(encrypted, "backup-password"), source)
|
|
21
|
+
|
|
22
|
+
def test_wrong_password_fails(self) -> None:
|
|
23
|
+
encrypted = backup.encrypt_backup('{"secret": true}', "backup-password")
|
|
24
|
+
|
|
25
|
+
with self.assertRaisesRegex(ValueError, "Invalid backup password"):
|
|
26
|
+
backup.decrypt_backup(encrypted, "wrong-password")
|
|
27
|
+
|
|
28
|
+
def test_plain_backup_decodes_without_password(self) -> None:
|
|
29
|
+
source = '{"tasks": [{"text": "visible task"}]}'
|
|
30
|
+
|
|
31
|
+
self.assertFalse(backup.is_encrypted_backup(source))
|
|
32
|
+
self.assertEqual(backup.decode_backup(source), source)
|
|
33
|
+
|
|
34
|
+
def test_auto_sync_can_use_plaintext_config_mode(self) -> None:
|
|
35
|
+
config_file = self._write_config(
|
|
36
|
+
'[github_backup]\nauto_sync = true\nrepo = "alice/td-backup"\nencrypt = false\n'
|
|
37
|
+
)
|
|
38
|
+
source = '{"tasks": [{"text": "visible task"}]}'
|
|
39
|
+
with (
|
|
40
|
+
patch.dict(os.environ, {"TD_CONFIG_PATH": str(config_file)}),
|
|
41
|
+
patch.object(backup, "upload_backup", return_value="alice/td-backup") as upload,
|
|
42
|
+
):
|
|
43
|
+
result = backup.sync_if_configured(source)
|
|
44
|
+
|
|
45
|
+
self.assertEqual(result, "alice/td-backup")
|
|
46
|
+
self.assertEqual(upload.call_args.args, ("alice/td-backup", source))
|
|
47
|
+
|
|
48
|
+
def test_auto_sync_skips_unchanged_export(self) -> None:
|
|
49
|
+
source = '{"tasks": [{"text": "unchanged"}]}'
|
|
50
|
+
digest = backup.backup_digest(source, encrypted=False)
|
|
51
|
+
config_file = self._write_config(
|
|
52
|
+
f'[github_backup]\nauto_sync = true\nrepo = "alice/td-backup"\nencrypt = false\nlast_sync_digest = "{digest}"\n'
|
|
53
|
+
)
|
|
54
|
+
with (
|
|
55
|
+
patch.dict(os.environ, {"TD_CONFIG_PATH": str(config_file)}),
|
|
56
|
+
patch.object(backup, "upload_backup") as upload,
|
|
57
|
+
):
|
|
58
|
+
result = backup.sync_if_configured(source)
|
|
59
|
+
|
|
60
|
+
self.assertIsNone(result)
|
|
61
|
+
upload.assert_not_called()
|
|
62
|
+
|
|
63
|
+
def _write_config(self, contents: str):
|
|
64
|
+
import tempfile
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
|
|
67
|
+
directory = tempfile.TemporaryDirectory()
|
|
68
|
+
self.addCleanup(directory.cleanup)
|
|
69
|
+
path = Path(directory.name) / "config.toml"
|
|
70
|
+
path.write_text(contents, encoding="utf-8")
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
def test_upload_creates_private_repo_and_updates_file(self) -> None:
|
|
74
|
+
responses = [
|
|
75
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
76
|
+
subprocess.CompletedProcess([], 1, "", "not found"),
|
|
77
|
+
subprocess.CompletedProcess([], 0, "https://github.com/alice/td-backup\n", ""),
|
|
78
|
+
subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/td-backup","isPrivate":true}', ""),
|
|
79
|
+
subprocess.CompletedProcess([], 1, "", "HTTP 404: Not Found"),
|
|
80
|
+
subprocess.CompletedProcess([], 0, '{"content":{"name":"td-backup.tdenc"}}', ""),
|
|
81
|
+
]
|
|
82
|
+
with patch.object(backup, "_run_gh", side_effect=responses) as run_gh:
|
|
83
|
+
repo = backup.upload_backup("alice/td-backup", '{"ciphertext":"x"}')
|
|
84
|
+
|
|
85
|
+
self.assertEqual(repo, "alice/td-backup")
|
|
86
|
+
upload_call = run_gh.call_args_list[-1]
|
|
87
|
+
self.assertEqual(upload_call.args[0][-4:], ["--method", "PUT", "--input", "-"])
|
|
88
|
+
body = json.loads(upload_call.kwargs["input_text"])
|
|
89
|
+
self.assertEqual(body["message"], "Update encrypted td backup")
|
|
90
|
+
self.assertNotIn("ciphertext", body["content"])
|
|
91
|
+
|
|
92
|
+
def test_public_repo_is_rejected(self) -> None:
|
|
93
|
+
responses = [
|
|
94
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
95
|
+
subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/public","isPrivate":false}', ""),
|
|
96
|
+
]
|
|
97
|
+
with patch.object(backup, "_run_gh", side_effect=responses):
|
|
98
|
+
with self.assertRaisesRegex(RuntimeError, "must be private"):
|
|
99
|
+
backup.upload_backup("alice/public", "encrypted")
|
|
100
|
+
|
|
101
|
+
def test_upload_initializes_existing_empty_repo(self) -> None:
|
|
102
|
+
gh_responses = iter([
|
|
103
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
104
|
+
subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/empty","isPrivate":true,"isEmpty":true}', ""),
|
|
105
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
106
|
+
])
|
|
107
|
+
git_responses = iter([
|
|
108
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
109
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
110
|
+
subprocess.CompletedProcess([], 0, "", ""),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
def run_gh(args: list[str], **kwargs):
|
|
114
|
+
if args[:2] == ["repo", "clone"]:
|
|
115
|
+
Path(args[-1]).mkdir()
|
|
116
|
+
return next(gh_responses)
|
|
117
|
+
|
|
118
|
+
with (
|
|
119
|
+
patch.object(backup, "_run_gh", side_effect=run_gh),
|
|
120
|
+
patch.object(backup, "_run_git", side_effect=lambda *args, **kwargs: next(git_responses)) as run_git,
|
|
121
|
+
):
|
|
122
|
+
repo = backup.upload_backup("alice/empty", "encrypted")
|
|
123
|
+
|
|
124
|
+
self.assertEqual(repo, "alice/empty")
|
|
125
|
+
self.assertEqual(run_git.call_args_list[-1].args[0], ["push", "-u", "origin", "HEAD:main"])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import tempfile
|
|
6
7
|
import unittest
|
|
7
8
|
from contextlib import redirect_stdout
|
|
@@ -15,6 +16,8 @@ from td.__main__ import _run_notes
|
|
|
15
16
|
class NoteCliTests(unittest.TestCase):
|
|
16
17
|
def setUp(self) -> None:
|
|
17
18
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
19
|
+
self.original_config_path = os.environ.get("TD_CONFIG_PATH")
|
|
20
|
+
os.environ["TD_CONFIG_PATH"] = str(Path(self.temp_dir.name) / "config.toml")
|
|
18
21
|
self.original_path = db.DB_PATH
|
|
19
22
|
self.original_key = db.ENCRYPTION_KEY
|
|
20
23
|
self.original_list_keys = db.LIST_ENCRYPTION_KEYS.copy()
|
|
@@ -27,6 +30,10 @@ class NoteCliTests(unittest.TestCase):
|
|
|
27
30
|
db.ENCRYPTION_KEY = self.original_key
|
|
28
31
|
db.LIST_ENCRYPTION_KEYS.clear()
|
|
29
32
|
db.LIST_ENCRYPTION_KEYS.update(self.original_list_keys)
|
|
33
|
+
if self.original_config_path is None:
|
|
34
|
+
os.environ.pop("TD_CONFIG_PATH", None)
|
|
35
|
+
else:
|
|
36
|
+
os.environ["TD_CONFIG_PATH"] = self.original_config_path
|
|
30
37
|
self.temp_dir.cleanup()
|
|
31
38
|
|
|
32
39
|
def _run(self, args: list[str], list_name: str = "work", has_list: bool = True) -> str:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
|
+
import os
|
|
4
5
|
import tempfile
|
|
5
6
|
import unittest
|
|
6
7
|
from pathlib import Path
|
|
@@ -16,6 +17,8 @@ from td import db, terminal, tui
|
|
|
16
17
|
class NoteTuiTests(unittest.TestCase):
|
|
17
18
|
def setUp(self) -> None:
|
|
18
19
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
20
|
+
self.original_config_path = os.environ.get("TD_CONFIG_PATH")
|
|
21
|
+
os.environ["TD_CONFIG_PATH"] = str(Path(self.temp_dir.name) / "config.toml")
|
|
19
22
|
self.original_path = db.DB_PATH
|
|
20
23
|
self.original_key = db.ENCRYPTION_KEY
|
|
21
24
|
self.original_list_keys = db.LIST_ENCRYPTION_KEYS.copy()
|
|
@@ -28,6 +31,10 @@ class NoteTuiTests(unittest.TestCase):
|
|
|
28
31
|
db.ENCRYPTION_KEY = self.original_key
|
|
29
32
|
db.LIST_ENCRYPTION_KEYS.clear()
|
|
30
33
|
db.LIST_ENCRYPTION_KEYS.update(self.original_list_keys)
|
|
34
|
+
if self.original_config_path is None:
|
|
35
|
+
os.environ.pop("TD_CONFIG_PATH", None)
|
|
36
|
+
else:
|
|
37
|
+
os.environ["TD_CONFIG_PATH"] = self.original_config_path
|
|
31
38
|
self.temp_dir.cleanup()
|
|
32
39
|
|
|
33
40
|
def _edit_note(self, note_id: int, keys: list[str]) -> None:
|
|
@@ -606,6 +613,16 @@ class NoteTuiTests(unittest.TestCase):
|
|
|
606
613
|
tui._run_settings_loop("main")
|
|
607
614
|
self.assertFalse(db.is_list_encryption_enabled("main"))
|
|
608
615
|
|
|
616
|
+
def test_settings_can_run_github_backup(self) -> None:
|
|
617
|
+
keys = iter([terminal.KEY_ARROW_DOWN] * 8 + [terminal.KEY_ENTER, "q"])
|
|
618
|
+
with (
|
|
619
|
+
patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
|
|
620
|
+
patch.object(tui, "_github_backup_now", return_value="✓ backup uploaded") as backup_now,
|
|
621
|
+
):
|
|
622
|
+
tui._run_settings_loop("main")
|
|
623
|
+
|
|
624
|
+
backup_now.assert_called_once_with()
|
|
625
|
+
|
|
609
626
|
|
|
610
627
|
if __name__ == "__main__":
|
|
611
628
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|