zotrm 0.1.0__py3-none-any.whl

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.
zotrm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """zotrm — bridge a Zotero collection to a reMarkable Paper Pro and back."""
2
+
3
+ __version__ = "0.1.0"
zotrm/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m zotrm`` (a sturdy fallback for cron jobs)."""
2
+
3
+ from zotrm.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
zotrm/cli.py ADDED
@@ -0,0 +1,244 @@
1
+ """Command-line interface: argument parsing and command dispatch.
2
+
3
+ Subcommands (push / pull / status / sync) and the global --config / --dry-run
4
+ flags mirror the original single-file tool exactly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ import tempfile
12
+ from configparser import ConfigParser
13
+ from pathlib import Path
14
+
15
+ from zotrm.config import DEFAULT_CONFIG, TAG_DONE, TAG_SYNCED, load_config, log
16
+ from zotrm.remarkable import ensure_remote_folder, rmapi
17
+ from zotrm.zotero import (
18
+ connect,
19
+ find_collection_key,
20
+ iter_items,
21
+ local_pdf_path,
22
+ pdf_child,
23
+ tags_of,
24
+ )
25
+
26
+
27
+ def _truthy(value: str) -> bool:
28
+ return value.lower() in ("1", "true", "yes")
29
+
30
+
31
+ def _interactive() -> bool:
32
+ """True when we have a real terminal (so it's safe to prompt)."""
33
+ return sys.stdin.isatty() and sys.stdout.isatty()
34
+
35
+
36
+ def cmd_push(cfg: ConfigParser, dry_run: bool) -> None:
37
+ zot = connect(cfg)
38
+ rm = cfg["remarkable"]
39
+ base = rm.get("folder", "/Papers")
40
+ mirror = _truthy(rm.get("mirror_subcollections", "true"))
41
+ coll_key = find_collection_key(zot, rm["collection"])
42
+
43
+ pushed = 0
44
+ for item, folder in iter_items(zot, coll_key, base, mirror):
45
+ key = item["key"]
46
+ title = item["data"].get("title", key)[:70]
47
+ if TAG_SYNCED in tags_of(item):
48
+ continue
49
+
50
+ child = pdf_child(zot, key)
51
+ if not child:
52
+ log(f" skip (no PDF) {title}")
53
+ continue
54
+ att_key, filename = child
55
+
56
+ if dry_run:
57
+ log(f" would push {title} -> {folder}/{filename}")
58
+ pushed += 1
59
+ continue
60
+
61
+ ensure_remote_folder(folder)
62
+
63
+ # Prefer the locally-synced PDF; fall back to the Zotero API.
64
+ local = local_pdf_path(cfg, att_key, filename)
65
+ tmp = None
66
+ if local:
67
+ src = str(local)
68
+ else:
69
+ tmp = Path(tempfile.gettempdir()) / filename
70
+ tmp.write_bytes(zot.file(att_key))
71
+ src = str(tmp)
72
+
73
+ res = rmapi("put", src, folder, capture=True)
74
+ if res.returncode != 0:
75
+ log(f" FAILED upload {title}\n{res.stderr.strip()}")
76
+ else:
77
+ zot.add_tags(item, TAG_SYNCED)
78
+ log(f" pushed {title} -> {folder}")
79
+ pushed += 1
80
+ if tmp:
81
+ tmp.unlink(missing_ok=True)
82
+
83
+ log(
84
+ f"\npush complete: {pushed} paper(s) "
85
+ f"{'would be ' if dry_run else ''}sent to the reMarkable."
86
+ )
87
+
88
+
89
+ def cmd_pull(cfg: ConfigParser, dry_run: bool) -> None:
90
+ zot = connect(cfg)
91
+ rm = cfg["remarkable"]
92
+ base = rm.get("folder", "/Papers")
93
+ mirror = _truthy(rm.get("mirror_subcollections", "true"))
94
+ out_dir = Path(rm.get("output_dir", str(Path.home() / "zotrm-annotated")))
95
+ reattach = _truthy(rm.get("reattach", "true"))
96
+ out_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ coll_key = find_collection_key(zot, rm["collection"])
99
+
100
+ pulled = 0
101
+ for item, folder in iter_items(zot, coll_key, base, mirror):
102
+ key = item["key"]
103
+ title = item["data"].get("title", key)[:70]
104
+ item_tags = tags_of(item)
105
+ if TAG_SYNCED not in item_tags or TAG_DONE in item_tags:
106
+ continue
107
+
108
+ child = pdf_child(zot, key)
109
+ if not child:
110
+ continue
111
+ _, filename = child
112
+ stem = Path(filename).stem
113
+ remote = f"{folder.rstrip('/')}/{stem}"
114
+ dest = out_dir / f"{stem} (annotated).pdf"
115
+
116
+ if dry_run:
117
+ log(f" would pull {title} -> {dest}")
118
+ pulled += 1
119
+ continue
120
+
121
+ # geta = "get annotated": render scribbles onto the original PDF.
122
+ res = rmapi("geta", remote, str(dest), capture=True)
123
+ if res.returncode != 0 or not dest.exists():
124
+ log(f" no annotations yet / failed {title}")
125
+ continue
126
+
127
+ if reattach:
128
+ try:
129
+ zot.attachment_simple([str(dest)], key)
130
+ except Exception as e:
131
+ log(f" (re-attach skipped: {e}; file saved to {dest})")
132
+
133
+ zot.add_tags(item, TAG_DONE)
134
+ log(f" pulled {title} -> {dest}")
135
+ pulled += 1
136
+
137
+ log(f"\npull complete: {pulled} annotated paper(s) {'would be ' if dry_run else ''}retrieved.")
138
+
139
+
140
+ def cmd_status(cfg: ConfigParser, dry_run: bool) -> None:
141
+ zot = connect(cfg)
142
+ rm = cfg["remarkable"]
143
+ base = rm.get("folder", "/Papers")
144
+ mirror = _truthy(rm.get("mirror_subcollections", "true"))
145
+ coll_key = find_collection_key(zot, rm["collection"])
146
+
147
+ queued: list[str] = []
148
+ on_device: list[str] = []
149
+ done: list[str] = []
150
+ for item, folder in iter_items(zot, coll_key, base, mirror):
151
+ t = tags_of(item)
152
+ title = item["data"].get("title", item["key"])[:60]
153
+ row = f"{title} [{folder}]"
154
+ if TAG_DONE in t:
155
+ done.append(row)
156
+ elif TAG_SYNCED in t:
157
+ on_device.append(row)
158
+ else:
159
+ queued.append(row)
160
+
161
+ def block(name: str, rows: list[str]) -> None:
162
+ log(f"\n{name} ({len(rows)})")
163
+ for r in rows:
164
+ log(f" - {r}")
165
+
166
+ log(f"Collection: {rm['collection']} (mirror={mirror})")
167
+ block("Queued (will push)", queued)
168
+ block("On reMarkable (reading)", on_device)
169
+ block("Annotated (back in Zotero)", done)
170
+
171
+
172
+ def main(argv: list[str] | None = None) -> None:
173
+ p = argparse.ArgumentParser(
174
+ prog="zotrm",
175
+ description="Bridge a Zotero collection to a reMarkable Paper Pro and back.",
176
+ )
177
+ p.add_argument(
178
+ "--config",
179
+ type=Path,
180
+ default=DEFAULT_CONFIG,
181
+ help=f"config file (default: {DEFAULT_CONFIG})",
182
+ )
183
+ p.add_argument("--dry-run", action="store_true", help="show what would happen, change nothing")
184
+ sub = p.add_subparsers(dest="command", required=True)
185
+ sub.add_parser("push", help="send queued papers to the reMarkable")
186
+ sub.add_parser("pull", help="bring annotated papers back into Zotero")
187
+ sub.add_parser("status", help="show the queue / on-device / done lists")
188
+ sub.add_parser("sync", help="pull then push, in one go")
189
+ config_p = sub.add_parser("config", help="create or edit your configuration")
190
+ config_p.add_argument(
191
+ "--show", action="store_true", help="print the current config location and values"
192
+ )
193
+ cron_p = sub.add_parser("cron", help="schedule an automatic sync")
194
+ cron_p.add_argument("--remove", action="store_true", help="remove the scheduled sync")
195
+ cron_p.add_argument("--show", action="store_true", help="show the scheduled sync, if any")
196
+
197
+ args = p.parse_args(argv)
198
+
199
+ if args.command == "config":
200
+ from zotrm.wizard import run_config_wizard, show_config
201
+
202
+ if args.show:
203
+ show_config(args.config)
204
+ else:
205
+ run_config_wizard(args.config)
206
+ return
207
+
208
+ if args.command == "cron":
209
+ from zotrm.cron import remove_cron_job, run_cron_setup, show_cron_job
210
+
211
+ if args.remove:
212
+ log("Removed the scheduled sync." if remove_cron_job() else "No scheduled sync found.")
213
+ elif args.show:
214
+ line = show_cron_job()
215
+ log(line if line else "No scheduled sync found.")
216
+ else:
217
+ run_cron_setup(args.config)
218
+ return
219
+
220
+ # push / pull / status / sync all need a config. On first run, if we have a
221
+ # terminal, launch the wizard; otherwise fall through to a clean error.
222
+ if not args.config.exists() and _interactive():
223
+ log("No config found yet — let's set it up first.\n")
224
+ from zotrm.wizard import run_config_wizard
225
+
226
+ if not run_config_wizard(args.config):
227
+ return
228
+ log("")
229
+
230
+ cfg = load_config(args.config)
231
+
232
+ if args.command == "push":
233
+ cmd_push(cfg, args.dry_run)
234
+ elif args.command == "pull":
235
+ cmd_pull(cfg, args.dry_run)
236
+ elif args.command == "status":
237
+ cmd_status(cfg, args.dry_run)
238
+ else: # sync = pull then push
239
+ cmd_pull(cfg, args.dry_run)
240
+ cmd_push(cfg, args.dry_run)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
zotrm/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Configuration loading, validation, and small shared helpers.
2
+
3
+ State for the whole tool lives in Zotero tags (see the tag constants below),
4
+ so there is no extra database. Configuration is a plain INI file; see the
5
+ README for the template.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import configparser
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import NoReturn
14
+
15
+ # Tags used to track per-item state, entirely inside Zotero.
16
+ TAG_SYNCED = "rm:synced" # pushed to the device
17
+ TAG_DONE = "rm:annotated" # annotated copy pulled back
18
+
19
+ DEFAULT_CONFIG = Path.home() / ".config" / "zotrm" / "config.ini"
20
+
21
+
22
+ def die(msg: str, code: int = 1) -> NoReturn:
23
+ """Print an error to stderr and exit with a non-zero status."""
24
+ print(f"error: {msg}", file=sys.stderr)
25
+ sys.exit(code)
26
+
27
+
28
+ def log(msg: str) -> None:
29
+ """Print a line of progress output, flushing so cron logs stay live."""
30
+ print(msg, flush=True)
31
+
32
+
33
+ def load_config(path: Path) -> configparser.ConfigParser:
34
+ """Load and minimally validate the INI config, or exit with an error."""
35
+ if not path.exists():
36
+ die(f"config not found at {path}\n create it (see the README for a template).")
37
+ cfg = configparser.ConfigParser()
38
+ cfg.read(path)
39
+ for section in ("zotero", "remarkable"):
40
+ if section not in cfg:
41
+ die(f"[{section}] section missing from {path}")
42
+ return cfg
zotrm/cron.py ADDED
@@ -0,0 +1,176 @@
1
+ """Friendly cron-job setup for automatic sync (the ``zotrm cron`` command).
2
+
3
+ The interactive parts use questionary; the schedule/cron-line building and the
4
+ crontab merge logic are kept as plain functions so they are easy to test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import questionary
17
+
18
+ from zotrm.config import DEFAULT_CONFIG, die, log
19
+
20
+ MARKER = "# zotrm-sync"
21
+
22
+ # Friendly label -> fixed cron expression (None means "needs follow-up questions").
23
+ PRESETS: dict[str, str | None] = {
24
+ "Every hour": "0 * * * *",
25
+ "Every 6 hours": "0 */6 * * *",
26
+ "Once a day": None,
27
+ "Once a week": None,
28
+ "Advanced (cron expression)": None,
29
+ }
30
+
31
+ _WEEKDAYS = [
32
+ ("Monday", 1),
33
+ ("Tuesday", 2),
34
+ ("Wednesday", 3),
35
+ ("Thursday", 4),
36
+ ("Friday", 5),
37
+ ("Saturday", 6),
38
+ ("Sunday", 0),
39
+ ]
40
+
41
+ _TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
42
+ _CRON_RE = re.compile(r"^\S+(\s+\S+){4}$")
43
+
44
+
45
+ def build_schedule(
46
+ preset: str,
47
+ *,
48
+ time: str | None = None,
49
+ weekday: int | None = None,
50
+ custom: str | None = None,
51
+ ) -> str:
52
+ """Turn a friendly choice into a 5-field cron expression."""
53
+ fixed = PRESETS.get(preset)
54
+ if fixed is not None:
55
+ return fixed
56
+ if preset == "Advanced (cron expression)":
57
+ if not custom or not _CRON_RE.match(custom.strip()):
58
+ die(f"invalid cron expression: {custom!r}")
59
+ return custom.strip()
60
+ if time is None or not _TIME_RE.match(time):
61
+ die(f"invalid time (expected HH:MM): {time!r}")
62
+ hour, minute = (int(part) for part in time.split(":"))
63
+ if preset == "Once a day":
64
+ return f"{minute} {hour} * * *"
65
+ if preset == "Once a week":
66
+ return f"{minute} {hour} * * {weekday}"
67
+ die(f"unknown schedule: {preset!r}")
68
+
69
+
70
+ def zotrm_command(config_path: Path) -> str:
71
+ """Build the absolute command cron should run."""
72
+ exe = shutil.which("zotrm")
73
+ parts = [exe] if exe else [sys.executable, "-m", "zotrm"]
74
+ if config_path != DEFAULT_CONFIG:
75
+ parts += ["--config", str(config_path)]
76
+ parts.append("sync")
77
+ return " ".join(parts)
78
+
79
+
80
+ def build_cron_line(schedule: str, command: str, logfile: str) -> str:
81
+ return f"{schedule} {command} >> {logfile} 2>&1 {MARKER}"
82
+
83
+
84
+ def _crontab(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
85
+ try:
86
+ return subprocess.run(
87
+ ["crontab", *args],
88
+ input=stdin,
89
+ text=True,
90
+ capture_output=True,
91
+ check=False,
92
+ )
93
+ except FileNotFoundError:
94
+ die("crontab not found on PATH; this command needs cron (macOS/Linux).")
95
+
96
+
97
+ def _read_crontab() -> list[str]:
98
+ res = _crontab("-l")
99
+ if res.returncode != 0: # no crontab installed yet
100
+ return []
101
+ return res.stdout.splitlines()
102
+
103
+
104
+ def _write_crontab(lines: list[str]) -> None:
105
+ res = _crontab("-", stdin="\n".join(lines) + "\n")
106
+ if res.returncode != 0:
107
+ die(f"could not write crontab: {res.stderr.strip()}")
108
+
109
+
110
+ def _without_marker(lines: list[str]) -> list[str]:
111
+ return [line for line in lines if MARKER not in line]
112
+
113
+
114
+ def install_cron_line(line: str) -> None:
115
+ """Add our cron line, replacing any previous zotrm-sync line."""
116
+ lines = _without_marker(_read_crontab())
117
+ lines.append(line)
118
+ _write_crontab(lines)
119
+
120
+
121
+ def remove_cron_job() -> bool:
122
+ """Remove our cron line. Returns True if one was present."""
123
+ lines = _read_crontab()
124
+ kept = _without_marker(lines)
125
+ if len(kept) == len(lines):
126
+ return False
127
+ _write_crontab(kept)
128
+ return True
129
+
130
+
131
+ def show_cron_job() -> str | None:
132
+ for line in _read_crontab():
133
+ if MARKER in line:
134
+ return line
135
+ return None
136
+
137
+
138
+ def _ask(question: Any) -> Any:
139
+ answer = question.ask()
140
+ if answer is None:
141
+ die("cancelled")
142
+ return answer
143
+
144
+
145
+ def run_cron_setup(config_path: Path) -> None:
146
+ """Interactively schedule an automatic ``zotrm sync`` via cron."""
147
+ if not config_path.exists():
148
+ log(f"No config at {config_path}. Run 'zotrm config' first.")
149
+ return
150
+
151
+ preset = str(_ask(questionary.select("How often should it sync?", choices=list(PRESETS))))
152
+
153
+ time: str | None = None
154
+ weekday: int | None = None
155
+ custom: str | None = None
156
+ if preset == "Once a day":
157
+ time = str(_ask(questionary.text("What time? (24h, e.g. 07:30)", default="07:00")))
158
+ elif preset == "Once a week":
159
+ day = str(_ask(questionary.select("Which day?", choices=[name for name, _ in _WEEKDAYS])))
160
+ weekday = dict(_WEEKDAYS)[day]
161
+ time = str(_ask(questionary.text("What time? (24h, e.g. 07:30)", default="07:00")))
162
+ elif preset == "Advanced (cron expression)":
163
+ custom = str(_ask(questionary.text("Cron expression (5 fields)", default="*/30 * * * *")))
164
+
165
+ schedule = build_schedule(preset, time=time, weekday=weekday, custom=custom)
166
+ logfile = str(config_path.parent / "sync.log")
167
+ line = build_cron_line(schedule, zotrm_command(config_path), logfile)
168
+
169
+ log(f"\nThis line will be added to your crontab:\n\n {line}\n")
170
+ if not bool(_ask(questionary.confirm("Install it?", default=True))):
171
+ log("Aborted; crontab unchanged.")
172
+ return
173
+
174
+ install_cron_line(line)
175
+ log(f" ✓ Scheduled. Logs will go to {logfile}")
176
+ log(" Check it any time with 'crontab -l' or 'zotrm cron --show'.")
zotrm/remarkable.py ADDED
@@ -0,0 +1,37 @@
1
+ """Thin wrapper around the external ``rmapi`` binary (the ddvk fork).
2
+
3
+ ``rmapi`` is a runtime system prerequisite, not a Python dependency. If it is
4
+ not on PATH we fail with a clear, actionable message instead of a traceback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+
11
+ from zotrm.config import die
12
+
13
+ # Remote folders created this process; avoids redundant mkdir calls.
14
+ _MADE_FOLDERS: set[str] = set()
15
+
16
+
17
+ def rmapi(*args: str, capture: bool = False) -> subprocess.CompletedProcess[str]:
18
+ """Run an rmapi sub-command non-interactively."""
19
+ try:
20
+ return subprocess.run(
21
+ ["rmapi", *args],
22
+ check=False,
23
+ text=True,
24
+ capture_output=capture,
25
+ )
26
+ except FileNotFoundError:
27
+ die("rmapi not found on PATH. Install the ddvk fork: brew install rmapi")
28
+
29
+
30
+ def ensure_remote_folder(folder: str) -> None:
31
+ """Create a (possibly nested) reMarkable folder, one level at a time."""
32
+ path = ""
33
+ for part in (p for p in folder.strip("/").split("/") if p):
34
+ path = f"{path}/{part}"
35
+ if path not in _MADE_FOLDERS:
36
+ rmapi("mkdir", path, capture=True) # harmless if it already exists
37
+ _MADE_FOLDERS.add(path)
zotrm/wizard.py ADDED
@@ -0,0 +1,194 @@
1
+ """Interactive setup wizard for the config file (the ``zotrm config`` command).
2
+
3
+ Uses ``questionary`` for arrow-key menus, masked secret entry, and validation.
4
+ It is only ever run interactively, so importing this module (and therefore
5
+ questionary) is deferred until a wizard command is actually used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import configparser
11
+ import shutil
12
+ from configparser import ConfigParser
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import questionary
17
+
18
+ from zotrm.config import die, log
19
+ from zotrm.zotero import connect
20
+
21
+
22
+ def _ask(question: Any) -> Any:
23
+ """Run a questionary prompt, aborting cleanly if the user cancels (Ctrl-C)."""
24
+ answer = question.ask()
25
+ if answer is None:
26
+ die("setup cancelled")
27
+ return answer
28
+
29
+
30
+ def _text(message: str, default: str = "", required: bool = False) -> str:
31
+ validate: Any = None
32
+ if required:
33
+ validate = lambda v: True if v.strip() else "This field is required." # noqa: E731
34
+ return str(_ask(questionary.text(message, default=default, validate=validate))).strip()
35
+
36
+
37
+ def _password(message: str, default: str = "") -> str:
38
+ # questionary.password cannot show a default; offer to keep the existing value.
39
+ if default and _confirm("Keep the existing API key?", default=True):
40
+ return default
41
+ return str(_ask(questionary.password(message))).strip()
42
+
43
+
44
+ def _select(message: str, choices: list[str], default: str) -> str:
45
+ return str(_ask(questionary.select(message, choices=choices, default=default)))
46
+
47
+
48
+ def _confirm(message: str, default: bool) -> bool:
49
+ return bool(_ask(questionary.confirm(message, default=default)))
50
+
51
+
52
+ def _path(message: str, default: str = "") -> str:
53
+ return str(_ask(questionary.path(message, default=default))).strip()
54
+
55
+
56
+ def _existing(path: Path) -> dict[str, str]:
57
+ """Flatten an existing config into a single dict for use as defaults."""
58
+ if not path.exists():
59
+ return {}
60
+ cfg = ConfigParser()
61
+ cfg.read(path)
62
+ values: dict[str, str] = {}
63
+ for section in ("zotero", "remarkable"):
64
+ if section in cfg:
65
+ values.update(cfg[section])
66
+ return values
67
+
68
+
69
+ def _as_bool(value: str | None, default: bool) -> bool:
70
+ if value is None:
71
+ return default
72
+ return value.strip().lower() in ("1", "true", "yes")
73
+
74
+
75
+ def _to_configparser(values: dict[str, dict[str, str]]) -> ConfigParser:
76
+ cfg = ConfigParser()
77
+ for section, kv in values.items():
78
+ cfg[section] = {k: v for k, v in kv.items() if v != ""}
79
+ return cfg
80
+
81
+
82
+ def _verify_zotero(cfg: ConfigParser) -> tuple[bool, str]:
83
+ try:
84
+ zot = connect(cfg)
85
+ count = zot.num_items()
86
+ return True, f"Zotero connection OK ({count} items)"
87
+ except Exception as e:
88
+ return False, f"Zotero check failed: {e}"
89
+
90
+
91
+ def _render(values: dict[str, dict[str, str]]) -> str:
92
+ """Render a self-documenting INI file from the collected values."""
93
+ z = values["zotero"]
94
+ r = values["remarkable"]
95
+ lines = [
96
+ "[zotero]",
97
+ f"library_id = {z['library_id']}",
98
+ f"api_key = {z['api_key']}",
99
+ f"library_type = {z['library_type']}",
100
+ ]
101
+ if z.get("storage_dir"):
102
+ lines.append("# Local Zotero storage, to avoid re-downloading PDFs.")
103
+ lines.append(f"storage_dir = {z['storage_dir']}")
104
+ lines += [
105
+ "",
106
+ "[remarkable]",
107
+ "# The Zotero collection whose items get pushed to the tablet.",
108
+ f"collection = {r['collection']}",
109
+ "# Folder on the reMarkable where papers land (created if missing).",
110
+ f"folder = {r['folder']}",
111
+ "# Recreate Zotero sub-collections as nested folders on the tablet?",
112
+ f"mirror_subcollections = {r['mirror_subcollections']}",
113
+ "# Where annotated PDFs are written locally when pulled back.",
114
+ f"output_dir = {r['output_dir']}",
115
+ "# Re-attach the annotated PDF to the Zotero item?",
116
+ f"reattach = {r['reattach']}",
117
+ "",
118
+ ]
119
+ return "\n".join(lines)
120
+
121
+
122
+ def run_config_wizard(path: Path) -> bool:
123
+ """Collect settings interactively and write them to ``path``.
124
+
125
+ Returns True if a config file was written, False if the user aborted.
126
+ """
127
+ prior = _existing(path)
128
+ log("Let's set up zotrm.\n" if not prior else f"Editing {path}\n")
129
+
130
+ home_annotated = str(Path.home() / "Zotero" / "annotated")
131
+ values: dict[str, dict[str, str]] = {
132
+ "zotero": {
133
+ "library_id": _text("Zotero library ID", prior.get("library_id", ""), required=True),
134
+ "api_key": _password("Zotero API key", prior.get("api_key", "")),
135
+ "library_type": _select(
136
+ "Library type", ["user", "group"], prior.get("library_type", "user")
137
+ ),
138
+ "storage_dir": _path(
139
+ "Local Zotero storage dir (optional, Enter to skip)",
140
+ prior.get("storage_dir", ""),
141
+ ),
142
+ },
143
+ "remarkable": {
144
+ "collection": _text("Zotero collection to sync", prior.get("collection", "reMarkable")),
145
+ "folder": _text("reMarkable folder", prior.get("folder", "/Papers")),
146
+ "mirror_subcollections": str(
147
+ _confirm(
148
+ "Mirror sub-collections as nested folders?",
149
+ _as_bool(prior.get("mirror_subcollections"), True),
150
+ )
151
+ ).lower(),
152
+ "output_dir": _path(
153
+ "Where to save annotated PDFs", prior.get("output_dir", home_annotated)
154
+ ),
155
+ "reattach": str(
156
+ _confirm(
157
+ "Re-attach the annotated PDF to the Zotero item?",
158
+ _as_bool(prior.get("reattach"), True),
159
+ )
160
+ ).lower(),
161
+ },
162
+ }
163
+
164
+ log("")
165
+ ok, message = _verify_zotero(_to_configparser(values))
166
+ log(f" {'✓' if ok else '✗'} {message}")
167
+ if shutil.which("rmapi"):
168
+ log(" ✓ rmapi found on PATH")
169
+ else:
170
+ log(" ✗ rmapi not found — install the ddvk fork: brew install rmapi")
171
+
172
+ if not ok and not _confirm("Zotero check failed. Save the config anyway?", default=True):
173
+ log("Aborted; nothing written.")
174
+ return False
175
+
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ path.write_text(_render(values))
178
+ log(f" ✓ Wrote {path}")
179
+ return True
180
+
181
+
182
+ def show_config(path: Path) -> None:
183
+ """Print the config location and its values, masking the API key."""
184
+ if not path.exists():
185
+ log(f"No config at {path}. Run 'zotrm config' to create one.")
186
+ return
187
+ cfg = configparser.ConfigParser()
188
+ cfg.read(path)
189
+ log(f"Config: {path}\n")
190
+ for section in cfg.sections():
191
+ log(f"[{section}]")
192
+ for key, value in cfg[section].items():
193
+ shown = "********" if key == "api_key" and value else value
194
+ log(f" {key} = {shown}")
zotrm/zotero.py ADDED
@@ -0,0 +1,76 @@
1
+ """pyzotero wrapper: connection, collection walk, and tag helpers.
2
+
3
+ ``pyzotero`` ships no type stubs, so the live Zotero client is typed as
4
+ ``Any``; the helpers below pin down the small slices of its API we use.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from configparser import ConfigParser
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from zotrm.config import die
15
+
16
+
17
+ def connect(cfg: ConfigParser) -> Any:
18
+ """Build a pyzotero client from the [zotero] config section."""
19
+ from pyzotero import zotero # imported lazily so --help works without it
20
+
21
+ z = cfg["zotero"]
22
+ return zotero.Zotero(
23
+ z["library_id"],
24
+ z.get("library_type", "user"),
25
+ z["api_key"],
26
+ )
27
+
28
+
29
+ def find_collection_key(zot: Any, name: str) -> str:
30
+ """Return the key of the collection named ``name``, or exit if missing."""
31
+ for c in zot.everything(zot.collections()):
32
+ if c["data"]["name"] == name:
33
+ return str(c["key"])
34
+ die(f"no Zotero collection named {name!r} found")
35
+
36
+
37
+ def pdf_child(zot: Any, item_key: str) -> tuple[str, str] | None:
38
+ """Return (attachment_key, filename) of the first PDF attachment, or None."""
39
+ for child in zot.children(item_key):
40
+ data = child["data"]
41
+ if data.get("contentType") == "application/pdf" and data.get("filename"):
42
+ return str(child["key"]), str(data["filename"])
43
+ return None
44
+
45
+
46
+ def local_pdf_path(cfg: ConfigParser, att_key: str, filename: str) -> Path | None:
47
+ """Return the locally-synced PDF path if it exists, else None."""
48
+ storage = cfg["zotero"].get("storage_dir", "").strip()
49
+ if storage:
50
+ p = Path(storage) / att_key / filename
51
+ if p.exists():
52
+ return p
53
+ return None
54
+
55
+
56
+ def tags_of(item: Any) -> set[str]:
57
+ """Return the set of tag strings on a Zotero item."""
58
+ return {t["tag"] for t in item["data"].get("tags", [])}
59
+
60
+
61
+ def iter_items(
62
+ zot: Any, coll_key: str, base_folder: str, mirror: bool
63
+ ) -> Iterator[tuple[Any, str]]:
64
+ """Yield (item, remote_folder) for every item under a collection.
65
+
66
+ If mirror is True, Zotero sub-collections are recreated as nested
67
+ reMarkable folders (e.g. reMarkable/Multimodal/Retrieval). If False,
68
+ everything lands flat in base_folder.
69
+ """
70
+ for item in zot.everything(zot.collection_items_top(coll_key)):
71
+ yield item, base_folder
72
+ if not mirror:
73
+ return
74
+ for sub in zot.everything(zot.collections_sub(coll_key)):
75
+ sub_folder = f"{base_folder.rstrip('/')}/{sub['data']['name']}"
76
+ yield from iter_items(zot, sub["key"], sub_folder, mirror)
@@ -0,0 +1,251 @@
1
+ Metadata-Version: 2.4
2
+ Name: zotrm
3
+ Version: 0.1.0
4
+ Summary: Bridge a Zotero collection to a reMarkable Paper Pro: push PDFs, pull annotations.
5
+ Project-URL: Homepage, https://roydipta.com
6
+ Project-URL: Repository, https://github.com/dipta007/zotRm
7
+ Author: Shubhashis Roy Dipta
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: annotations,pdf,remarkable,rmapi,zotero
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: pyzotero>=1.5
25
+ Requires-Dist: questionary>=2.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # zotrm
29
+
30
+ [![CI](https://github.com/dipta007/zotRm/actions/workflows/ci.yml/badge.svg)](https://github.com/dipta007/zotRm/actions/workflows/ci.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/zotrm.svg)](https://pypi.org/project/zotrm/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/zotrm.svg)](https://pypi.org/project/zotrm/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/dipta007/zotRm/blob/main/LICENSE)
34
+
35
+ Read your Zotero papers on a **reMarkable Paper Pro**, then get your handwritten
36
+ notes back into Zotero — with one command.
37
+
38
+ ## Demo
39
+
40
+ ![zotrm in action: push papers, annotate on the tablet, pull them back](https://raw.githubusercontent.com/dipta007/zotRm/main/docs/demo.gif)
41
+
42
+ > _Recording the GIF: see [how to record the demo](https://github.com/dipta007/zotRm/blob/main/docs/advanced-usage.md#recording-the-demo-gif)._
43
+
44
+ **What it does, in plain words:**
45
+
46
+ - **push** — sends the PDFs from one Zotero collection to your reMarkable tablet.
47
+ - **pull** — brings the marked-up PDFs back and attaches them to the same Zotero papers.
48
+ - **status** — shows which papers are waiting, on the tablet, or already done.
49
+ - **sync** — does a pull and then a push, together.
50
+
51
+ You never push or pull the same paper twice by mistake — the tool remembers what it has
52
+ done. It is safe to run again and again.
53
+
54
+ > **One honest limit (this is reMarkable's behavior, not a bug here):**
55
+ > Your highlights and handwriting come back as part of the PDF image — "painted onto"
56
+ > the page. They do **not** come back as clickable Zotero highlights.
57
+
58
+ ## Why zotrm?
59
+
60
+ If you read research papers on a reMarkable, getting them on and off the tablet is fiddly:
61
+ export each PDF, drag it into the reMarkable app, and later dig the annotated copy back
62
+ out and re-file it in Zotero. zotrm makes that **one command in each direction**, driven by
63
+ the collection you already curate in Zotero:
64
+
65
+ - **No manual file shuffling** — it reads your Zotero collection and uploads the PDFs for you.
66
+ - **Folders match your library** — Zotero sub-collections become nested folders on the tablet.
67
+ - **Nothing pushed or pulled twice** — progress is tracked with Zotero tags, so it's safe to
68
+ re-run or schedule with `zotrm cron`.
69
+ - **No extra database or account** — just your Zotero API key and the `rmapi` tool.
70
+
71
+ It's a small, focused CLI — not a sync daemon or a cloud service.
72
+
73
+ ---
74
+
75
+ ## Before you start (what you need)
76
+
77
+ 1. A **Zotero** account with some PDFs in it.
78
+ 2. A **reMarkable** tablet, turned on and connected to WiFi.
79
+ 3. A **reMarkable Connect** subscription — this is what lets files move to and from the
80
+ tablet over the internet. Without it, push and pull may not work.
81
+ 4. A computer (Mac or Linux) where you can open a **Terminal** (a window where you type
82
+ commands). On a Mac, open the app called **Terminal**.
83
+
84
+ You will copy and paste a few commands. That is all.
85
+
86
+ ---
87
+
88
+ ## Setup (about 10 minutes)
89
+
90
+ ### Step 1 — Install `uv`
91
+
92
+ `uv` installs this tool for you. Paste this into the Terminal and press Enter:
93
+
94
+ ```sh
95
+ curl -LsSf https://astral.sh/uv/install.sh | sh
96
+ ```
97
+
98
+ Close the Terminal and open it again so the change takes effect.
99
+
100
+ ### Step 2 — Install `rmapi` and connect it to your tablet
101
+
102
+ `rmapi` is a separate program that talks to your reMarkable:
103
+
104
+ ```sh
105
+ brew install rmapi
106
+ ```
107
+
108
+ (No Homebrew? See <https://github.com/ddvk/rmapi> for other ways. Use the **ddvk**
109
+ version — the original does not work with new tablets.)
110
+
111
+ Now connect it to your tablet **one time**:
112
+
113
+ ```sh
114
+ rmapi
115
+ ```
116
+
117
+ It shows a web address and asks for a code. Open
118
+ <https://my.remarkable.com/device/desktop>, copy the code shown there, and paste it into
119
+ the Terminal. Done — you won't do this again.
120
+
121
+ ### Step 3 — Install `zotrm`
122
+
123
+ ```sh
124
+ uv tool install zotrm
125
+ ```
126
+
127
+ ### Step 4 — Set up your account (answer a few questions)
128
+
129
+ Just run:
130
+
131
+ ```sh
132
+ zotrm config
133
+ ```
134
+
135
+ It asks you a few simple questions (use the arrow keys and Enter), then checks that your
136
+ details work. To answer the two Zotero questions:
137
+
138
+ - Open <https://www.zotero.org/settings/keys>.
139
+ - Your **library ID** is the number shown as "Your userID".
140
+ - Click **Create new private key**, allow read **and write**, and copy the key it gives
141
+ you.
142
+
143
+ > **Tip:** In Zotero, make a collection (for example named `reMarkable`) and drag the
144
+ > papers you want to read into it. Give that same name when the wizard asks for the
145
+ > collection.
146
+
147
+ That's it — your settings are saved automatically. (The very first time you run any
148
+ command, this wizard starts on its own if you haven't set up yet.)
149
+
150
+ ### Step 5 — Use it
151
+
152
+ Send your papers to the tablet:
153
+
154
+ ```sh
155
+ zotrm push
156
+ ```
157
+
158
+ Read and annotate them on the reMarkable. When you're done, bring them back:
159
+
160
+ ```sh
161
+ zotrm pull
162
+ ```
163
+
164
+ That's the whole loop. 🎉
165
+
166
+ ### Step 6 (optional) — Make it automatic
167
+
168
+ Want it to sync by itself on a schedule? Run:
169
+
170
+ ```sh
171
+ zotrm cron
172
+ ```
173
+
174
+ Pick how often (every hour, daily, etc.) and it sets everything up for you. Remove it
175
+ later with `zotrm cron --remove`.
176
+
177
+ ---
178
+
179
+ ## Everyday commands
180
+
181
+ ```sh
182
+ zotrm push # send waiting papers to the tablet
183
+ zotrm pull # bring marked-up papers back into Zotero
184
+ zotrm status # see what is waiting / on the tablet / done
185
+ zotrm sync # pull, then push, in one step
186
+
187
+ zotrm config # change your settings any time
188
+ zotrm cron # set up (or change) automatic syncing
189
+ ```
190
+
191
+ Not sure what a command will do? Add `--dry-run` to see without changing anything:
192
+
193
+ ```sh
194
+ zotrm --dry-run push
195
+ ```
196
+
197
+ ---
198
+
199
+ ## If something goes wrong
200
+
201
+ - **"rmapi not found"** → Step 2 was missed, or reopen the Terminal.
202
+ - **"config not found"** → Run `zotrm config` to set up your account.
203
+ - **"no Zotero collection named ..."** → The collection name in your settings doesn't
204
+ exactly match a collection in Zotero. Check spelling and capital letters
205
+ (`zotrm config --show` prints your current settings).
206
+ - **Pull finds nothing** → Make sure the tablet is online and finished syncing, and that
207
+ you have a reMarkable Connect subscription.
208
+
209
+ Run any command with `--dry-run` first if you're unsure — it changes nothing.
210
+
211
+ ---
212
+
213
+ ## FAQ
214
+
215
+ **Do I need a reMarkable Connect subscription?**
216
+ Yes, in practice. `zotrm` talks to the reMarkable **cloud** through `rmapi`, and two-way
217
+ document sync (uploading PDFs and downloading annotated ones) needs Connect.
218
+
219
+ **Why don't my highlights come back as real Zotero highlights?**
220
+ The reMarkable returns a flattened PDF — your marks are baked into the page image. That's a
221
+ reMarkable limitation, not something `zotrm` can change. You get a faithful annotated PDF
222
+ re-attached to the item, just not editable highlight objects.
223
+
224
+ **Does it duplicate files if I run it again?**
225
+ No. It tags items `rm:synced` once pushed and `rm:annotated` once pulled back, and skips
226
+ anything already done. Re-run it (or schedule it) freely.
227
+
228
+ **Can I use it with more than one Zotero library?**
229
+ Yes — keep separate config files and pass `--config`. See
230
+ [advanced usage](https://github.com/dipta007/zotRm/blob/main/docs/advanced-usage.md#global-flags).
231
+
232
+ **Does it work on Windows?**
233
+ Not currently. `zotrm` targets macOS and Linux (the `cron` scheduler is Unix-only).
234
+
235
+ **Is my Zotero API key safe?**
236
+ It's stored only in your local config file (`~/.config/zotrm/config.ini`) and never sent
237
+ anywhere except Zotero's own API. `zotrm config --show` masks it.
238
+
239
+ ---
240
+
241
+ ## More
242
+
243
+ - **[Advanced usage](https://github.com/dipta007/zotRm/blob/main/docs/advanced-usage.md)** —
244
+ editing the config file by hand, the full settings reference, how the scheduled sync
245
+ works, multiple configs, and deeper troubleshooting.
246
+ - **[Contributing](https://github.com/dipta007/zotRm/blob/main/CONTRIBUTING.md)** — set up
247
+ the project for development and run the tests.
248
+
249
+ ## License
250
+
251
+ MIT — see [LICENSE](https://github.com/dipta007/zotRm/blob/main/LICENSE).
@@ -0,0 +1,13 @@
1
+ zotrm/__init__.py,sha256=dQSrozlQVGQ5OEjriTHU0U8bI3uAqOMaN4FvDKCLv6g,102
2
+ zotrm/__main__.py,sha256=EPqmnVMXDviTpUsyw3XV30-0aokC4yei14C8bbSdptM,135
3
+ zotrm/cli.py,sha256=l18Gp1uPISHwNqU5INUR2ZubsQqO5Xtoo0S3pu5h5Cw,8058
4
+ zotrm/config.py,sha256=mnzwo5UCZlwRwdJ---4Pa2LWnz2K568uTuxFs5BBAZM,1376
5
+ zotrm/cron.py,sha256=hZghpPk8nARLdLTuc9Mle2c7ohrPSejB73rciDq0Jtw,5428
6
+ zotrm/remarkable.py,sha256=GpBuN2MRaQv3Pwy2kj6nNz6kQd4jU_NVHnvIQvrKrQA,1221
7
+ zotrm/wizard.py,sha256=JW1ZF4TXzqAevbtOPXV0bXnjws8sYNzx1gbUHRfuJOg,6894
8
+ zotrm/zotero.py,sha256=Gz0qJ91hrBCUz7IjaVn_aaW8PrSWBNtbbBvczDVrHgI,2621
9
+ zotrm-0.1.0.dist-info/METADATA,sha256=QoKq40upwmr7WUydDUWpbJrMkv1PxlAqW6TGMqjMf5s,8863
10
+ zotrm-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ zotrm-0.1.0.dist-info/entry_points.txt,sha256=b-7bS0Byeu3-VIxXGdISRKdpsNhKwLmCA-pKbjmv5io,41
12
+ zotrm-0.1.0.dist-info/licenses/LICENSE,sha256=IqkO3GTk9vcWZBUMHYTbz9K7mEngJAWfRy7ohgzicIU,1077
13
+ zotrm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zotrm = zotrm.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shubhashis Roy Dipta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.