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.
Files changed (74) hide show
  1. {remarkablesync-2.0.2/remarkablesync.egg-info → remarkablesync-2.0.3}/PKG-INFO +1 -1
  2. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/RemarkableSync.py +7 -2
  3. {remarkablesync-2.0.2 → remarkablesync-2.0.3/remarkablesync.egg-info}/PKG-INFO +1 -1
  4. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/SOURCES.txt +2 -0
  5. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/__version__.py +1 -1
  6. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/backup_manager.py +33 -48
  7. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/connection.py +55 -15
  8. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/config_command.py +61 -21
  9. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/pipeline.py +20 -11
  10. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/sync_command.py +14 -4
  11. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/config.py +2 -0
  12. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/pdf_md_converter.py +1 -1
  13. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/rm_pdf_converter.py +1 -1
  14. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/__init__.py +29 -0
  15. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/console.py +5 -5
  16. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/logging.py +6 -1
  17. remarkablesync-2.0.3/tests/test_agent_issues.py +362 -0
  18. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_config.py +2 -0
  19. remarkablesync-2.0.3/tests/test_config_command.py +114 -0
  20. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_pipeline.py +2 -2
  21. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/LICENSE +0 -0
  22. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/MANIFEST.in +0 -0
  23. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/README.md +0 -0
  24. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/pyproject.toml +0 -0
  25. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/dependency_links.txt +0 -0
  26. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/entry_points.txt +0 -0
  27. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/requires.txt +0 -0
  28. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/remarkablesync.egg-info/top_level.txt +0 -0
  29. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/requirements-dev.txt +0 -0
  30. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/requirements.txt +0 -0
  31. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/setup.cfg +0 -0
  32. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/setup.py +0 -0
  33. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/__init__.py +0 -0
  34. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/__init__.py +0 -0
  35. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/base_provider.py +0 -0
  36. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/claude_provider.py +0 -0
  37. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ai/github_models_provider.py +0 -0
  38. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/auth/__init__.py +0 -0
  39. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/auth/github_device_flow.py +0 -0
  40. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/__init__.py +0 -0
  41. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/backup/metadata.py +0 -0
  42. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/__init__.py +0 -0
  43. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/backup_command.py +0 -0
  44. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/convert_command.py +0 -0
  45. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/commands/watch_command.py +0 -0
  46. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/__init__.py +0 -0
  47. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/base_converter.py +0 -0
  48. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v4_converter.py +0 -0
  49. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v5_converter.py +0 -0
  50. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/converters/v6_converter.py +0 -0
  51. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/hybrid_converter.py +0 -0
  52. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/keyring_store.py +0 -0
  53. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/md_export/__init__.py +0 -0
  54. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ocr/__init__.py +0 -0
  55. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/ocr/ocr_engine.py +0 -0
  56. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/template_renderer.py +0 -0
  57. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/src/utils/name_registry.py +0 -0
  58. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/__init__.py +0 -0
  59. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/conftest.py +0 -0
  60. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/generate_test_assets.py +0 -0
  61. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/mock_ai_provider.py +0 -0
  62. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/mock_connection.py +0 -0
  63. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_ai_providers.py +0 -0
  64. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_auth_device_flow.py +0 -0
  65. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_basic.py +0 -0
  66. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_console.py +0 -0
  67. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_hybrid_converter.py +0 -0
  68. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_keyring_store.py +0 -0
  69. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_logging.py +0 -0
  70. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_md_exporter.py +0 -0
  71. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_metadata.py +0 -0
  72. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_ocr_pipeline.py +0 -0
  73. {remarkablesync-2.0.2 → remarkablesync-2.0.3}/tests/test_template_renderer.py +0 -0
  74. {remarkablesync-2.0.2 → 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.2
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.2
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
@@ -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
@@ -1,4 +1,4 @@
1
1
  """Version information for RemarkableSync."""
2
2
 
3
- __version__ = "2.0.2"
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
@@ -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)
@@ -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(), {}
461
+
462
+ updated_notebook_uuids: Set[str] = set()
463
+ updated_pages: Dict[str, Set[str]] = {}
485
464
 
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")
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,
@@ -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
 
@@ -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. Connect to tablet and select folders
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
- click.echo(" Could not connect to tablet. Folder selection skipped.")
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: {path}")
411
- click.echo(f" Mode: {connection_mode.upper()}")
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: {wifi_host}")
414
- click.echo(f" Password: {'••••••••' if password else '(not set)'}")
415
- click.echo(f" Backup: {backup_dir}")
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: {pdf_dir}")
418
- click.echo(f" Folders: {', '.join(folders) if folders else '(all)'}")
419
- click.echo(f" Actions: {', '.join(sync_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: {output_dir}")
422
- click.echo(f" Images: {'yes (_images/ folder)' if embed_images else 'no'}")
423
- click.echo(f" AI: {ai_provider} ({ai_model})")
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: {'OK - saved in keyring' if has_token else '(not set)'}")
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
- click.echo(" WRN - Could not connect via USB. Is the tablet plugged in?")
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, password: str, wifi_host: 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.backup_files()
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
- return 1
122
- backup_tool.backup_templates()
123
- print_success(f" OK - Backed up ({len(updated_uuids)} notebooks updated)")
124
- write_manifest(
125
- backup_dir.parent / "updated_notebooks.txt",
126
- sorted(updated_uuids),
127
- "updated_notebooks",
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
- from ..config import load_config
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.")
@@ -57,6 +57,8 @@ DEFAULT_CONFIG: Dict[str, Any] = {
57
57
  "pdf_dir": "",
58
58
  "ai_provider": "github",
59
59
  "ai_model": "",
60
+ "pre_sync_command": "",
61
+ "post_sync_command": "",
60
62
  }
61
63
 
62
64
  # All available sync actions
@@ -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 0 of {nb_pages})",
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 0 of {nb_total})")
172
+ progress.update(task, description=f"{nb_name} (page 1 of {nb_total})")
173
173
 
174
174
  try:
175
175
  notebook_changed_pages = None