memory-toast-make-card 0.1.0 → 0.6.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 +50 -3
- package/SKILL.md +56 -7
- package/package.json +2 -2
- package/references/pack-format.md +104 -2
- package/scripts/gen_image.py +14 -3
- package/scripts/library_pack.py +136 -0
- package/scripts/mt_config.py +94 -0
- package/scripts/test_upload_pack.py +351 -0
- package/scripts/upload_pack.py +581 -26
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# memory-toast-make-card
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/memory-toast-make-card)
|
|
4
|
+
[](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
|
-
#
|
|
77
|
-
python3 $S/
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.6.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
|
},
|
|
@@ -73,7 +73,7 @@ pack.zip
|
|
|
73
73
|
|
|
74
74
|
Rules:
|
|
75
75
|
|
|
76
|
-
- `id` is
|
|
76
|
+
- `id` is a UUID v4. **On re-upload of the same deck, `upload_pack.py` matches each card against the previous build (`build/pack.zip`) by content and reuses the existing id for unchanged cards** (including back-only edits, e.g. adding an example) — only new cards or cards whose front text changed get a fresh UUID. This keeps the user's study progress across deck updates (the app keys study progress by card id and prunes orphaned progress on pull). `position` starts at 0 and increments by array order.
|
|
77
77
|
- For `storageKind: local`, `storageRef` must be `media/{sectionId}.{ext}` and the ZIP must
|
|
78
78
|
contain the matching file.
|
|
79
79
|
- `storageKind: external` is for YouTube/Vimeo and similar links; `storageRef` is the full URL.
|
|
@@ -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 | ≤
|
|
264
|
+
| ZIP size | ≤ 300 MB |
|
|
163
265
|
| title | 1–200 characters |
|
|
164
266
|
| description | ≤ 1000 characters |
|
|
165
267
|
| tags | ≤ 10 |
|
package/scripts/gen_image.py
CHANGED
|
@@ -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
|
|
98
|
-
|
|
99
|
-
|
|
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()
|