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 +3 -0
- zotrm/__main__.py +6 -0
- zotrm/cli.py +244 -0
- zotrm/config.py +42 -0
- zotrm/cron.py +176 -0
- zotrm/remarkable.py +37 -0
- zotrm/wizard.py +194 -0
- zotrm/zotero.py +76 -0
- zotrm-0.1.0.dist-info/METADATA +251 -0
- zotrm-0.1.0.dist-info/RECORD +13 -0
- zotrm-0.1.0.dist-info/WHEEL +4 -0
- zotrm-0.1.0.dist-info/entry_points.txt +2 -0
- zotrm-0.1.0.dist-info/licenses/LICENSE +21 -0
zotrm/__init__.py
ADDED
zotrm/__main__.py
ADDED
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
|
+
[](https://github.com/dipta007/zotRm/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/zotrm/)
|
|
32
|
+
[](https://pypi.org/project/zotrm/)
|
|
33
|
+
[](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
|
+

|
|
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,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.
|