remarkablesync 2.0.1__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.
Files changed (75) hide show
  1. {remarkablesync-2.0.1/remarkablesync.egg-info → remarkablesync-2.0.3}/PKG-INFO +1 -1
  2. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/RemarkableSync.py +7 -2
  3. {remarkablesync-2.0.1 → remarkablesync-2.0.3/remarkablesync.egg-info}/PKG-INFO +1 -1
  4. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/remarkablesync.egg-info/SOURCES.txt +3 -0
  5. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/__version__.py +1 -1
  6. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/backup/backup_manager.py +37 -78
  7. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/backup/connection.py +55 -15
  8. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/backup_command.py +2 -1
  9. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/config_command.py +92 -42
  10. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/pipeline.py +41 -26
  11. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/sync_command.py +16 -5
  12. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/watch_command.py +1 -1
  13. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/config.py +13 -1
  14. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/hybrid_converter.py +47 -27
  15. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/pdf_md_converter.py +19 -89
  16. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/rm_pdf_converter.py +25 -29
  17. remarkablesync-2.0.3/src/utils/__init__.py +62 -0
  18. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/utils/console.py +5 -5
  19. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/utils/logging.py +6 -1
  20. remarkablesync-2.0.3/src/utils/name_registry.py +117 -0
  21. remarkablesync-2.0.3/tests/test_agent_issues.py +362 -0
  22. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_config.py +4 -2
  23. remarkablesync-2.0.3/tests/test_config_command.py +114 -0
  24. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_hybrid_converter.py +2 -2
  25. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_md_exporter.py +16 -14
  26. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_pipeline.py +6 -6
  27. remarkablesync-2.0.1/src/utils/__init__.py +0 -1
  28. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/LICENSE +0 -0
  29. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/MANIFEST.in +0 -0
  30. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/README.md +0 -0
  31. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/pyproject.toml +0 -0
  32. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/remarkablesync.egg-info/dependency_links.txt +0 -0
  33. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/remarkablesync.egg-info/entry_points.txt +0 -0
  34. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/remarkablesync.egg-info/requires.txt +0 -0
  35. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/remarkablesync.egg-info/top_level.txt +0 -0
  36. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/requirements-dev.txt +0 -0
  37. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/requirements.txt +0 -0
  38. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/setup.cfg +0 -0
  39. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/setup.py +0 -0
  40. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/__init__.py +0 -0
  41. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ai/__init__.py +0 -0
  42. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ai/base_provider.py +0 -0
  43. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ai/claude_provider.py +0 -0
  44. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ai/github_models_provider.py +0 -0
  45. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/auth/__init__.py +0 -0
  46. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/auth/github_device_flow.py +0 -0
  47. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/backup/__init__.py +0 -0
  48. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/backup/metadata.py +0 -0
  49. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/__init__.py +0 -0
  50. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/commands/convert_command.py +0 -0
  51. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/converters/__init__.py +0 -0
  52. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/converters/base_converter.py +0 -0
  53. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/converters/v4_converter.py +0 -0
  54. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/converters/v5_converter.py +0 -0
  55. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/converters/v6_converter.py +0 -0
  56. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/keyring_store.py +0 -0
  57. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/md_export/__init__.py +0 -0
  58. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ocr/__init__.py +0 -0
  59. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/ocr/ocr_engine.py +0 -0
  60. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/src/template_renderer.py +0 -0
  61. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/__init__.py +0 -0
  62. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/conftest.py +0 -0
  63. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/generate_test_assets.py +0 -0
  64. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/mock_ai_provider.py +0 -0
  65. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/mock_connection.py +0 -0
  66. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_ai_providers.py +0 -0
  67. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_auth_device_flow.py +0 -0
  68. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_basic.py +0 -0
  69. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_console.py +0 -0
  70. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_keyring_store.py +0 -0
  71. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_logging.py +0 -0
  72. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_metadata.py +0 -0
  73. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_ocr_pipeline.py +0 -0
  74. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_template_renderer.py +0 -0
  75. {remarkablesync-2.0.1 → remarkablesync-2.0.3}/tests/test_wifi_connection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remarkablesync
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Backup and convert reMarkable tablet notebooks to PDF
5
5
  Home-page: https://github.com/JeffSteinbok/RemarkableSync
6
6
  Author: Jeff Steinbok
@@ -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
-
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remarkablesync
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Backup and convert reMarkable tablet notebooks to PDF
5
5
  Home-page: https://github.com/JeffSteinbok/RemarkableSync
6
6
  Author: Jeff Steinbok
@@ -48,15 +48,18 @@ src/ocr/ocr_engine.py
48
48
  src/utils/__init__.py
49
49
  src/utils/console.py
50
50
  src/utils/logging.py
51
+ src/utils/name_registry.py
51
52
  tests/__init__.py
52
53
  tests/conftest.py
53
54
  tests/generate_test_assets.py
54
55
  tests/mock_ai_provider.py
55
56
  tests/mock_connection.py
57
+ tests/test_agent_issues.py
56
58
  tests/test_ai_providers.py
57
59
  tests/test_auth_device_flow.py
58
60
  tests/test_basic.py
59
61
  tests/test_config.py
62
+ tests/test_config_command.py
60
63
  tests/test_console.py
61
64
  tests/test_hybrid_converter.py
62
65
  tests/test_keyring_store.py
@@ -1,4 +1,4 @@
1
1
  """Version information for RemarkableSync."""
2
2
 
3
- __version__ = "2.0.1"
3
+ __version__ = "2.0.3"
4
4
  __repository__ = "https://github.com/JeffSteinbok/RemarkableSync"
@@ -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" # Clean folder name
63
- self.templates_dir = backup_dir / "Templates" # Clean folder name
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 backup_files(
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
@@ -298,7 +292,7 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
298
292
  progress.update(task, advance=1, description=local_path.name[:40])
299
293
 
300
294
  except (OSError, SCPException) as e:
301
- print_error(f" [ERR] Failed to download {remote_file['path']}: {e}")
295
+ print_error(f" ERR - Failed to download {remote_file['path']}: {e}")
302
296
  progress.update(task, advance=1)
303
297
 
304
298
  # Save metadata
@@ -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
- finally:
320
- self.connection.disconnect()
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)
@@ -373,7 +354,7 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
373
354
  self.metadata.update_file_metadata(remote_file, local_path)
374
355
 
375
356
  except (OSError, SCPException) as e:
376
- print_error(f" [ERR] Failed to download {remote_file['path']}: {e}")
357
+ print_error(f" ERR - Failed to download {remote_file['path']}: {e}")
377
358
 
378
359
  progress.update(task, advance=1, description=local_path.name[:40])
379
360
 
@@ -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
- bool: True if backup successful, False otherwise
455
+ Tuple of (success, updated_notebook_uuids, updated_pages)
478
456
  """
479
457
  logging.info("Starting ReMarkable backup process")
480
458
 
481
- # Backup files and get list of updated notebooks
482
- success, updated_notebook_uuids, updated_pages = self.backup_files()
483
- if not success:
484
- return False
459
+ if not self.connection.connect():
460
+ return False, set(), {}
485
461
 
486
- # Backup templates if requested
487
- if backup_templates:
488
- templates_success = self.backup_templates()
489
- if not templates_success:
490
- logging.warning("Template backup failed, but continuing with main backup")
462
+ updated_notebook_uuids: Set[str] = set()
463
+ updated_pages: Dict[str, Set[str]] = {}
464
+
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
- return self.run_pdf_conversion(updated_notebook_uuids, force_convert_all, updated_pages)
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,
@@ -527,34 +512,15 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
527
512
  output_dir = self.backup_dir / "PDF"
528
513
  logging.warning("No pdf_dir configured, falling back to %s", output_dir)
529
514
 
530
- # Determine conversion strategy
531
- updated_only_file = None
532
- if force_convert_all:
533
- logging.info("Force conversion enabled - converting all notebooks to PDF")
534
- elif updated_notebook_uuids:
535
- # Create a temporary file list of updated notebooks for selective conversion
536
- updated_list_file = self.backup_dir / "updated_notebooks.txt"
537
- try:
538
- with open(updated_list_file, "w", encoding="utf-8") as f:
539
- for uuid in sorted(updated_notebook_uuids):
540
- f.write(f"{uuid}\n")
541
-
542
- updated_only_file = updated_list_file
543
- logging.info("Converting %d updated notebooks to PDF", len(updated_notebook_uuids))
544
- except OSError as e:
545
- logging.error("Failed to create updated notebooks list: %s", e)
546
- return False
547
- else:
515
+ if not updated_notebook_uuids and not force_convert_all:
548
516
  logging.info("No notebooks were updated - skipping PDF conversion")
549
517
  return True
550
518
 
551
- # Load folder filter from config
552
519
  from ..config import load_config
553
520
 
554
521
  config = load_config()
555
522
  folder_filter = config.get("folders", []) or None
556
523
 
557
- # Run conversion
558
524
  try:
559
525
  success, _converted = run_conversion(
560
526
  backup_dir=self.backup_dir,
@@ -562,18 +528,11 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
562
528
  verbose="INF",
563
529
  sample=None,
564
530
  notebook_filter=None,
565
- updated_only=updated_only_file,
531
+ updated_uuids=updated_notebook_uuids if not force_convert_all else None,
566
532
  updated_pages=updated_pages,
567
533
  folder_filter=folder_filter,
568
534
  )
569
535
 
570
- # Clean up temporary file if created
571
- if updated_only_file and updated_only_file.exists():
572
- try:
573
- updated_only_file.unlink()
574
- except OSError:
575
- pass # Ignore cleanup errors
576
-
577
536
  if success:
578
537
  logging.info("PDF conversion completed successfully")
579
538
  else:
@@ -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
- Attempts multiple connection strategies with different timeout values
192
- to handle various network conditions and tablet responsiveness.
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
- print("\nSaved password appears to be incorrect.")
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
- # User-entered password was wrong
267
- print("\nAuthentication failed. Please check your password.")
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.warning("Connection attempt %d failed: %s", i + 1, e)
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.error("All connection attempts failed")
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.error("Failed to connect to ReMarkable: %s", e)
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
- print("\nMaximum password retry attempts reached.")
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
 
@@ -34,7 +34,8 @@ def run_backup_command(
34
34
  Returns:
35
35
  Exit code (0 for success, 1 for failure)
36
36
  """
37
- setup_logging(log_level, log_dir=backup_dir)
37
+ log_dir = backup_dir.parent
38
+ setup_logging(log_level, log_dir=log_dir)
38
39
 
39
40
  print("ReMarkable Tablet Backup")
40
41
  print("=" * 70)