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.
- {remarkablesync-2.0.1/remarkablesync.egg-info → remarkablesync-2.0.2}/PKG-INFO +1 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2/remarkablesync.egg-info}/PKG-INFO +1 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/SOURCES.txt +1 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/__version__.py +1 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/backup_manager.py +4 -30
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/backup_command.py +2 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/config_command.py +34 -24
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/pipeline.py +27 -21
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/sync_command.py +2 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/watch_command.py +1 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/config.py +11 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/hybrid_converter.py +47 -27
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/pdf_md_converter.py +18 -88
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/rm_pdf_converter.py +24 -28
- remarkablesync-2.0.2/src/utils/__init__.py +33 -0
- remarkablesync-2.0.2/src/utils/name_registry.py +117 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_config.py +2 -2
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_hybrid_converter.py +2 -2
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_md_exporter.py +16 -14
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_pipeline.py +4 -4
- remarkablesync-2.0.1/src/utils/__init__.py +0 -1
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/LICENSE +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/MANIFEST.in +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/README.md +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/RemarkableSync.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/pyproject.toml +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/dependency_links.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/entry_points.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/requires.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/remarkablesync.egg-info/top_level.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/requirements-dev.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/requirements.txt +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/setup.cfg +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/setup.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/base_provider.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/claude_provider.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ai/github_models_provider.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/auth/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/auth/github_device_flow.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/connection.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/backup/metadata.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/commands/convert_command.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/base_converter.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v4_converter.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v5_converter.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/converters/v6_converter.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/keyring_store.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/md_export/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ocr/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/ocr/ocr_engine.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/template_renderer.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/utils/console.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/src/utils/logging.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/__init__.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/conftest.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/generate_test_assets.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/mock_ai_provider.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/mock_connection.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_ai_providers.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_auth_device_flow.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_basic.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_console.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_keyring_store.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_logging.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_metadata.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_ocr_pipeline.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_template_renderer.py +0 -0
- {remarkablesync-2.0.1 → remarkablesync-2.0.2}/tests/test_wifi_connection.py +0 -0
|
@@ -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"
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
181
|
+
chosen = inquirer.select(
|
|
174
182
|
message="What to do on sync:",
|
|
175
|
-
choices=
|
|
176
|
-
|
|
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
|
|
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: {'
|
|
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("
|
|
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("
|
|
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"
|
|
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("
|
|
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"
|
|
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("
|
|
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("
|
|
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"
|
|
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
|
-
|
|
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("
|
|
120
|
+
print_error(" ERR - Backup failed.")
|
|
119
121
|
return 1
|
|
120
122
|
backup_tool.backup_templates()
|
|
121
|
-
print_success(f"
|
|
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"
|
|
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
|
-
|
|
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("
|
|
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"
|
|
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"
|
|
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("
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
#
|
|
470
|
-
|
|
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
|
|
480
|
-
for
|
|
481
|
-
|
|
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
|
|
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"
|
|
683
|
+
f"OK - {notebook['name']}: Merged {len(page_pdfs)} pages into {final_pdf.name}"
|
|
664
684
|
)
|
|
665
685
|
else:
|
|
666
686
|
logging.warning(
|