remarkablesync 2.0.1__tar.gz → 2.0.2__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 (73) hide show
  1. {remarkablesync-2.0.1/remarkablesync.egg-info → remarkablesync-2.0.2}/PKG-INFO +1 -1
  2. {remarkablesync-2.0.1 → remarkablesync-2.0.2/remarkablesync.egg-info}/PKG-INFO +1 -1
  3. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/SOURCES.txt +1 -0
  4. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/__version__.py +1 -1
  5. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/backup_manager.py +4 -30
  6. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/backup_command.py +2 -1
  7. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/config_command.py +34 -24
  8. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/pipeline.py +27 -21
  9. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/sync_command.py +2 -1
  10. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/watch_command.py +1 -1
  11. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/config.py +11 -1
  12. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/hybrid_converter.py +47 -27
  13. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/pdf_md_converter.py +18 -88
  14. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/rm_pdf_converter.py +24 -28
  15. remarkablesync-2.0.2/src/utils/__init__.py +33 -0
  16. remarkablesync-2.0.2/src/utils/name_registry.py +117 -0
  17. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_config.py +2 -2
  18. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_hybrid_converter.py +2 -2
  19. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_md_exporter.py +16 -14
  20. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_pipeline.py +4 -4
  21. remarkablesync-2.0.1/src/utils/__init__.py +0 -1
  22. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/LICENSE +0 -0
  23. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/MANIFEST.in +0 -0
  24. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/README.md +0 -0
  25. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/RemarkableSync.py +0 -0
  26. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/pyproject.toml +0 -0
  27. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/dependency_links.txt +0 -0
  28. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/entry_points.txt +0 -0
  29. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/requires.txt +0 -0
  30. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/top_level.txt +0 -0
  31. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/requirements-dev.txt +0 -0
  32. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/requirements.txt +0 -0
  33. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/setup.cfg +0 -0
  34. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/setup.py +0 -0
  35. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/__init__.py +0 -0
  36. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/__init__.py +0 -0
  37. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/base_provider.py +0 -0
  38. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/claude_provider.py +0 -0
  39. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/github_models_provider.py +0 -0
  40. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/auth/__init__.py +0 -0
  41. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/auth/github_device_flow.py +0 -0
  42. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/__init__.py +0 -0
  43. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/connection.py +0 -0
  44. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/metadata.py +0 -0
  45. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/__init__.py +0 -0
  46. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/convert_command.py +0 -0
  47. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/__init__.py +0 -0
  48. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/base_converter.py +0 -0
  49. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v4_converter.py +0 -0
  50. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v5_converter.py +0 -0
  51. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v6_converter.py +0 -0
  52. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/keyring_store.py +0 -0
  53. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/md_export/__init__.py +0 -0
  54. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ocr/__init__.py +0 -0
  55. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ocr/ocr_engine.py +0 -0
  56. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/template_renderer.py +0 -0
  57. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/utils/console.py +0 -0
  58. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/utils/logging.py +0 -0
  59. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/__init__.py +0 -0
  60. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/conftest.py +0 -0
  61. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/generate_test_assets.py +0 -0
  62. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/mock_ai_provider.py +0 -0
  63. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/mock_connection.py +0 -0
  64. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_ai_providers.py +0 -0
  65. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_auth_device_flow.py +0 -0
  66. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_basic.py +0 -0
  67. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_console.py +0 -0
  68. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_keyring_store.py +0 -0
  69. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_logging.py +0 -0
  70. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_metadata.py +0 -0
  71. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_ocr_pipeline.py +0 -0
  72. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_template_renderer.py +0 -0
  73. {remarkablesync-2.0.1 → remarkablesync-2.0.2}/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.2
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remarkablesync
3
- Version: 2.0.1
3
+ Version: 2.0.2
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,6 +48,7 @@ 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
@@ -1,4 +1,4 @@
1
1
  """Version information for RemarkableSync."""
2
2
 
3
- __version__ = "2.0.1"
3
+ __version__ = "2.0.2"
4
4
  __repository__ = "https://github.com/JeffSteinbok/RemarkableSync"
@@ -298,7 +298,7 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
298
298
  progress.update(task, advance=1, description=local_path.name[:40])
299
299
 
300
300
  except (OSError, SCPException) as e:
301
- print_error(f" [ERR] Failed to download {remote_file['path']}: {e}")
301
+ print_error(f" ERR - Failed to download {remote_file['path']}: {e}")
302
302
  progress.update(task, advance=1)
303
303
 
304
304
  # Save metadata
@@ -373,7 +373,7 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
373
373
  self.metadata.update_file_metadata(remote_file, local_path)
374
374
 
375
375
  except (OSError, SCPException) as e:
376
- print_error(f" [ERR] Failed to download {remote_file['path']}: {e}")
376
+ print_error(f" ERR - Failed to download {remote_file['path']}: {e}")
377
377
 
378
378
  progress.update(task, advance=1, description=local_path.name[:40])
379
379
 
@@ -527,34 +527,15 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
527
527
  output_dir = self.backup_dir / "PDF"
528
528
  logging.warning("No pdf_dir configured, falling back to %s", output_dir)
529
529
 
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:
530
+ if not updated_notebook_uuids and not force_convert_all:
548
531
  logging.info("No notebooks were updated - skipping PDF conversion")
549
532
  return True
550
533
 
551
- # Load folder filter from config
552
534
  from ..config import load_config
553
535
 
554
536
  config = load_config()
555
537
  folder_filter = config.get("folders", []) or None
556
538
 
557
- # Run conversion
558
539
  try:
559
540
  success, _converted = run_conversion(
560
541
  backup_dir=self.backup_dir,
@@ -562,18 +543,11 @@ class ReMarkableBackup: # pylint: disable=too-many-instance-attributes
562
543
  verbose="INF",
563
544
  sample=None,
564
545
  notebook_filter=None,
565
- updated_only=updated_only_file,
546
+ updated_uuids=updated_notebook_uuids if not force_convert_all else None,
566
547
  updated_pages=updated_pages,
567
548
  folder_filter=folder_filter,
568
549
  )
569
550
 
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
551
  if success:
578
552
  logging.info("PDF conversion completed successfully")
579
553
  else:
@@ -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)
@@ -102,13 +102,11 @@ def run_config_command() -> int:
102
102
  click.echo(" 5. Re-run this wizard with the IP ready")
103
103
  return 1
104
104
 
105
- # Let user confirm/change the IP (pre-filled from device or config)
106
- default_ip = wifi_host or current.get("wifi_host", "") or "192.168.1."
105
+ # Let user confirm/change the IP (pre-filled from device or config; blank if unknown)
106
+ default_ip = wifi_host or current.get("wifi_host", "") or ""
107
107
  wifi_host = inquirer.text(
108
108
  message="Tablet WiFi IP address:",
109
109
  default=default_ip,
110
- validate=lambda x: len(x.strip()) > 0,
111
- invalid_message="IP address cannot be empty.",
112
110
  ).execute()
113
111
 
114
112
  if wifi_host is None:
@@ -163,24 +161,36 @@ def run_config_command() -> int:
163
161
  backup_dir = _default_backup_dir()
164
162
  click.echo(f" Using default: {backup_dir}")
165
163
 
166
- # 5. Sync actions
167
- current_actions = current.get("sync_actions", ["backup", "pdf"])
168
- action_choices = [
169
- {"name": display, "value": value, "enabled": value in current_actions}
170
- for value, display in SYNC_ACTIONS
171
- ]
164
+ # 5. Sync actions — later steps imply earlier ones (backup → pdf → ocr)
165
+ action_order = [value for value, _ in SYNC_ACTIONS]
166
+ current_actions = current.get("sync_actions", ["backup", "pdf", "ocr"])
167
+ if not current_actions:
168
+ current_actions = action_order
169
+
170
+ # Build cascade choices: each option enables all steps up to and including it
171
+ cascade_labels = {
172
+ "backup": "Backup only",
173
+ "pdf": "Backup + PDF Conversion",
174
+ "ocr": "Backup + PDF Conversion + AI OCR & Markdown Export",
175
+ }
176
+ highest_current = max(
177
+ (action_order.index(a) for a in current_actions if a in action_order), default=0
178
+ )
179
+ default_action = action_order[highest_current]
172
180
 
173
- sync_actions = inquirer.checkbox(
181
+ chosen = inquirer.select(
174
182
  message="What to do on sync:",
175
- choices=action_choices,
176
- validate=lambda result: len(result) >= 1,
177
- invalid_message="Select at least one sync action.",
183
+ choices=[{"name": cascade_labels[value], "value": value} for value, _ in SYNC_ACTIONS],
184
+ default=default_action,
178
185
  ).execute()
179
186
 
180
- if sync_actions is None:
187
+ if chosen is None:
181
188
  click.echo("Configuration cancelled.")
182
189
  return 0
183
190
 
191
+ # Cascade: all steps up to and including the chosen step
192
+ sync_actions = action_order[: action_order.index(chosen) + 1]
193
+
184
194
  # 6. PDF output directory (if PDF or OCR selected)
185
195
  from src.config import _default_documents_dir
186
196
 
@@ -412,7 +422,7 @@ def run_config_command() -> int:
412
422
  click.echo(f" Images: {'yes (_images/ folder)' if embed_images else 'no'}")
413
423
  click.echo(f" AI: {ai_provider} ({ai_model})")
414
424
  has_token = bool(github_token or claude_api_key)
415
- click.echo(f" Token: {'[OK] saved in keyring' if has_token else '(not set)'}")
425
+ click.echo(f" Token: {'OK - saved in keyring' if has_token else '(not set)'}")
416
426
  click.echo()
417
427
 
418
428
  return 0
@@ -456,14 +466,14 @@ def _enable_wifi_ssh(password: str) -> str:
456
466
  try:
457
467
  from src.backup.connection import USB_HOST, ReMarkableConnection
458
468
  except ImportError:
459
- click.echo(" [WARN] Could not import connection module.")
469
+ click.echo(" WRN - Could not import connection module.")
460
470
  return ""
461
471
 
462
472
  conn = ReMarkableConnection(password=password, host=USB_HOST)
463
473
  click.echo(" Connecting via USB...")
464
474
 
465
475
  if not conn.connect():
466
- click.echo(" [WARN] Could not connect via USB. Is the tablet plugged in?")
476
+ click.echo(" WRN - Could not connect via USB. Is the tablet plugged in?")
467
477
  return ""
468
478
 
469
479
  try:
@@ -471,7 +481,7 @@ def _enable_wifi_ssh(password: str) -> str:
471
481
  click.echo(" Enabling WiFi SSH...")
472
482
  stdout, stderr, exit_code = conn.execute_command("rm-ssh-over-wlan on")
473
483
  if exit_code != 0:
474
- click.echo(f" [WARN] Command failed: {stderr.strip() or stdout.strip()}")
484
+ click.echo(f" WRN - Command failed: {stderr.strip() or stdout.strip()}")
475
485
  return ""
476
486
 
477
487
  click.echo(" WiFi SSH enabled!")
@@ -487,11 +497,11 @@ def _enable_wifi_ssh(password: str) -> str:
487
497
  click.echo(f" Tablet WiFi IP: {ip}")
488
498
  return ip
489
499
 
490
- click.echo(" [WARN] Could not determine WiFi IP. Is the tablet on WiFi?")
500
+ click.echo(" WRN - Could not determine WiFi IP. Is the tablet on WiFi?")
491
501
  return ""
492
502
 
493
503
  except Exception as exc:
494
- click.echo(f" [WARN] Error enabling WiFi SSH: {exc}")
504
+ click.echo(f" WRN - Error enabling WiFi SSH: {exc}")
495
505
  return ""
496
506
  finally:
497
507
  conn.disconnect()
@@ -522,7 +532,7 @@ def _get_folder_choices_live(
522
532
  )
523
533
 
524
534
  if not conn.connect():
525
- click.echo(" [WARN] Could not connect to tablet.")
535
+ click.echo(" WRN - Could not connect to tablet.")
526
536
  return []
527
537
 
528
538
  try:
@@ -536,7 +546,7 @@ def _get_folder_choices_live(
536
546
  f"done"
537
547
  )
538
548
  if exit_code != 0:
539
- click.echo(" [WARN] Failed to read metadata from tablet.")
549
+ click.echo(" WRN - Failed to read metadata from tablet.")
540
550
  return []
541
551
 
542
552
  # Parse the output — each metadata block starts with FILE: line
@@ -566,7 +576,7 @@ def _get_folder_choices_live(
566
576
 
567
577
  except Exception as exc:
568
578
  logging.debug("Failed to list folders from tablet: %s", exc)
569
- click.echo(f" [WARN] Error reading folders: {exc}")
579
+ click.echo(f" WRN - Error reading folders: {exc}")
570
580
  return []
571
581
  finally:
572
582
  conn.disconnect()
@@ -14,6 +14,7 @@ from typing import Dict, List, Optional
14
14
  from ..backup import ReMarkableBackup
15
15
  from ..backup.connection import USB_HOST
16
16
  from ..rm_pdf_converter import run_conversion
17
+ from ..utils import write_manifest
17
18
  from ..utils.console import print_error, print_success, print_warn
18
19
  from ..utils.logging import setup_logging
19
20
 
@@ -67,7 +68,8 @@ def run_pipeline(
67
68
  """
68
69
  import time as _time
69
70
 
70
- setup_logging(log_level, log_dir=backup_dir)
71
+ log_dir = backup_dir.parent
72
+ setup_logging(log_level, log_dir=log_dir)
71
73
  _start_time = _time.monotonic()
72
74
 
73
75
  from ..config import load_config
@@ -115,13 +117,18 @@ def run_pipeline(
115
117
  try:
116
118
  success, updated_uuids, updated_pages = backup_tool.backup_files()
117
119
  if not success:
118
- print_error(" [ERR] Backup failed.")
120
+ print_error(" ERR - Backup failed.")
119
121
  return 1
120
122
  backup_tool.backup_templates()
121
- print_success(f" [OK] Backed up ({len(updated_uuids)} notebooks updated)")
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
+ )
122
129
  except Exception as exc: # noqa: BLE001
123
130
  logging.error("Backup error: %s", exc)
124
- print_error(f" [ERR] Backup failed: {exc}")
131
+ print_error(f" ERR - Backup failed: {exc}")
125
132
  return 1
126
133
  else:
127
134
  print("\n[1/3] Backup skipped (--skip-backup)")
@@ -133,29 +140,27 @@ def run_pipeline(
133
140
 
134
141
  if not skip_convert:
135
142
  print("\n[2/3] Converting notebooks to PDF...")
136
- updated_list_file: Optional[Path] = None
137
-
138
- if not force_convert and updated_uuids is not None and not skip_backup:
139
- updated_list_file = backup_dir / "updated_notebooks.txt"
140
- try:
141
- updated_list_file.write_text("\n".join(sorted(updated_uuids)), encoding="utf-8")
142
- except OSError as exc:
143
- logging.warning("Could not write updated notebooks list: %s", exc)
144
- updated_list_file = None
145
143
 
146
144
  try:
147
- _ok, converted_pages = run_conversion(
145
+ _ok, converted_pages, merged_pdfs = run_conversion(
148
146
  backup_dir=backup_dir,
149
147
  output_dir=pdf_output_dir,
150
148
  verbose=log_level,
151
- updated_only=updated_list_file,
149
+ updated_uuids=updated_uuids if not force_convert and not skip_backup else None,
152
150
  updated_pages=updated_pages,
153
151
  folder_filter=folder_filter,
154
152
  )
155
- print_success(" [OK] PDF conversion done")
153
+ print_success(" OK - PDF conversion done")
154
+ all_page_pdfs = sorted(p for pages in converted_pages.values() for p in pages)
155
+ write_manifest(
156
+ backup_dir.parent / "updated_pdf_pages.txt", all_page_pdfs, "updated_pdf_pages"
157
+ )
158
+ write_manifest(
159
+ backup_dir.parent / "updated_pdfs.txt", sorted(merged_pdfs), "updated_pdfs"
160
+ )
156
161
  except Exception as exc: # noqa: BLE001
157
162
  logging.error("Conversion error: %s", exc)
158
- print_error(f" [ERR] PDF conversion failed: {exc}")
163
+ print_error(f" ERR - PDF conversion failed: {exc}")
159
164
  return 1
160
165
  else:
161
166
  print("\n[2/3] PDF conversion skipped (--skip-convert)")
@@ -185,13 +190,13 @@ def run_pipeline(
185
190
  print(f" AI OCR provider: {ai_provider} ({provider.model})")
186
191
  else:
187
192
  print_warn(
188
- f" [WRN] AI provider '{ai_provider}' not available "
193
+ f" WRN - AI provider '{ai_provider}' not available "
189
194
  "(missing API key or package). OCR skipped."
190
195
  )
191
196
  except (ValueError, ImportError) as exc:
192
197
  logging.warning("Could not initialise AI provider: %s", exc)
193
198
  elif use_ai_ocr:
194
- print_warn(" [WRN] --use-ai-ocr set but no --ai-provider given. OCR skipped.")
199
+ print_warn(" WRN - --use-ai-ocr set but no --ai-provider given. OCR skipped.")
195
200
 
196
201
  tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["remarkable"]
197
202
 
@@ -241,9 +246,9 @@ def run_pipeline(
241
246
 
242
247
  if not notebooks:
243
248
  print(" No notebooks to export — skipping")
244
- exported, skipped = 0, 0
249
+ exported, skipped, exported_dirs = 0, 0, []
245
250
  else:
246
- exported, skipped = exporter.export_all(
251
+ exported, skipped, exported_dirs = exporter.export_all(
247
252
  notebooks=notebooks,
248
253
  pdf_output_dir=pdf_output_dir,
249
254
  force=force_export,
@@ -251,6 +256,7 @@ def run_pipeline(
251
256
  page_filter=page_filter,
252
257
  updated_pages=updated_pages,
253
258
  )
259
+ write_manifest(backup_dir.parent / "updated_md.txt", sorted(exported_dirs), "updated_md")
254
260
 
255
261
  # ------------------------------------------------------------------
256
262
  # Summary
@@ -41,7 +41,8 @@ def run_sync_command(
41
41
  """
42
42
  import time as _time
43
43
 
44
- setup_logging(log_level, log_dir=backup_dir)
44
+ log_dir = backup_dir.parent
45
+ setup_logging(log_level, log_dir=log_dir)
45
46
  _start_time = _time.monotonic()
46
47
 
47
48
  print("ReMarkable Sync (Backup + Convert)")
@@ -330,7 +330,7 @@ class _WatchTray:
330
330
 
331
331
  def _on_open_log(self, icon, item):
332
332
  if self._backup_dir:
333
- log_file = self._backup_dir / "remarkablesync.log"
333
+ log_file = self._backup_dir.parent / "remarkablesync.log"
334
334
  if log_file.exists():
335
335
  self._open_file(log_file)
336
336
 
@@ -49,7 +49,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
49
49
  "wifi_host": "",
50
50
  "password": "",
51
51
  "folders": [],
52
- "sync_actions": ["pdf"],
52
+ "sync_actions": ["backup", "pdf", "ocr"],
53
53
  "ocr_enabled": False,
54
54
  "ocr_output_dir": "",
55
55
  "output_dir": "",
@@ -83,6 +83,16 @@ def load_config() -> Dict[str, Any]:
83
83
  # Merge with defaults so new keys are always present
84
84
  merged = dict(DEFAULT_CONFIG)
85
85
  merged.update(data)
86
+
87
+ # Cascade-normalize sync_actions: if a later step is present,
88
+ # all earlier steps must be too (e.g. "pdf" implies "backup").
89
+ _action_order = [a for a, _ in SYNC_ACTIONS]
90
+ actions = merged.get("sync_actions", [])
91
+ valid = [a for a in actions if a in _action_order]
92
+ if valid:
93
+ highest = max(_action_order.index(a) for a in valid)
94
+ merged["sync_actions"] = _action_order[: highest + 1]
95
+
86
96
  return merged
87
97
  except (json.JSONDecodeError, OSError):
88
98
  return dict(DEFAULT_CONFIG)
@@ -16,6 +16,7 @@ This module provides:
16
16
  - Detection and reporting for v4/v3 files (limited support)
17
17
  """
18
18
 
19
+ import hashlib
19
20
  import json
20
21
  import logging
21
22
  import shutil
@@ -27,11 +28,23 @@ from typing import Dict, List, Optional
27
28
  # Import modular converter classes
28
29
  from .converters import V4Converter, V5Converter, V6Converter
29
30
  from .template_renderer import TemplateRenderer
31
+ from .utils import sanitize_name
30
32
 
31
33
  # Suppress warnings from third-party libraries to reduce output noise
32
34
  warnings.filterwarnings("ignore")
33
35
 
34
36
 
37
+ def _hash_file(path: Path) -> str:
38
+ """Return MD5 hex-digest of *path*, or empty string if it doesn't exist."""
39
+ if not path.exists():
40
+ return ""
41
+ h = hashlib.md5()
42
+ with open(path, "rb") as fh:
43
+ for chunk in iter(lambda: fh.read(65536), b""):
44
+ h.update(chunk)
45
+ return h.hexdigest()
46
+
47
+
35
48
  def setup_logging(verbose: bool = False):
36
49
  """Configure logging with appropriate levels and formatting.
37
50
 
@@ -291,14 +304,12 @@ def organize_notebooks_by_structure(notebooks: List[Dict], backup_dir: Path) ->
291
304
 
292
305
  for item in notebooks:
293
306
  if item["type"] == "DocumentType":
294
- # This is a notebook to convert
295
307
  hierarchy = get_folder_hierarchy(item, backup_dir)
296
- folder_path = "/".join(hierarchy) if hierarchy else ""
297
-
298
- item["folder_path"] = folder_path
308
+ item["folder_path"] = "/".join(name for name, _ in hierarchy)
309
+ item["folder_hierarchy"] = hierarchy
299
310
  documents_to_convert.append(item)
300
311
 
301
- # Ensure folder exists in structure
312
+ folder_path = item["folder_path"]
302
313
  if folder_path not in folder_structure:
303
314
  folder_structure[folder_path] = []
304
315
  folder_structure[folder_path].append(item)
@@ -306,8 +317,12 @@ def organize_notebooks_by_structure(notebooks: List[Dict], backup_dir: Path) ->
306
317
  return {"folder_structure": folder_structure, "documents_to_convert": documents_to_convert}
307
318
 
308
319
 
309
- def get_folder_hierarchy(notebook: Dict, backup_dir: Path) -> List[str]:
310
- """Get the folder hierarchy for a notebook by following parent UUIDs."""
320
+ def get_folder_hierarchy(notebook: Dict, backup_dir: Path) -> List[tuple]:
321
+ """Get the folder hierarchy for a notebook by following parent UUIDs.
322
+
323
+ Returns a list of ``(raw_name, uuid)`` tuples ordered from root to
324
+ immediate parent, e.g. ``[("1:1", "abc..."), ("L65+", "def...")]``.
325
+ """
311
326
  hierarchy = []
312
327
  current_uuid = notebook.get("parent")
313
328
  files_dir = backup_dir / "Notebooks"
@@ -319,12 +334,8 @@ def get_folder_hierarchy(notebook: Dict, backup_dir: Path) -> List[str]:
319
334
  with open(metadata_file, "r", encoding="utf-8") as f:
320
335
  metadata = json.load(f)
321
336
  folder_name = metadata.get("visibleName", "Unknown")
322
- # Create safe folder name
323
- safe_folder = "".join(
324
- c for c in folder_name if c.isalnum() or c in (" ", "-", "_")
325
- ).strip()
326
- if safe_folder:
327
- hierarchy.insert(0, safe_folder) # Insert at beginning to build path
337
+ if folder_name:
338
+ hierarchy.insert(0, (folder_name, current_uuid))
328
339
  current_uuid = metadata.get("parent")
329
340
  else:
330
341
  break
@@ -447,6 +458,7 @@ def convert_notebook(
447
458
  changed_page_ids: Optional[set] = None,
448
459
  on_page_done: Optional[callable] = None,
449
460
  on_page_start: Optional[callable] = None,
461
+ registry=None,
450
462
  ) -> Dict:
451
463
  """Convert a notebook using appropriate tools for each file type.
452
464
 
@@ -465,20 +477,25 @@ def convert_notebook(
465
477
  When *None* all pages are (re-)converted.
466
478
  on_page_done: Callback ``(cached: bool)`` called after each page.
467
479
  *cached* is True when the page was served from cache.
480
+ registry: Optional :class:`~src.utils.name_registry.NameRegistry`
481
+ for stable, deduplicated output path names.
468
482
  """
469
- # Create safe filename
470
- safe_name = "".join(c for c in notebook["name"] if c.isalnum() or c in (" ", "-", "_")).rstrip()
471
- if not safe_name:
472
- safe_name = f"notebook_{notebook['uuid'][:8]}"
473
-
474
- # Use pre-computed folder path from organization
475
- folder_path = notebook.get("folder_path", "")
476
-
477
- # Create output directory with folder structure
483
+ # Build output directory using registry if available, else plain sanitize
484
+ hierarchy = notebook.get("folder_hierarchy", [])
478
485
  output_notebook_dir = output_dir
479
- if folder_path:
480
- for folder in folder_path.split("/"):
481
- output_notebook_dir = output_notebook_dir / folder
486
+ if registry:
487
+ for i, (folder_name, folder_uuid) in enumerate(hierarchy):
488
+ parent_uuid = hierarchy[i - 1][1] if i > 0 else ""
489
+ output_notebook_dir = output_notebook_dir / registry.get_or_assign(
490
+ folder_uuid, folder_name, parent_uuid
491
+ )
492
+ parent_uuid = hierarchy[-1][1] if hierarchy else ""
493
+ safe_name = registry.get_or_assign(notebook["uuid"], notebook["name"], parent_uuid)
494
+ else:
495
+ for folder_name, _ in hierarchy:
496
+ output_notebook_dir = output_notebook_dir / sanitize_name(folder_name)
497
+ safe_name = sanitize_name(notebook["name"]) or f"notebook_{notebook['uuid'][:8]}"
498
+
482
499
  output_notebook_dir.mkdir(parents=True, exist_ok=True)
483
500
 
484
501
  # Persistent page PDF cache directory
@@ -487,7 +504,7 @@ def convert_notebook(
487
504
 
488
505
  results = {
489
506
  "name": notebook["name"],
490
- "folder_path": str(output_notebook_dir.relative_to(output_dir)) if folder_path else "",
507
+ "folder_path": str(output_notebook_dir.relative_to(output_dir)) if hierarchy else "",
491
508
  "page_cache_dir": page_cache_dir,
492
509
  "v5_converted": 0,
493
510
  "v6_converted": 0,
@@ -657,10 +674,13 @@ def convert_notebook(
657
674
  # Create merged PDF if we have any pages
658
675
  if page_pdfs:
659
676
  final_pdf = output_notebook_dir / f"{safe_name}.pdf"
677
+ pre_merge_hash = _hash_file(final_pdf)
678
+
660
679
  if merge_pdfs(page_pdfs, final_pdf):
661
680
  results["output_files"].append(final_pdf)
681
+ results["pdf_changed"] = _hash_file(final_pdf) != pre_merge_hash
662
682
  logging.info(
663
- f"[OK] {notebook['name']}: Merged {len(page_pdfs)} pages into {final_pdf.name}"
683
+ f"OK - {notebook['name']}: Merged {len(page_pdfs)} pages into {final_pdf.name}"
664
684
  )
665
685
  else:
666
686
  logging.warning(