remarkablesync 2.0.2__tar.gz → 2.0.3__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.
- {remarkablesync-2.0.2/remarkablesync.egg-info → remarkablesync-2.0.3}/PKG-INFO +1 -1
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/RemarkableSync.py +7 -2
- {remarkablesync-2.0.2 → remarkablesync-2.0.3/remarkablesync.egg-info}/PKG-INFO +1 -1
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/SOURCES.txt +2 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/__version__.py +1 -1
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/backup_manager.py +33 -48
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/connection.py +55 -15
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/config_command.py +61 -21
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/pipeline.py +20 -11
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/sync_command.py +14 -4
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/config.py +2 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/pdf_md_converter.py +1 -1
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/rm_pdf_converter.py +1 -1
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/__init__.py +29 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/console.py +5 -5
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/logging.py +6 -1
- remarkablesync-2.0.3/tests/test_agent_issues.py +362 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_config.py +2 -0
- remarkablesync-2.0.3/tests/test_config_command.py +114 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_pipeline.py +2 -2
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/LICENSE +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/MANIFEST.in +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/README.md +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/pyproject.toml +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/dependency_links.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/entry_points.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/requires.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/top_level.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/requirements-dev.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/requirements.txt +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/setup.cfg +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/setup.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/base_provider.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/claude_provider.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/github_models_provider.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/auth/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/auth/github_device_flow.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/metadata.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/backup_command.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/convert_command.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/watch_command.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/base_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v4_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v5_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v6_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/hybrid_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/keyring_store.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/md_export/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ocr/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ocr/ocr_engine.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/template_renderer.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/name_registry.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/__init__.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/conftest.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/generate_test_assets.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/mock_ai_provider.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/mock_connection.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_ai_providers.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_auth_device_flow.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_basic.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_console.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_hybrid_converter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_keyring_store.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_logging.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_md_exporter.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_metadata.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_ocr_pipeline.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_template_renderer.py +0 -0
- {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_wifi_connection.py +0 -0
|
@@ -590,7 +590,13 @@ def main():
|
|
|
590
590
|
|
|
591
591
|
if not has_command and '--version' not in sys.argv and '--help' not in sys.argv:
|
|
592
592
|
# Load config to decide which pipeline to run
|
|
593
|
-
from src.config import load_config
|
|
593
|
+
from src.config import get_config_path, load_config
|
|
594
|
+
|
|
595
|
+
if not get_config_path().exists():
|
|
596
|
+
script_name = Path(sys.argv[0]).name or "RemarkableSync.py"
|
|
597
|
+
click.echo("[ERROR] No configuration found.", err=True)
|
|
598
|
+
click.echo(f"Run: python {script_name} config", err=True)
|
|
599
|
+
sys.exit(1)
|
|
594
600
|
|
|
595
601
|
cfg = load_config()
|
|
596
602
|
actions = cfg.get("sync_actions", [])
|
|
@@ -638,4 +644,3 @@ def main():
|
|
|
638
644
|
|
|
639
645
|
if __name__ == "__main__":
|
|
640
646
|
main()
|
|
641
|
-
|
|
@@ -54,10 +54,12 @@ tests/conftest.py
|
|
|
54
54
|
tests/generate_test_assets.py
|
|
55
55
|
tests/mock_ai_provider.py
|
|
56
56
|
tests/mock_connection.py
|
|
57
|
+
tests/test_agent_issues.py
|
|
57
58
|
tests/test_ai_providers.py
|
|
58
59
|
tests/test_auth_device_flow.py
|
|
59
60
|
tests/test_basic.py
|
|
60
61
|
tests/test_config.py
|
|
62
|
+
tests/test_config_command.py
|
|
61
63
|
tests/test_console.py
|
|
62
64
|
tests/test_hybrid_converter.py
|
|
63
65
|
tests/test_keyring_store.py
|
|
@@ -48,6 +48,8 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
48
48
|
host: str = "10.11.99.1",
|
|
49
49
|
use_wifi: bool = False,
|
|
50
50
|
wifi_host: str = "",
|
|
51
|
+
pre_sync_command: str = "",
|
|
52
|
+
post_sync_command: str = "",
|
|
51
53
|
):
|
|
52
54
|
"""Initialize backup orchestrator.
|
|
53
55
|
|
|
@@ -57,22 +59,24 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
57
59
|
host: Tablet IP/hostname (USB default: 10.11.99.1)
|
|
58
60
|
use_wifi: Connect via Wi-Fi instead of USB
|
|
59
61
|
wifi_host: Wi-Fi IP/hostname (auto-discovered if empty)
|
|
62
|
+
pre_sync_command: Shell command to run before SSH connects.
|
|
63
|
+
post_sync_command: Shell command to run after SSH disconnects.
|
|
60
64
|
"""
|
|
61
65
|
self.backup_dir = backup_dir
|
|
62
|
-
self.files_dir = backup_dir / "Notebooks"
|
|
63
|
-
self.templates_dir = backup_dir / "Templates"
|
|
66
|
+
self.files_dir = backup_dir / "Notebooks"
|
|
67
|
+
self.templates_dir = backup_dir / "Templates"
|
|
64
68
|
self.metadata_file = backup_dir / "sync_metadata.json"
|
|
65
69
|
|
|
66
|
-
# Create directories
|
|
67
70
|
self.files_dir.mkdir(parents=True, exist_ok=True)
|
|
68
71
|
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
|
69
72
|
|
|
70
|
-
# Initialize components
|
|
71
73
|
self.connection = ReMarkableConnection(
|
|
72
74
|
password=password,
|
|
73
75
|
host=host,
|
|
74
76
|
use_wifi=use_wifi,
|
|
75
77
|
wifi_host=wifi_host,
|
|
78
|
+
pre_sync_command=pre_sync_command,
|
|
79
|
+
post_sync_command=post_sync_command,
|
|
76
80
|
)
|
|
77
81
|
self.metadata = FileMetadata(self.metadata_file)
|
|
78
82
|
|
|
@@ -173,23 +177,13 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
173
177
|
print(f" Found {len(allowed)} notebooks in selected folders")
|
|
174
178
|
return allowed
|
|
175
179
|
|
|
176
|
-
def
|
|
180
|
+
def _do_backup_files(
|
|
177
181
|
self,
|
|
178
182
|
) -> Tuple[bool, Set[str], Dict[str, Set[str]]]: # pylint: disable=too-many-branches
|
|
179
|
-
"""Backup files from ReMarkable tablet.
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
Tuple of (success, set of notebook UUIDs that were updated,
|
|
183
|
-
dict mapping notebook UUID to set of changed page IDs)
|
|
184
|
-
"""
|
|
183
|
+
"""Backup files from ReMarkable tablet. Assumes connection is already open."""
|
|
185
184
|
logging.info("Starting file backup...")
|
|
186
|
-
print(" Connecting to tablet...")
|
|
187
|
-
|
|
188
|
-
if not self.connection.connect():
|
|
189
|
-
return False, set(), {}
|
|
190
185
|
|
|
191
186
|
try:
|
|
192
|
-
# Resolve folder filter before listing files
|
|
193
187
|
allowed_uuids = self._resolve_allowed_uuids()
|
|
194
188
|
|
|
195
189
|
# Get list of remote files
|
|
@@ -316,23 +310,10 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
316
310
|
logging.error("Backup failed: %s", e)
|
|
317
311
|
return False, set(), {}
|
|
318
312
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def backup_templates(self) -> bool:
|
|
323
|
-
"""Backup template files from ReMarkable tablet.
|
|
324
|
-
|
|
325
|
-
Templates are stored in /usr/share/remarkable/templates/ and include
|
|
326
|
-
PNG/SVG template images and a templates.json configuration file.
|
|
327
|
-
|
|
328
|
-
Returns:
|
|
329
|
-
bool: True if successful, False otherwise
|
|
330
|
-
"""
|
|
313
|
+
def _do_backup_templates(self) -> bool:
|
|
314
|
+
"""Backup template files from ReMarkable tablet. Assumes connection is already open."""
|
|
331
315
|
logging.info("Starting template backup...")
|
|
332
316
|
|
|
333
|
-
if not self.connection.connect():
|
|
334
|
-
return False
|
|
335
|
-
|
|
336
317
|
try:
|
|
337
318
|
# Get list of template files
|
|
338
319
|
remote_files = self.connection.list_files(self.remote_templates_dir)
|
|
@@ -387,9 +368,6 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
387
368
|
logging.error("Template backup failed: %s", e)
|
|
388
369
|
return False
|
|
389
370
|
|
|
390
|
-
finally:
|
|
391
|
-
self.connection.disconnect()
|
|
392
|
-
|
|
393
371
|
def find_notebooks(self) -> List[Dict]:
|
|
394
372
|
"""Find and parse notebook metadata.
|
|
395
373
|
|
|
@@ -465,7 +443,7 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
465
443
|
force_convert_all: bool = False,
|
|
466
444
|
convert_to_pdf: bool = False,
|
|
467
445
|
backup_templates: bool = True,
|
|
468
|
-
) -> bool:
|
|
446
|
+
) -> Tuple[bool, Set[str], Dict[str, Set[str]]]:
|
|
469
447
|
"""Run complete backup process with optional PDF conversion.
|
|
470
448
|
|
|
471
449
|
Args:
|
|
@@ -474,27 +452,34 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
|
|
|
474
452
|
backup_templates: If True, backup template files from the tablet (default: True)
|
|
475
453
|
|
|
476
454
|
Returns:
|
|
477
|
-
|
|
455
|
+
Tuple of (success, updated_notebook_uuids, updated_pages)
|
|
478
456
|
"""
|
|
479
457
|
logging.info("Starting ReMarkable backup process")
|
|
480
458
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
459
|
+
if not self.connection.connect():
|
|
460
|
+
return False, set(), {}
|
|
461
|
+
|
|
462
|
+
updated_notebook_uuids: Set[str] = set()
|
|
463
|
+
updated_pages: Dict[str, Set[str]] = {}
|
|
485
464
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
465
|
+
try:
|
|
466
|
+
success, updated_notebook_uuids, updated_pages = self._do_backup_files()
|
|
467
|
+
if not success:
|
|
468
|
+
return False, set(), {}
|
|
469
|
+
|
|
470
|
+
if backup_templates:
|
|
471
|
+
templates_success = self._do_backup_templates()
|
|
472
|
+
if not templates_success:
|
|
473
|
+
logging.warning("Template backup failed, but continuing with main backup")
|
|
474
|
+
finally:
|
|
475
|
+
self.connection.disconnect()
|
|
491
476
|
|
|
492
|
-
# Automatic PDF conversion using hybrid converter
|
|
493
477
|
if convert_to_pdf:
|
|
494
|
-
|
|
478
|
+
ok = self.run_pdf_conversion(updated_notebook_uuids, force_convert_all, updated_pages)
|
|
479
|
+
return ok, updated_notebook_uuids, updated_pages
|
|
495
480
|
|
|
496
481
|
logging.info("Backup process completed successfully")
|
|
497
|
-
return True
|
|
482
|
+
return True, updated_notebook_uuids, updated_pages
|
|
498
483
|
|
|
499
484
|
def run_pdf_conversion(
|
|
500
485
|
self,
|
|
@@ -19,6 +19,14 @@ import click
|
|
|
19
19
|
import paramiko
|
|
20
20
|
from scp import SCPClient
|
|
21
21
|
|
|
22
|
+
from ..utils.console import print_error, print_success, print_warn
|
|
23
|
+
|
|
24
|
+
# Suppress paramiko noise regardless of when setup_logging is called
|
|
25
|
+
for _n in ("paramiko", "paramiko.transport", "paramiko.auth", "paramiko.channel"):
|
|
26
|
+
_l = logging.getLogger(_n)
|
|
27
|
+
_l.setLevel(logging.CRITICAL)
|
|
28
|
+
_l.propagate = False
|
|
29
|
+
|
|
22
30
|
try:
|
|
23
31
|
import keyring # type: ignore
|
|
24
32
|
|
|
@@ -76,6 +84,8 @@ class ReMarkableConnection:
|
|
|
76
84
|
password: str | None = None,
|
|
77
85
|
use_wifi: bool = False,
|
|
78
86
|
wifi_host: str = "",
|
|
87
|
+
pre_sync_command: str = "",
|
|
88
|
+
post_sync_command: str = "",
|
|
79
89
|
):
|
|
80
90
|
"""Initialize connection parameters.
|
|
81
91
|
|
|
@@ -89,6 +99,8 @@ class ReMarkableConnection:
|
|
|
89
99
|
Falls back to USB if *wifi_host* is empty.
|
|
90
100
|
wifi_host: IP address or hostname of the tablet on the local
|
|
91
101
|
network. Ignored when *use_wifi* is False.
|
|
102
|
+
pre_sync_command: Shell command to run before SSH connects.
|
|
103
|
+
post_sync_command: Shell command to run after SSH disconnects.
|
|
92
104
|
"""
|
|
93
105
|
# Resolve effective host
|
|
94
106
|
if use_wifi:
|
|
@@ -107,6 +119,8 @@ class ReMarkableConnection:
|
|
|
107
119
|
self.scp_client = None
|
|
108
120
|
self.password = password
|
|
109
121
|
self.password_saved = False
|
|
122
|
+
self.pre_sync_command = pre_sync_command.strip()
|
|
123
|
+
self.post_sync_command = post_sync_command.strip()
|
|
110
124
|
|
|
111
125
|
def get_saved_password(self) -> str | None:
|
|
112
126
|
"""Get saved password from system keyring.
|
|
@@ -188,13 +202,22 @@ class ReMarkableConnection:
|
|
|
188
202
|
def connect(self) -> bool:
|
|
189
203
|
"""Establish SSH connection to ReMarkable tablet.
|
|
190
204
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
Handles password retry logic if saved password fails.
|
|
205
|
+
Runs the pre-sync command (if configured) before opening the SSH
|
|
206
|
+
connection, and the post-sync command (if configured) in disconnect().
|
|
194
207
|
|
|
195
208
|
Returns:
|
|
196
209
|
bool: True if connection successful, False otherwise
|
|
197
210
|
"""
|
|
211
|
+
if self.pre_sync_command:
|
|
212
|
+
from ..utils import run_shell_command
|
|
213
|
+
|
|
214
|
+
print(f" Running pre-sync: {self.pre_sync_command}")
|
|
215
|
+
rc = run_shell_command(self.pre_sync_command)
|
|
216
|
+
if rc != 0:
|
|
217
|
+
print_error(f" ERR - Pre-sync command failed (exit {rc})")
|
|
218
|
+
return False
|
|
219
|
+
print_success(" OK - Pre-sync done")
|
|
220
|
+
|
|
198
221
|
max_password_retries = 3
|
|
199
222
|
password_attempt = 0
|
|
200
223
|
used_saved_password = False
|
|
@@ -246,7 +269,7 @@ class ReMarkableConnection:
|
|
|
246
269
|
logging.warning("Authentication failed on attempt %d: %s", i + 1, e)
|
|
247
270
|
# Authentication failed - might be wrong password
|
|
248
271
|
if used_saved_password:
|
|
249
|
-
|
|
272
|
+
print_warn(" WRN - Saved password appears to be incorrect.")
|
|
250
273
|
if click.confirm(
|
|
251
274
|
"Would you like to enter a new password?", default=True
|
|
252
275
|
):
|
|
@@ -263,13 +286,14 @@ class ReMarkableConnection:
|
|
|
263
286
|
else:
|
|
264
287
|
return False
|
|
265
288
|
else:
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
print_error(
|
|
290
|
+
" ERR - Authentication failed. Please check your password."
|
|
291
|
+
)
|
|
268
292
|
self.password = None
|
|
269
293
|
password_attempt += 1
|
|
270
294
|
break
|
|
271
295
|
except (paramiko.SSHException, OSError) as e:
|
|
272
|
-
logging.
|
|
296
|
+
logging.debug("Connection attempt %d failed: %s", i + 1, e)
|
|
273
297
|
if self.ssh_client:
|
|
274
298
|
try:
|
|
275
299
|
self.ssh_client.close()
|
|
@@ -278,28 +302,44 @@ class ReMarkableConnection:
|
|
|
278
302
|
self.ssh_client = paramiko.SSHClient()
|
|
279
303
|
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
280
304
|
|
|
281
|
-
logging.
|
|
305
|
+
logging.debug("All connection attempts failed")
|
|
306
|
+
|
|
307
|
+
print_error(
|
|
308
|
+
f" ERR - Connection to {self.host} failed. "
|
|
309
|
+
"Check that the tablet is connected and try again."
|
|
310
|
+
)
|
|
282
311
|
return False
|
|
283
312
|
|
|
284
313
|
except (paramiko.SSHException, OSError) as e:
|
|
285
|
-
logging.
|
|
314
|
+
logging.debug("Failed to connect to ReMarkable: %s", e)
|
|
315
|
+
print_error(
|
|
316
|
+
f" ERR - Connection to {self.host} failed. "
|
|
317
|
+
"Check that the tablet is connected and try again."
|
|
318
|
+
)
|
|
286
319
|
return False
|
|
287
320
|
|
|
288
|
-
|
|
321
|
+
print_error(" ERR - Maximum password retry attempts reached.")
|
|
289
322
|
return False
|
|
290
323
|
|
|
291
324
|
def disconnect(self):
|
|
292
|
-
"""Close SSH and SCP connections to ReMarkable tablet.
|
|
293
|
-
|
|
294
|
-
Safely closes both SCP and SSH client connections,
|
|
295
|
-
ensuring clean disconnection from the tablet.
|
|
296
|
-
"""
|
|
325
|
+
"""Close SSH and SCP connections to ReMarkable tablet."""
|
|
326
|
+
print(" Disconnecting...")
|
|
297
327
|
if self.scp_client:
|
|
298
328
|
self.scp_client.close()
|
|
299
329
|
if self.ssh_client:
|
|
300
330
|
self.ssh_client.close()
|
|
301
331
|
logging.info("Disconnected from ReMarkable tablet")
|
|
302
332
|
|
|
333
|
+
if self.post_sync_command:
|
|
334
|
+
from ..utils import run_shell_command
|
|
335
|
+
|
|
336
|
+
print(f" Running post-sync: {self.post_sync_command}")
|
|
337
|
+
rc = run_shell_command(self.post_sync_command)
|
|
338
|
+
if rc != 0:
|
|
339
|
+
print_error(f" ERR - Post-sync command failed (exit {rc})")
|
|
340
|
+
else:
|
|
341
|
+
print_success(" OK - Post-sync done")
|
|
342
|
+
|
|
303
343
|
def execute_command(self, command: str) -> Tuple[str, str, int]:
|
|
304
344
|
"""Execute command on ReMarkable tablet via SSH.
|
|
305
345
|
|
|
@@ -10,6 +10,7 @@ from typing import Any, Dict, List
|
|
|
10
10
|
import click
|
|
11
11
|
|
|
12
12
|
from src.config import SYNC_ACTIONS, load_config, save_config
|
|
13
|
+
from src.utils.console import print_warn
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def run_config_command() -> int:
|
|
@@ -214,6 +215,7 @@ def run_config_command() -> int:
|
|
|
214
215
|
# 7. Markdown export settings — OCR is implied when export is selected
|
|
215
216
|
ocr_enabled = current.get("ocr_enabled", False)
|
|
216
217
|
output_dir = current.get("output_dir", "")
|
|
218
|
+
embed_images = current.get("embed_images", True)
|
|
217
219
|
|
|
218
220
|
if "ocr" in sync_actions:
|
|
219
221
|
ocr_enabled = True
|
|
@@ -231,7 +233,6 @@ def run_config_command() -> int:
|
|
|
231
233
|
click.echo(f" Using default: {output_dir}")
|
|
232
234
|
|
|
233
235
|
# 7b. Embed page images with Markdown?
|
|
234
|
-
embed_images = current.get("embed_images", True)
|
|
235
236
|
embed_images = inquirer.confirm(
|
|
236
237
|
message="Include page images alongside Markdown files?",
|
|
237
238
|
default=embed_images,
|
|
@@ -352,13 +353,41 @@ def run_config_command() -> int:
|
|
|
352
353
|
else:
|
|
353
354
|
click.echo(" Skipped. You can set ANTHROPIC_API_KEY env var instead.")
|
|
354
355
|
|
|
355
|
-
# 8.
|
|
356
|
+
# 8. Pre/post-sync commands (optional)
|
|
357
|
+
pre_sync_command = current.get("pre_sync_command", "")
|
|
358
|
+
post_sync_command = current.get("post_sync_command", "")
|
|
359
|
+
|
|
360
|
+
click.echo()
|
|
361
|
+
click.echo(" Optional: shell commands to run before and after sync.")
|
|
362
|
+
click.echo(" Useful for disabling VPNs, network tools, etc. Leave blank to skip.")
|
|
363
|
+
click.echo()
|
|
364
|
+
|
|
365
|
+
pre_sync_command = (
|
|
366
|
+
inquirer.text(
|
|
367
|
+
message="Pre-sync command (blank=none):",
|
|
368
|
+
default=pre_sync_command,
|
|
369
|
+
).execute()
|
|
370
|
+
or ""
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
post_sync_command = (
|
|
374
|
+
inquirer.text(
|
|
375
|
+
message="Post-sync command (blank=none):",
|
|
376
|
+
default=post_sync_command,
|
|
377
|
+
).execute()
|
|
378
|
+
or ""
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# 9. Connect to tablet and select folders
|
|
356
382
|
click.echo()
|
|
357
383
|
click.echo(" Connecting to tablet to discover folders...")
|
|
384
|
+
|
|
358
385
|
folder_choices = _get_folder_choices_live(
|
|
359
386
|
connection_mode,
|
|
360
387
|
password,
|
|
361
388
|
wifi_host,
|
|
389
|
+
pre_sync_command=pre_sync_command,
|
|
390
|
+
post_sync_command=post_sync_command,
|
|
362
391
|
)
|
|
363
392
|
folders: List[str] = []
|
|
364
393
|
|
|
@@ -378,7 +407,7 @@ def run_config_command() -> int:
|
|
|
378
407
|
click.echo("Configuration cancelled.")
|
|
379
408
|
return 0
|
|
380
409
|
else:
|
|
381
|
-
|
|
410
|
+
print_warn(" WRN - Could not connect to tablet. Folder selection skipped.")
|
|
382
411
|
folders = current.get("folders", [])
|
|
383
412
|
|
|
384
413
|
# Save configuration — preserve keys not managed by this wizard
|
|
@@ -397,6 +426,8 @@ def run_config_command() -> int:
|
|
|
397
426
|
"embed_images": embed_images,
|
|
398
427
|
"ai_provider": ai_provider,
|
|
399
428
|
"ai_model": ai_model,
|
|
429
|
+
"pre_sync_command": pre_sync_command,
|
|
430
|
+
"post_sync_command": post_sync_command,
|
|
400
431
|
}
|
|
401
432
|
)
|
|
402
433
|
|
|
@@ -407,22 +438,26 @@ def run_config_command() -> int:
|
|
|
407
438
|
click.echo(" Configuration saved!")
|
|
408
439
|
click.echo("=" * 70)
|
|
409
440
|
click.echo()
|
|
410
|
-
click.echo(f" File:
|
|
411
|
-
click.echo(f" Mode:
|
|
441
|
+
click.echo(f" File: {path}")
|
|
442
|
+
click.echo(f" Mode: {connection_mode.upper()}")
|
|
412
443
|
if connection_mode == "wifi":
|
|
413
|
-
click.echo(f" Host:
|
|
414
|
-
click.echo(f" Password:
|
|
415
|
-
click.echo(f" Backup:
|
|
444
|
+
click.echo(f" Host: {wifi_host}")
|
|
445
|
+
click.echo(f" Password: {'••••••••' if password else '(not set)'}")
|
|
446
|
+
click.echo(f" Backup: {backup_dir}")
|
|
416
447
|
if pdf_dir:
|
|
417
|
-
click.echo(f" PDFs:
|
|
418
|
-
click.echo(f" Folders:
|
|
419
|
-
click.echo(f" Actions:
|
|
448
|
+
click.echo(f" PDFs: {pdf_dir}")
|
|
449
|
+
click.echo(f" Folders: {', '.join(folders) if folders else '(all)'}")
|
|
450
|
+
click.echo(f" Actions: {', '.join(sync_actions)}")
|
|
420
451
|
if ocr_enabled:
|
|
421
|
-
click.echo(f" MD:
|
|
422
|
-
click.echo(f" Images:
|
|
423
|
-
click.echo(f" AI:
|
|
452
|
+
click.echo(f" MD: {output_dir}")
|
|
453
|
+
click.echo(f" Images: {'yes (_images/ folder)' if embed_images else 'no'}")
|
|
454
|
+
click.echo(f" AI: {ai_provider} ({ai_model})")
|
|
424
455
|
has_token = bool(github_token or claude_api_key)
|
|
425
|
-
click.echo(f" Token:
|
|
456
|
+
click.echo(f" Token: {'OK - saved in keyring' if has_token else '(not set)'}")
|
|
457
|
+
if pre_sync_command:
|
|
458
|
+
click.echo(f" Pre: {pre_sync_command}")
|
|
459
|
+
if post_sync_command:
|
|
460
|
+
click.echo(f" Post: {post_sync_command}")
|
|
426
461
|
click.echo()
|
|
427
462
|
|
|
428
463
|
return 0
|
|
@@ -473,7 +508,7 @@ def _enable_wifi_ssh(password: str) -> str:
|
|
|
473
508
|
click.echo(" Connecting via USB...")
|
|
474
509
|
|
|
475
510
|
if not conn.connect():
|
|
476
|
-
|
|
511
|
+
print_warn(" WRN - Could not connect via USB. Is the tablet plugged in?")
|
|
477
512
|
return ""
|
|
478
513
|
|
|
479
514
|
try:
|
|
@@ -508,7 +543,11 @@ def _enable_wifi_ssh(password: str) -> str:
|
|
|
508
543
|
|
|
509
544
|
|
|
510
545
|
def _get_folder_choices_live(
|
|
511
|
-
connection_mode: str,
|
|
546
|
+
connection_mode: str,
|
|
547
|
+
password: str,
|
|
548
|
+
wifi_host: str,
|
|
549
|
+
pre_sync_command: str = "",
|
|
550
|
+
post_sync_command: str = "",
|
|
512
551
|
) -> List[Dict[str, Any]]:
|
|
513
552
|
"""Connect to the tablet and discover top-level folders.
|
|
514
553
|
|
|
@@ -529,13 +568,14 @@ def _get_folder_choices_live(
|
|
|
529
568
|
host=host,
|
|
530
569
|
use_wifi=use_wifi,
|
|
531
570
|
wifi_host=wifi_host,
|
|
571
|
+
pre_sync_command=pre_sync_command,
|
|
572
|
+
post_sync_command=post_sync_command,
|
|
532
573
|
)
|
|
533
574
|
|
|
534
|
-
if not conn.connect():
|
|
535
|
-
click.echo(" WRN - Could not connect to tablet.")
|
|
536
|
-
return []
|
|
537
|
-
|
|
538
575
|
try:
|
|
576
|
+
if not conn.connect():
|
|
577
|
+
print_warn(" WRN - Could not connect to tablet.")
|
|
578
|
+
return []
|
|
539
579
|
xochitl = "/home/root/.local/share/remarkable/xochitl"
|
|
540
580
|
|
|
541
581
|
# Use a single command to dump all metadata files efficiently
|
|
@@ -101,6 +101,8 @@ def run_pipeline(
|
|
|
101
101
|
# ------------------------------------------------------------------
|
|
102
102
|
# Stage 1: Backup
|
|
103
103
|
# ------------------------------------------------------------------
|
|
104
|
+
pre_sync_cmd = config.get("pre_sync_command", "").strip()
|
|
105
|
+
post_sync_cmd = config.get("post_sync_command", "").strip()
|
|
104
106
|
updated_uuids: set = set()
|
|
105
107
|
updated_pages: dict = {}
|
|
106
108
|
|
|
@@ -113,22 +115,29 @@ def run_pipeline(
|
|
|
113
115
|
host=host,
|
|
114
116
|
use_wifi=use_wifi,
|
|
115
117
|
wifi_host=wifi_host,
|
|
118
|
+
pre_sync_command=pre_sync_cmd,
|
|
119
|
+
post_sync_command=post_sync_cmd,
|
|
116
120
|
)
|
|
121
|
+
backup_ok = False
|
|
117
122
|
try:
|
|
118
|
-
success, updated_uuids, updated_pages = backup_tool.
|
|
123
|
+
success, updated_uuids, updated_pages = backup_tool.run_backup(
|
|
124
|
+
backup_templates=True,
|
|
125
|
+
)
|
|
119
126
|
if not success:
|
|
120
127
|
print_error(" ERR - Backup failed.")
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
else:
|
|
129
|
+
print_success(f" OK - Backed up ({len(updated_uuids)} notebooks updated)")
|
|
130
|
+
write_manifest(
|
|
131
|
+
backup_dir.parent / "updated_notebooks.txt",
|
|
132
|
+
sorted(updated_uuids),
|
|
133
|
+
"updated_notebooks",
|
|
134
|
+
)
|
|
135
|
+
backup_ok = True
|
|
129
136
|
except Exception as exc: # noqa: BLE001
|
|
130
137
|
logging.error("Backup error: %s", exc)
|
|
131
138
|
print_error(f" ERR - Backup failed: {exc}")
|
|
139
|
+
|
|
140
|
+
if not backup_ok:
|
|
132
141
|
return 1
|
|
133
142
|
else:
|
|
134
143
|
print("\n[1/3] Backup skipped (--skip-backup)")
|
|
@@ -211,8 +220,7 @@ def run_pipeline(
|
|
|
211
220
|
# Discover notebooks and their folder paths
|
|
212
221
|
all_items = find_notebooks(backup_dir)
|
|
213
222
|
if not all_items:
|
|
214
|
-
print(" No notebooks found in backup directory.")
|
|
215
|
-
return 0
|
|
223
|
+
print(" No notebooks found in backup directory. Continuing to post-sync step.")
|
|
216
224
|
|
|
217
225
|
org = organize_notebooks_by_structure(all_items, backup_dir)
|
|
218
226
|
notebooks = org["documents_to_convert"]
|
|
@@ -281,4 +289,5 @@ def run_pipeline(
|
|
|
281
289
|
print(f" Markdown : {exported} exported, {skipped} unchanged -> {output_dir.absolute()}")
|
|
282
290
|
print(f" Duration : {mins}m {secs}s")
|
|
283
291
|
print("=" * 70)
|
|
292
|
+
|
|
284
293
|
return 0
|
|
@@ -41,10 +41,14 @@ def run_sync_command(
|
|
|
41
41
|
"""
|
|
42
42
|
import time as _time
|
|
43
43
|
|
|
44
|
+
from ..config import load_config
|
|
45
|
+
|
|
44
46
|
log_dir = backup_dir.parent
|
|
45
47
|
setup_logging(log_level, log_dir=log_dir)
|
|
46
48
|
_start_time = _time.monotonic()
|
|
47
49
|
|
|
50
|
+
config = load_config()
|
|
51
|
+
|
|
48
52
|
print("ReMarkable Sync (Backup + Convert)")
|
|
49
53
|
print("=" * 70)
|
|
50
54
|
print(f"Backup directory: {backup_dir.absolute()}")
|
|
@@ -60,12 +64,17 @@ def run_sync_command(
|
|
|
60
64
|
if force_convert:
|
|
61
65
|
print("Force convert: All notebooks will be converted")
|
|
62
66
|
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Backup + convert
|
|
69
|
+
# ------------------------------------------------------------------
|
|
63
70
|
backup_tool = ReMarkableBackup(
|
|
64
71
|
backup_dir,
|
|
65
72
|
password=password,
|
|
66
73
|
host=host,
|
|
67
74
|
use_wifi=use_wifi,
|
|
68
75
|
wifi_host=wifi_host,
|
|
76
|
+
pre_sync_command=config.get("pre_sync_command", "").strip(),
|
|
77
|
+
post_sync_command=config.get("post_sync_command", "").strip(),
|
|
69
78
|
)
|
|
70
79
|
|
|
71
80
|
try:
|
|
@@ -87,14 +96,15 @@ def run_sync_command(
|
|
|
87
96
|
print(f" Backup : {backup_tool.files_dir}")
|
|
88
97
|
if not skip_templates:
|
|
89
98
|
print(f" Templates : {backup_tool.templates_dir}")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
_cfg = load_config()
|
|
93
|
-
_pdf_dir = _cfg.get("pdf_dir", "")
|
|
99
|
+
_pdf_dir = config.get("pdf_dir", "")
|
|
94
100
|
if _pdf_dir and Path(_pdf_dir).exists():
|
|
95
101
|
print(f" PDFs : {_pdf_dir}")
|
|
96
102
|
print(f" Duration : {mins}m {secs}s")
|
|
97
103
|
print("=" * 70)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Post-sync command
|
|
107
|
+
# ------------------------------------------------------------------
|
|
98
108
|
return 0
|
|
99
109
|
else:
|
|
100
110
|
print("\n[ERROR] Sync failed. Check logs for details.")
|
|
@@ -513,7 +513,7 @@ class MarkdownExporter:
|
|
|
513
513
|
nb_pages = nb_page_counts[i]
|
|
514
514
|
progress.update(
|
|
515
515
|
task,
|
|
516
|
-
description=f"{nb_name} (page
|
|
516
|
+
description=f"{nb_name} (page 1 of {nb_pages})",
|
|
517
517
|
)
|
|
518
518
|
|
|
519
519
|
safe = sanitize_name(notebook["name"]) or f"notebook_{notebook['uuid'][:8]}"
|
|
@@ -169,7 +169,7 @@ def run_conversion(
|
|
|
169
169
|
description=f"{_nb} (page {_pc[0] + 1} of {_nbt})",
|
|
170
170
|
)
|
|
171
171
|
|
|
172
|
-
progress.update(task, description=f"{nb_name} (page
|
|
172
|
+
progress.update(task, description=f"{nb_name} (page 1 of {nb_total})")
|
|
173
173
|
|
|
174
174
|
try:
|
|
175
175
|
notebook_changed_pages = None
|