memory-toast-make-card 0.1.0 → 0.5.0

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.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # memory-toast-make-card
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/memory-toast-make-card)](https://www.npmjs.com/package/memory-toast-make-card)
4
+ [![license: MIT](https://img.shields.io/npm/l/memory-toast-make-card)](LICENSE)
5
+
3
6
  A [Claude Code](https://claude.com/claude-code) skill that turns your study material —
4
7
  requirements, web research, PDFs, images, notes — into [Memory Toast](https://memory-toast-api.smallseven-87b.workers.dev)
5
8
  flashcard decks (卡包), optionally **generates card images with your own OpenAI/Gemini key**,
@@ -62,6 +65,9 @@ You can also drive the scripts directly:
62
65
  ```bash
63
66
  S=~/.claude/skills/memory-toast-make-card/scripts
64
67
 
68
+ # One-time: choose where decks are kept (remembered; keeps them out of /tmp)
69
+ python3 $S/mt_config.py set deck-root ~/Documents/MemoryToast/decks
70
+
65
71
  # Generate an image with your own key
66
72
  python3 $S/gen_image.py --provider openai \
67
73
  --prompt "flat vector icon of a person eating, warm palette, white background" \
@@ -70,16 +76,57 @@ python3 $S/gen_image.py --provider openai \
70
76
  # Validate a deck directory offline (builds my-deck/build/pack.zip)
71
77
  python3 $S/upload_pack.py my-deck --dry-run
72
78
 
73
- # Create a new deck and upload
79
+ # Create a new deck and upload (writes my-deck/.memory-toast.json)
80
+ python3 $S/upload_pack.py my-deck
81
+
82
+ # Update the SAME deck later — just run it again; it auto-reads the record
74
83
  python3 $S/upload_pack.py my-deck
75
84
 
76
- # Update an existing deck (needs its current server version)
77
- python3 $S/upload_pack.py my-deck --deck-id <id> --local-version <serverVersion>
85
+ # Publish to the public Library, then release new versions later
86
+ python3 $S/library_pack.py publish my-deck --category language --description "..."
87
+ python3 $S/library_pack.py release my-deck --changelog "Added 10 cards"
78
88
  ```
79
89
 
80
90
  See [`references/pack-format.md`](references/pack-format.md) for the `deck.json` schema, the
81
91
  upload protocol, limits, and version/conflict rules.
82
92
 
93
+ ### Deck storage, updates & publishing
94
+
95
+ - Decks are built under a **remembered root** (`mt_config.py set deck-root <path>`), not /tmp,
96
+ so the editable source survives reboots. Each deck folder gets a `.memory-toast.json` record
97
+ (deckId, version, structure, …) on upload — the state a later session reads to continue.
98
+ - To **update** a deck, edit it and re-run `upload_pack.py <deck-dir>`; it reads that record and
99
+ bumps the server version automatically — no ids to remember (`--new` forces a fresh deck).
100
+ - To **share** a deck, `library_pack.py publish` it to the public Library (categories: language,
101
+ science, history, programming, math, geography, exam, other), then `release` new versions as
102
+ you update it. `status` lists what you've published.
103
+
104
+ ## Rich text in cards
105
+
106
+ A card's `frontContent` / `backContent` (and a section's `caption`) may contain a small **HTML
107
+ subset** for formatting; plain text is always valid, so existing decks keep working unchanged.
108
+ `make-card` emits this HTML straight from `deck.json` — what you write is what the app renders.
109
+
110
+ Allowed tags:
111
+
112
+ - `<b>` / `<strong>` — bold
113
+ - `<i>` / `<em>` — italic
114
+ - `<u>` — underline
115
+ - `<br>` — line break
116
+ - `<span style="font-size:sm|base|lg|xl;color:<token>">` — inline size and/or color
117
+ - `<p style="text-align:left|center|right">…</p>` — paragraph alignment
118
+
119
+ Font sizes are four semantic levels — `sm`, `base`, `lg`, `xl` (not arbitrary px). Color tokens
120
+ are: `primary`, `red`, `orange`, `green`, `blue`, `purple`, `gray`.
121
+
122
+ ```json
123
+ { "frontContent": "<b>紅蘋果</b>", "backContent": "apple <span style=\"font-size:lg;color:red\">(fruit)</span>" }
124
+ ```
125
+
126
+ Anything outside the whitelist (other tags, attributes, styles, or color values) is dropped
127
+ while its text is kept — no scripts ever run. See
128
+ [`references/pack-format.md`](references/pack-format.md) §3.1 for the full rules.
129
+
83
130
  ## Configuration
84
131
 
85
132
  - **Server URL** resolves as: `--api` flag → `MEMORY_TOAST_API_URL` env → stored value →
package/SKILL.md CHANGED
@@ -37,6 +37,21 @@ Ask (in the user's language) only what is missing, one item at a time:
37
37
  - Whether cards should have **generated images** — and if so, the visual style
38
38
  (e.g. flat vector icon, watercolor, photoreal) and which provider (OpenAI / Gemini).
39
39
 
40
+ **Deck storage location (do this first — never build decks in /tmp):**
41
+
42
+ ```bash
43
+ python3 scripts/mt_config.py get deck-root # prints the saved root, or empty
44
+ ```
45
+
46
+ If empty, ask the user where to keep their decks, then save it (remembered across sessions in
47
+ `~/.memory-toast/config.json`):
48
+
49
+ ```bash
50
+ python3 scripts/mt_config.py set deck-root ~/Documents/MemoryToast/decks
51
+ ```
52
+
53
+ Build each deck at `<deck-root>/<slug>/` — `mt_config.py path <slug>` prints the full path.
54
+
40
55
  ### 2. Collect data
41
56
 
42
57
  - **User files:** read PDFs/images directly and transcribe (a dedicated `pdf` skill is
@@ -80,9 +95,18 @@ self-explanatory play button. Use a caption only when it carries real informatio
80
95
 
81
96
  ### 5. Build the deck directory + validate (no network)
82
97
 
83
- Create a working directory (e.g. `/tmp/make-card/<slug>/` or a user-specified path) with
84
- `deck.json` (schema in pack-format.md §3) and any `assets/`. Do NOT hand-write UUIDs,
85
- positions, `storageRef`, or `mimeType` the script generates them. Then:
98
+ Create the deck directory at `<deck-root>/<slug>/` (the root from step 1 — **never /tmp**,
99
+ which is wiped on reboot and loses the editable source + AI record). Put `deck.json` (schema
100
+ in pack-format.md §3) and any `assets/` there. Do NOT hand-write UUIDs, positions,
101
+ `storageRef`, or `mimeType` — the script generates them. On upload the script also writes
102
+ `.memory-toast.json` (the AI record) into this directory.
103
+
104
+ Rich text is supported: `frontContent` / `backContent` / `caption` may use an HTML subset
105
+ for font size, bold/italic/underline, color, and paragraph alignment. The script emits the
106
+ matching `*Html` keys automatically and falls back to plain text for old-style content. See
107
+ the whitelist + examples in pack-format.md §3.1 — stay inside it or tags are stripped.
108
+
109
+ Then:
86
110
 
87
111
  ```bash
88
112
  python3 scripts/upload_pack.py <deck-dir> --dry-run
@@ -93,20 +117,45 @@ Fix any validation errors. The built ZIP lands at `<deck-dir>/build/pack.zip`.
93
117
  ### 6. Upload
94
118
 
95
119
  ```bash
96
- # New deck (creates the deck, uploads pack version 1)
120
+ # New deck (creates it, uploads pack v1, writes .memory-toast.json)
97
121
  python3 scripts/upload_pack.py <deck-dir>
98
122
 
99
- # Update an existing deck — needs the current server version
123
+ # Update the SAME deck later just run it again. upload_pack.py reads
124
+ # .memory-toast.json and auto-updates (deckId + version); no flags needed.
125
+ python3 scripts/upload_pack.py <deck-dir>
126
+
127
+ # Override target/version explicitly if needed (--new forces a brand-new deck):
100
128
  python3 scripts/upload_pack.py <deck-dir> --deck-id <id> --local-version <serverVersion>
101
129
  ```
102
130
 
103
- On a 409 conflict the script prints the server version and the exact retry command.
131
+ Every successful upload (re)writes the deck's `.memory-toast.json` the **AI record** a
132
+ future session reads to update or publish the deck. On a 409 conflict the script prints the
133
+ server version and the exact retry command.
104
134
  **Before updating an existing deck, warn the user:** the upload replaces the server pack
105
135
  wholesale; un-synced edits on the phone are overwritten on the next pull (pack-format.md §5).
106
136
 
107
137
  If the script says the session expired, run `python3 scripts/mt_login.py` again.
108
138
 
109
- ### 7. Report
139
+ ### 7. Publish to the Library (optional)
140
+
141
+ Decks are private until published. To share a deck publicly, use `scripts/library_pack.py`
142
+ (reads `deckId` from `.memory-toast.json`):
143
+
144
+ ```bash
145
+ # Publish once (category: language|science|history|programming|math|geography|exam|other)
146
+ python3 scripts/library_pack.py publish <deck-dir> \
147
+ --category language --description "..." --learning-language es
148
+
149
+ # After updating the deck (upload_pack.py first), push a new public version:
150
+ python3 scripts/library_pack.py release <deck-dir> --changelog "Added digraphs"
151
+
152
+ python3 scripts/library_pack.py status # list your published packs
153
+ ```
154
+
155
+ `publish` records the `libraryPackId` back into `.memory-toast.json`, so `release` later needs
156
+ no ids. Confirm with the user before publishing — it makes the deck publicly downloadable.
157
+
158
+ ### 8. Report
110
159
 
111
160
  Tell the user: deck title, card count, media count, ZIP size, deck id, pack version, and
112
161
  remind them to pull the deck in the app (open the deck → top banner "new version available" →
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memory-toast-make-card",
3
- "version": "0.1.0",
4
- "description": "Claude Code skill to build Memory Toast flashcard decks (卡包) — with optional AI image generation using your own OpenAI/Gemini key — and upload them to your Memory Toast account.",
3
+ "version": "0.5.0",
4
+ "description": "Claude Code skill to build Memory Toast flashcard decks (卡包) — with optional AI image generation using your own OpenAI/Gemini key — and upload, update, or publish them to your Memory Toast account.",
5
5
  "bin": {
6
6
  "memory-toast-make-card": "bin/install.js"
7
7
  },
@@ -126,6 +126,108 @@ my-deck/
126
126
  - Generated images (from `gen_image.py`) are just local image sections — save them under the
127
127
  deck directory and reference them via `file`.
128
128
 
129
+ ### 3.1 Rich text — the HTML subset
130
+
131
+ `frontContent` / `backContent` and a section's `caption` may be **HTML-subset** strings that
132
+ carry **font size, bold / italic / underline, color, and paragraph alignment**. The mobile app
133
+ parses them with the same whitelist; anything outside it is dropped (tag removed, inner text
134
+ kept), so as long as you stay inside the whitelist, what you emit is what the app renders.
135
+
136
+ Whitelist (identical to `apps/mobile/.../rich_text/rich_html_parser.dart`):
137
+
138
+ | Class | Allowed | Meaning |
139
+ |-------|---------|---------|
140
+ | inline | `<b>` `<strong>` | bold |
141
+ | inline | `<i>` `<em>` | italic |
142
+ | inline | `<u>` | underline |
143
+ | inline | `<br>` | line break |
144
+ | inline | `<span style="…">` | **only** `font-size:sm\|base\|lg\|xl` and/or `color:<token>` |
145
+ | block | `<p style="text-align:left\|center\|right">…</p>` | paragraph alignment |
146
+
147
+ - `color` token must be one of: `primary` `red` `orange` `green` `blue` `purple` `gray`.
148
+ - Any other tag (`<div>`, `<script>`, …), other attribute (`class`, `onclick`, …), or other
149
+ style prop/value (`font-weight`, `99px`, `hotpink`, …) is dropped while its text is kept.
150
+
151
+ How the emitter derives `*Html` + plain text:
152
+
153
+ - The rich source for a field is the explicit `frontContentHtml` / `backContentHtml` /
154
+ `captionHtml` key on the card/section if present; otherwise the `frontContent` /
155
+ `backContent` / `caption` value itself if it contains whitelist tags.
156
+ - With a rich source: the output `*Html` = sanitized whitelist HTML, and the plain field =
157
+ the tag-stripped text (used for search + as a render fallback).
158
+ - With no rich source: only the plain field is emitted; the `*Html` key is **omitted** so
159
+ old-style plain packs stay byte-clean.
160
+ - The empty-card check (front/back + sections not all empty) runs against the **plain** text.
161
+
162
+ Rich card in deck.json (the two forms are equivalent — use either):
163
+
164
+ ```json
165
+ {
166
+ "frontContent": "<b>食べる</b><br><span style=\"font-size:lg;color:red\">godan verb</span>",
167
+ "backContent": "to eat (taberu)"
168
+ }
169
+ ```
170
+
171
+ ```json
172
+ {
173
+ "frontContent": "食べる",
174
+ "frontContentHtml": "<b>食べる</b><br><span style=\"font-size:lg;color:red\">godan verb</span>",
175
+ "backContent": "to eat (taberu)"
176
+ }
177
+ ```
178
+
179
+ Both produce, in `cards.json`:
180
+ `frontContentHtml = "<b>食べる</b><br><span style=\"font-size:lg;color:red\">godan verb</span>"`
181
+ and `frontContent = "食べる\ngodan verb"` (the plain projection).
182
+
183
+ ### 3.2 Ordered blocks — advanced layout
184
+
185
+ Most cards only need `frontContent` + `frontSections[]`. To control the **exact vertical
186
+ order** (e.g. "word → audio right under it → KK/POS → a tappable example → image"), a card
187
+ side may instead use `frontBlocks` / `backBlocks`: an **ordered** array of blocks. The app
188
+ renders blocks in order when present; cards without blocks render via `*Content` +
189
+ `*Sections` exactly as before.
190
+
191
+ Block types:
192
+
193
+ | type | fields | notes |
194
+ |------|--------|-------|
195
+ | `text` | `content` / `contentHtml` (rich, §3.1 whitelist), `audios[]` (optional), `audioAlign` / `audioSize` (optional) | a text run; may carry a row of tap-to-play audio buttons below it |
196
+ | `image` | `file` (local) or `url` (external), `caption` / `captionHtml` (optional) | image, same rules as a §3 image section |
197
+
198
+ Each `audios[]` button: `{ "label": "US", "file": "assets/x.us.mp3" }` (or `url`); `label`
199
+ may be rich (`labelHtml`). Each button is independently tap-to-play — no autoplay, no toggle;
200
+ add more buttons for more accents.
201
+
202
+ The audio row's layout is tunable per text block: `audioAlign` = `start` / `center` (default) /
203
+ `end` (horizontal alignment of the button row), and `audioSize` = `sm` / `md` (default) / `lg`
204
+ (button padding, icon and label size). Older app versions ignore these and use the defaults.
205
+
206
+ deck.json example (back uses blocks; front stays a plain word):
207
+
208
+ ```json
209
+ {
210
+ "frontContent": "<p style=\"text-align:center\"><b><span style=\"font-size:xl\">apple</span></b></p>",
211
+ "backBlocks": [
212
+ { "type": "text", "content": "<b><span style=\"font-size:xl\">apple</span></b>",
213
+ "audios": [ {"label":"US","file":"assets/apple.us.mp3"}, {"label":"UK","file":"assets/apple.uk.mp3"} ],
214
+ "audioAlign": "center", "audioSize": "md" },
215
+ { "type": "text", "content": "<span style=\"color:gray\">[ˈæpl̩]</span><br><b><span style=\"color:primary\">n.</span></b> apple" },
216
+ { "type": "text", "content": "<b>e.g.</b> I eat an <b>apple</b>.",
217
+ "audios": [ {"label":"US","file":"assets/apple.ex1.us.mp3"}, {"label":"UK","file":"assets/apple.ex1.uk.mp3"} ] },
218
+ { "type": "image", "file": "assets/apple.webp" }
219
+ ]
220
+ }
221
+ ```
222
+
223
+ **Backward compatibility (important):** when emitting blocks, `upload_pack.py` ALSO writes an
224
+ equivalent legacy projection — `backContent` (text blocks joined) + `backSections` (image
225
+ blocks and every audio turned into a section, caption = the button's label). So new app
226
+ versions render the new layout from `*Blocks`, while old app versions ignore `*Blocks` and
227
+ render the previous layout from `*Content` + `*Sections`. `schemaVersion` stays `1` (purely
228
+ additive optional keys). Each block's media follows the same extension whitelist and gets
229
+ generated UUID/position/storageRef/mimeType, just like §3 sections.
230
+
129
231
  ## 4. Upload protocol (API)
130
232
 
131
233
  `scripts/upload_pack.py` runs these steps (the same endpoints the mobile app's sync uses):
@@ -159,7 +261,7 @@ with the local one and offers the pull when the server is newer and no local edi
159
261
 
160
262
  | Item | Limit |
161
263
  |------|-------|
162
- | ZIP size | ≤ 100 MB |
264
+ | ZIP size | ≤ 200 MB |
163
265
  | title | 1–200 characters |
164
266
  | description | ≤ 1000 characters |
165
267
  | tags | ≤ 10 |
@@ -74,6 +74,13 @@ def _http_post(url: str, payload: dict, headers: dict, timeout: int = 180):
74
74
  return e.code, {"raw": text}
75
75
 
76
76
 
77
+ def _http_get_bytes(url: str, timeout: int = 120) -> bytes:
78
+ """Download raw bytes (used for DALL·E URL responses)."""
79
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
80
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
81
+ return resp.read()
82
+
83
+
77
84
  def _size_to_aspect(size: str):
78
85
  """Map a WxH size to the closest Imagen aspectRatio bucket (or None)."""
79
86
  if not size or "x" not in size.lower():
@@ -88,15 +95,19 @@ def _size_to_aspect(size: str):
88
95
 
89
96
 
90
97
  def gen_openai(key: str, model: str, prompt: str, size: str) -> bytes:
98
+ # No response_format param: gpt-image-1 returns base64 (and rejects the param),
99
+ # while DALL·E returns a URL — handle whichever comes back.
91
100
  payload = {"model": model, "prompt": prompt, "n": 1, "size": size}
92
101
  status, res = _http_post("https://api.openai.com/v1/images/generations",
93
102
  payload, {"Authorization": f"Bearer {key}"})
94
103
  if status != 200:
95
104
  fail(f"OpenAI image API error ({status}): {(res.get('error') or {}).get('message') or res}")
96
105
  data = res.get("data") or []
97
- if not data or not data[0].get("b64_json"):
98
- fail(f"OpenAI returned no image data: {res}")
99
- return base64.b64decode(data[0]["b64_json"])
106
+ if data and data[0].get("b64_json"):
107
+ return base64.b64decode(data[0]["b64_json"])
108
+ if data and data[0].get("url"):
109
+ return _http_get_bytes(data[0]["url"])
110
+ fail(f"OpenAI returned no image data: {res}")
100
111
 
101
112
 
102
113
  def gen_gemini_imagen(key: str, model: str, prompt: str, size: str) -> bytes:
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Publish a Memory Toast deck to the public Library, or release a new version.
3
+
4
+ Reads the deck's .memory-toast.json (written by upload_pack.py) for deckId and
5
+ libraryPackId, so you just point it at the same deck directory. Auth uses the
6
+ refresh token stored by mt_login.py — no password. Zero deps (Python 3.9+ stdlib).
7
+
8
+ CLI:
9
+ library_pack.py publish DECK_DIR --description TEXT --category CAT
10
+ [--title T] [--language zh-TW] [--learning-language es]
11
+ [--tags a,b,c] [--api URL]
12
+ library_pack.py release DECK_DIR [--changelog TEXT] [--api URL]
13
+ library_pack.py status [--api URL]
14
+
15
+ Categories (key): language science history programming math geography exam other
16
+
17
+ publish → makes the deck's current pack public as a NEW library pack (once).
18
+ release → publishes the deck's CURRENT pack as a NEW library version. Push the
19
+ new deck content with upload_pack.py FIRST, then release.
20
+ status → lists your published library packs.
21
+ """
22
+
23
+ import argparse
24
+ from pathlib import Path
25
+
26
+ from _mt_auth import api_call, fail, get_access_token, load_credentials, resolve_api_url
27
+ from upload_pack import DECK_RECORD, read_record, write_record
28
+
29
+ CATEGORIES = ["language", "science", "history", "programming", "math", "geography", "exam", "other"]
30
+
31
+
32
+ def _deck_id_from_record(deck_dir: Path):
33
+ record = read_record(deck_dir)
34
+ deck_id = record.get("deckId")
35
+ if not deck_id:
36
+ fail(f"no deckId in {deck_dir / DECK_RECORD} — run upload_pack.py first to upload the deck")
37
+ return deck_id, record
38
+
39
+
40
+ def cmd_publish(args) -> None:
41
+ deck_dir = args.deck_dir.resolve()
42
+ deck_id, record = _deck_id_from_record(deck_dir)
43
+ api = resolve_api_url(args.api)
44
+ token = get_access_token(api)
45
+
46
+ body = {
47
+ "deckId": deck_id,
48
+ "title": (args.title or record.get("title") or "")[:100],
49
+ "description": args.description,
50
+ "category": args.category,
51
+ "language": args.language or record.get("language") or "zh-TW",
52
+ }
53
+ if args.learning_language:
54
+ body["learningLanguage"] = args.learning_language
55
+ if args.tags:
56
+ body["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()][:10]
57
+
58
+ status, res = api_call("POST", f"{api}/api/v1/library/publish", body, token)
59
+ if status == 409:
60
+ lp_id = (res.get("libraryPack") or {}).get("id")
61
+ if lp_id:
62
+ write_record(deck_dir, {"libraryPackId": lp_id})
63
+ fail(f"already published as libraryPack {lp_id} — use `release` to push a new version.")
64
+ if status != 201:
65
+ fail(f"publish failed ({status}): {res}")
66
+ lp = res["libraryPack"]
67
+ write_record(deck_dir, {"libraryPackId": lp["id"]})
68
+ print(f"Published. libraryPack={lp['id']} category={args.category}")
69
+ print(f"Recorded libraryPackId in {DECK_RECORD}. The deck is now public in the Library.")
70
+
71
+
72
+ def cmd_release(args) -> None:
73
+ deck_dir = args.deck_dir.resolve()
74
+ deck_id, record = _deck_id_from_record(deck_dir)
75
+ lp_id = record.get("libraryPackId")
76
+ if not lp_id:
77
+ fail(f"no libraryPackId in {DECK_RECORD} — run `library_pack.py publish` first")
78
+ api = resolve_api_url(args.api)
79
+ token = get_access_token(api)
80
+
81
+ body = {"deckId": deck_id}
82
+ if args.changelog:
83
+ body["changelog"] = args.changelog
84
+ status, res = api_call("POST", f"{api}/api/v1/library/packs/{lp_id}/release", body, token)
85
+ if status != 201:
86
+ fail(f"release failed ({status}): {res}")
87
+ ver = (res.get("pack") or {}).get("version")
88
+ print(f"Released new library version v{ver} for libraryPack={lp_id}.")
89
+
90
+
91
+ def cmd_status(args) -> None:
92
+ api = resolve_api_url(args.api)
93
+ token = get_access_token(api)
94
+ status, res = api_call("GET", f"{api}/api/v1/library/my-published", token=token)
95
+ if status != 200:
96
+ fail(f"status failed ({status}): {res}")
97
+ packs = res.get("packs", [])
98
+ who = load_credentials().get("email", "you")
99
+ print(f"{who} has {len(packs)} published library pack(s):")
100
+ for p in packs:
101
+ ver = p.get("latestVersion") or p.get("version")
102
+ print(f" - {p.get('title')} id={p.get('id')} category={p.get('category')} v{ver}")
103
+
104
+
105
+ def main() -> None:
106
+ parser = argparse.ArgumentParser(
107
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
108
+ sub = parser.add_subparsers(dest="cmd", required=True)
109
+
110
+ common = argparse.ArgumentParser(add_help=False)
111
+ common.add_argument("--api", help="override API base URL")
112
+
113
+ pub = sub.add_parser("publish", parents=[common], help="publish the deck to the Library (once)")
114
+ pub.add_argument("deck_dir", type=Path)
115
+ pub.add_argument("--description", required=True, help="1-500 chars, shown in the Library")
116
+ pub.add_argument("--category", required=True, choices=CATEGORIES)
117
+ pub.add_argument("--title", help="default: title from .memory-toast.json (max 100)")
118
+ pub.add_argument("--language", help="content language (default: deck language)")
119
+ pub.add_argument("--learning-language", help="language being learned, e.g. es / ja")
120
+ pub.add_argument("--tags", help="comma-separated, max 10")
121
+ pub.set_defaults(func=cmd_publish)
122
+
123
+ rel = sub.add_parser("release", parents=[common], help="release a new version of a published deck")
124
+ rel.add_argument("deck_dir", type=Path)
125
+ rel.add_argument("--changelog", help="1-500 chars describing what changed")
126
+ rel.set_defaults(func=cmd_release)
127
+
128
+ st = sub.add_parser("status", parents=[common], help="list your published library packs")
129
+ st.set_defaults(func=cmd_status)
130
+
131
+ args = parser.parse_args()
132
+ args.func(args)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent config for make-card — remembered across sessions.
3
+
4
+ Stored at ~/.memory-toast/config.json (chmod 600), shared by every make-card
5
+ script. Zero deps — Python 3.9+ stdlib only.
6
+
7
+ The main use is `deckRoot`: the folder where deck directories live so they are
8
+ NOT built in /tmp (which is wiped on reboot). Each deck is a subfolder
9
+ `<deckRoot>/<slug>/` holding deck.json, assets, build/, and .memory-toast.json.
10
+
11
+ CLI:
12
+ mt_config.py show # print the whole config
13
+ mt_config.py get deck-root # print deckRoot (empty line if unset)
14
+ mt_config.py set deck-root ~/Decks # set + persist deckRoot (expands ~)
15
+ mt_config.py path <slug> # print <deckRoot>/<slug> (errors if unset)
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ CONFIG_DIR = Path.home() / ".memory-toast"
25
+ CONFIG_PATH = CONFIG_DIR / "config.json"
26
+
27
+ # CLI keys (kebab) -> JSON keys (camel)
28
+ _KEY_MAP = {"deck-root": "deckRoot"}
29
+
30
+
31
+ def load_config() -> dict:
32
+ if CONFIG_PATH.is_file():
33
+ try:
34
+ return json.loads(CONFIG_PATH.read_text() or "{}")
35
+ except json.JSONDecodeError:
36
+ return {}
37
+ return {}
38
+
39
+
40
+ def save_config(cfg: dict) -> None:
41
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
42
+ CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
43
+ try:
44
+ os.chmod(CONFIG_PATH, 0o600)
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ def get_deck_root():
50
+ """Return the configured deck root as a string, or None if unset."""
51
+ return load_config().get("deckRoot")
52
+
53
+
54
+ def set_deck_root(path: str) -> Path:
55
+ resolved = Path(path).expanduser().resolve()
56
+ cfg = load_config()
57
+ cfg["deckRoot"] = str(resolved)
58
+ save_config(cfg)
59
+ return resolved
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser(description=__doc__,
64
+ formatter_class=argparse.RawDescriptionHelpFormatter)
65
+ sub = parser.add_subparsers(dest="cmd", required=True)
66
+ sub.add_parser("show")
67
+ g = sub.add_parser("get")
68
+ g.add_argument("key", choices=list(_KEY_MAP))
69
+ s = sub.add_parser("set")
70
+ s.add_argument("key", choices=list(_KEY_MAP))
71
+ s.add_argument("value")
72
+ p = sub.add_parser("path")
73
+ p.add_argument("slug")
74
+ args = parser.parse_args()
75
+
76
+ if args.cmd == "show":
77
+ print(json.dumps(load_config(), ensure_ascii=False, indent=2))
78
+ elif args.cmd == "get":
79
+ print(load_config().get(_KEY_MAP[args.key], ""))
80
+ elif args.cmd == "set":
81
+ if args.key == "deck-root":
82
+ resolved = set_deck_root(args.value)
83
+ print(f"deckRoot = {resolved}")
84
+ elif args.cmd == "path":
85
+ root = get_deck_root()
86
+ if not root:
87
+ print("ERROR: deckRoot is unset — run: mt_config.py set deck-root <path>",
88
+ file=sys.stderr)
89
+ sys.exit(1)
90
+ print(str(Path(root) / args.slug))
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()