memory-toast-make-card 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 smallseven
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.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # memory-toast-make-card
2
+
3
+ A [Claude Code](https://claude.com/claude-code) skill that turns your study material —
4
+ requirements, web research, PDFs, images, notes — into [Memory Toast](https://memory-toast-api.smallseven-87b.workers.dev)
5
+ flashcard decks (卡包), optionally **generates card images with your own OpenAI/Gemini key**,
6
+ and uploads the deck to **your** Memory Toast account as a ZIP pack.
7
+
8
+ It is the public, token-authenticated counterpart of the internal `make-card` skill: you sign
9
+ in once with your own account, and your password is never stored — only a rotating refresh token.
10
+
11
+ ## Install
12
+
13
+ ### Option A — npm
14
+
15
+ ```bash
16
+ # Into your user skills (~/.claude/skills/)
17
+ npx memory-toast-make-card install
18
+
19
+ # Or into the current project (./.claude/skills/)
20
+ npx memory-toast-make-card install --project
21
+ ```
22
+
23
+ ### Option B — skills.sh
24
+
25
+ Install from the registry, or copy the folder into `~/.claude/skills/memory-toast-make-card/`
26
+ manually. The skill is the directory containing `SKILL.md`.
27
+
28
+ ## Prerequisites
29
+
30
+ 1. **A Memory Toast account** — the same one you use in the app.
31
+ 2. **Log in once** (stores a refresh token in `~/.memory-toast/credentials.json`, chmod 600;
32
+ no secret is written to disk except the rotating token):
33
+ ```bash
34
+ # Email/password account:
35
+ python3 ~/.claude/skills/memory-toast-make-card/scripts/mt_login.py
36
+
37
+ # Google/Facebook account (no password): copy the token from the app
38
+ # (Settings → Copy upload token), then paste it here:
39
+ python3 ~/.claude/skills/memory-toast-make-card/scripts/mt_login.py token
40
+ ```
41
+ `mt_login.py whoami` shows the logged-in account; `mt_login.py logout` removes the token.
42
+ 3. **(Optional) image generation** — export your own key for the provider you want:
43
+ ```bash
44
+ export OPENAI_API_KEY=sk-... # OpenAI gpt-image-1
45
+ export GEMINI_API_KEY=... # Google Imagen / Gemini
46
+ ```
47
+ You always supply your own key; the skill never provides one. Image generation calls a
48
+ **paid** API — the skill confirms the cost with you before generating in bulk.
49
+
50
+ ## Usage
51
+
52
+ In Claude Code, just ask — for example:
53
+
54
+ > Make a Memory Toast deck of 30 common Japanese N3 verbs, each with a flat-vector icon image.
55
+
56
+ The skill will gather requirements, draft a few sample cards for your approval, generate any
57
+ images (with your confirmation on cost), build the ZIP, and upload it. Then open the deck in
58
+ the Memory Toast app and tap **Download** on the "new version available" banner.
59
+
60
+ You can also drive the scripts directly:
61
+
62
+ ```bash
63
+ S=~/.claude/skills/memory-toast-make-card/scripts
64
+
65
+ # Generate an image with your own key
66
+ python3 $S/gen_image.py --provider openai \
67
+ --prompt "flat vector icon of a person eating, warm palette, white background" \
68
+ --out my-deck/assets/taberu.png
69
+
70
+ # Validate a deck directory offline (builds my-deck/build/pack.zip)
71
+ python3 $S/upload_pack.py my-deck --dry-run
72
+
73
+ # Create a new deck and upload
74
+ python3 $S/upload_pack.py my-deck
75
+
76
+ # Update an existing deck (needs its current server version)
77
+ python3 $S/upload_pack.py my-deck --deck-id <id> --local-version <serverVersion>
78
+ ```
79
+
80
+ See [`references/pack-format.md`](references/pack-format.md) for the `deck.json` schema, the
81
+ upload protocol, limits, and version/conflict rules.
82
+
83
+ ## Configuration
84
+
85
+ - **Server URL** resolves as: `--api` flag → `MEMORY_TOAST_API_URL` env → stored value →
86
+ built-in default. Self-hosters can point the skill at their own server without editing files.
87
+ - **Image model** is overridable with `--model`. Defaults: `gpt-image-1` (OpenAI),
88
+ `imagen-4.0-generate-001` (Gemini). Pass a `gemini-*-image` model (e.g.
89
+ `gemini-2.5-flash-image`) to use the `generateContent` path instead of Imagen `predict`.
90
+ > Note: Google's older image-generation model endpoints are deprecated after **2026-06-30** —
91
+ > switch the default via `--model` if generation starts failing.
92
+
93
+ ## Security notes
94
+
95
+ - The password is read interactively (never echoed) and **never stored** — only the rotating
96
+ 7-day refresh token, in a `chmod 600` file under your home directory. `logout` deletes it.
97
+ - API keys and tokens are never printed; error paths redact them.
98
+ - These are **stateless JWTs**: a leaked refresh token cannot be individually revoked before
99
+ its 7-day expiry. Keep the credential file private and run `logout` on shared machines.
100
+ - An upload **replaces** the deck's server pack wholesale — push any un-synced phone edits
101
+ first (see pack-format.md §5).
102
+
103
+ ## Requirements
104
+
105
+ - Python 3.9+ (standard library only — no `pip install`).
106
+ - Node 16+ (for the `npx` installer only).
107
+
108
+ ## License
109
+
110
+ MIT — see [LICENSE](LICENSE). (Copyright holder is set to `smallseven`; edit it if you'd
111
+ rather publish under a different name/org.)
package/SKILL.md ADDED
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: memory-toast-make-card
3
+ description: Create Memory Toast flashcard decks (卡包) from the user's requirements, web research, or their own data (PDFs, images, notes) — optionally generating card images with the user's own OpenAI/Gemini key — then upload them to the user's Memory Toast account as ZIP packs via the API. Use when the user asks to make cards, build a deck/卡包, turn study material into flashcards, generate card images, or upload a deck to Memory Toast.
4
+ ---
5
+
6
+ # Make Card — Memory Toast deck builder & uploader
7
+
8
+ Build a flashcard deck as a local "deck directory" (`deck.json` + media), confirm the card
9
+ format with the user via samples, optionally generate images with the user's own API key,
10
+ then build + upload the ZIP pack with `scripts/upload_pack.py`.
11
+
12
+ The authoritative spec for formats, the API protocol, limits, and conflict rules is
13
+ [references/pack-format.md](references/pack-format.md) — read it before writing `deck.json`
14
+ or debugging an upload. **Converse in the user's language** even though these docs are in English.
15
+
16
+ ## Prerequisites (one-time)
17
+
18
+ - **Account:** the user needs a Memory Toast account (the same one they use in the app).
19
+ - **Login (email/password):** `python3 scripts/mt_login.py` — prompts once, then stores a
20
+ rotating refresh token in `~/.memory-toast/credentials.json` (chmod 600). The password is
21
+ never stored. **Never ask for or handle the user's raw password yourself — the helper does.**
22
+ - **Login (Google/Facebook users):** these accounts have no password. In the app, go to
23
+ **Settings → Copy upload token**, then run `python3 scripts/mt_login.py token` and paste it.
24
+ - `mt_login.py whoami` shows who is logged in; `mt_login.py logout` clears it.
25
+ - **Image keys (only if generating images):** the user exports their own
26
+ `OPENAI_API_KEY` or `GEMINI_API_KEY`. You never provide a key.
27
+
28
+ ## Workflow
29
+
30
+ ### 1. Gather requirements
31
+
32
+ Ask (in the user's language) only what is missing, one item at a time:
33
+
34
+ - Topic and scope (e.g. "50 N3 verbs" vs. "this whole PDF").
35
+ - Deck title, description, tags, language (default `zh-TW`).
36
+ - Data source: user-provided files (PDF/images/notes) or web research.
37
+ - Whether cards should have **generated images** — and if so, the visual style
38
+ (e.g. flat vector icon, watercolor, photoreal) and which provider (OpenAI / Gemini).
39
+
40
+ ### 2. Collect data
41
+
42
+ - **User files:** read PDFs/images directly and transcribe (a dedicated `pdf` skill is
43
+ optional, not required).
44
+ - **Web research:** use WebSearch/WebFetch. Verify factual content (dates, definitions,
45
+ translations) against at least one more source.
46
+ - Any downloaded media for a card goes into the deck directory (e.g. `my-deck/assets/`),
47
+ referenced by relative path in `deck.json`.
48
+
49
+ ### 3. Confirm card format with examples (MANDATORY before mass production)
50
+
51
+ 1. Ask the user for a front/back example of how they want cards to look (or whether to copy
52
+ an existing deck's style).
53
+ 2. Draft 2–3 sample cards from the **actual data** and show them as front/back text (plus any
54
+ planned sections) in chat.
55
+ 3. Wait for explicit approval or corrections, then apply the approved pattern to ALL cards.
56
+
57
+ Audio sections: do NOT add decorative captions (e.g. "聽發音 🔊") — the app renders a
58
+ self-explanatory play button. Use a caption only when it carries real information.
59
+
60
+ ### 4. Generate images (optional — only if requested)
61
+
62
+ 1. Confirm the relevant key is set: `python3 scripts/gen_image.py --provider openai --prompt x
63
+ --out /tmp/probe.png --dry-run` (reports `key=set` or errors). If missing, tell the user the
64
+ exact env var to export.
65
+ 2. **Cost gate (MANDATORY):** before generating, tell the user *"this will call your
66
+ `<provider>` key ~N times (~$X) — proceed?"* and wait for a yes. Image generation spends the
67
+ user's money.
68
+ 3. Generate **one image at a time**, writing into the deck's `assets/`:
69
+ ```bash
70
+ python3 scripts/gen_image.py --provider openai \
71
+ --prompt "flat vector icon of a person eating, warm palette, white background" \
72
+ --out my-deck/assets/taberu.png
73
+ ```
74
+ - OpenAI default model `gpt-image-1`; Gemini default `imagen-4.0-generate-001`
75
+ (override with `--model`).
76
+ - If one image fails (rate limit, content policy), leave that card text-only and continue;
77
+ report which cards got no image.
78
+ 4. Reference each generated PNG as a normal local image section in `deck.json`
79
+ (`{ "kind": "image", "file": "assets/taberu.png" }`).
80
+
81
+ ### 5. Build the deck directory + validate (no network)
82
+
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:
86
+
87
+ ```bash
88
+ python3 scripts/upload_pack.py <deck-dir> --dry-run
89
+ ```
90
+
91
+ Fix any validation errors. The built ZIP lands at `<deck-dir>/build/pack.zip`.
92
+
93
+ ### 6. Upload
94
+
95
+ ```bash
96
+ # New deck (creates the deck, uploads pack version 1)
97
+ python3 scripts/upload_pack.py <deck-dir>
98
+
99
+ # Update an existing deck — needs the current server version
100
+ python3 scripts/upload_pack.py <deck-dir> --deck-id <id> --local-version <serverVersion>
101
+ ```
102
+
103
+ On a 409 conflict the script prints the server version and the exact retry command.
104
+ **Before updating an existing deck, warn the user:** the upload replaces the server pack
105
+ wholesale; un-synced edits on the phone are overwritten on the next pull (pack-format.md §5).
106
+
107
+ If the script says the session expired, run `python3 scripts/mt_login.py` again.
108
+
109
+ ### 7. Report
110
+
111
+ Tell the user: deck title, card count, media count, ZIP size, deck id, pack version, and
112
+ remind them to pull the deck in the app (open the deck → top banner "new version available" →
113
+ tap Download).
package/bin/install.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Installer for the memory-toast-make-card Claude skill.
6
+ *
7
+ * npx memory-toast-make-card install # → ~/.claude/skills/
8
+ * npx memory-toast-make-card install --project # → ./.claude/skills/
9
+ * npx memory-toast-make-card install --dir PATH # → PATH/
10
+ *
11
+ * Copies SKILL.md + scripts/ + references/ into a Claude skills directory.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const SKILL_NAME = 'memory-toast-make-card';
19
+ const PAYLOAD = ['SKILL.md', 'scripts', 'references'];
20
+ const SKIP = new Set(['__pycache__', 'build', '.DS_Store']);
21
+ const pkgRoot = path.resolve(__dirname, '..');
22
+
23
+ function copyRecursive(src, dest) {
24
+ const stat = fs.statSync(src);
25
+ if (stat.isDirectory()) {
26
+ fs.mkdirSync(dest, { recursive: true });
27
+ for (const entry of fs.readdirSync(src)) {
28
+ if (SKIP.has(entry) || entry.endsWith('.pyc')) continue;
29
+ copyRecursive(path.join(src, entry), path.join(dest, entry));
30
+ }
31
+ } else {
32
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
33
+ fs.copyFileSync(src, dest);
34
+ }
35
+ }
36
+
37
+ function main() {
38
+ const args = process.argv.slice(2);
39
+ const cmd = args.find((a) => !a.startsWith('-')) || 'install';
40
+ if (cmd !== 'install') {
41
+ console.error(`Unknown command "${cmd}".`);
42
+ console.error(`Usage: npx ${SKILL_NAME} install [--project] [--dir <path>]`);
43
+ process.exit(1);
44
+ }
45
+
46
+ let baseDir;
47
+ const dirIdx = args.indexOf('--dir');
48
+ if (dirIdx !== -1 && args[dirIdx + 1]) {
49
+ baseDir = path.resolve(args[dirIdx + 1]);
50
+ } else if (args.includes('--project')) {
51
+ baseDir = path.resolve(process.cwd(), '.claude', 'skills');
52
+ } else {
53
+ baseDir = path.join(os.homedir(), '.claude', 'skills');
54
+ }
55
+
56
+ const target = path.join(baseDir, SKILL_NAME);
57
+ for (const item of PAYLOAD) {
58
+ const src = path.join(pkgRoot, item);
59
+ if (!fs.existsSync(src)) {
60
+ console.error(`ERROR: missing payload "${item}" in package at ${pkgRoot}`);
61
+ process.exit(1);
62
+ }
63
+ copyRecursive(src, path.join(target, item));
64
+ }
65
+
66
+ console.log(`Installed "${SKILL_NAME}" skill → ${target}`);
67
+ console.log('');
68
+ console.log('Next steps:');
69
+ console.log(` 1. python3 "${path.join(target, 'scripts', 'mt_login.py')}" # log in once`);
70
+ console.log(' 2. (optional) export OPENAI_API_KEY or GEMINI_API_KEY to generate card images');
71
+ console.log(' 3. In Claude Code, ask: "make a Memory Toast deck about <topic>"');
72
+ }
73
+
74
+ main();
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
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.",
5
+ "bin": {
6
+ "memory-toast-make-card": "bin/install.js"
7
+ },
8
+ "files": [
9
+ "SKILL.md",
10
+ "README.md",
11
+ "scripts/*.py",
12
+ "references/*.md",
13
+ "bin/*.js"
14
+ ],
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "claude-skill",
19
+ "agent-skill",
20
+ "flashcards",
21
+ "spaced-repetition",
22
+ "memory-toast",
23
+ "anki",
24
+ "study"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=16"
29
+ }
30
+ }
@@ -0,0 +1,168 @@
1
+ # Memory Toast pack format & upload protocol
2
+
3
+ Authoritative spec for the `memory-toast-make-card` skill: the ZIP pack structure, the
4
+ `deck.json` input format, and the full API upload flow. Read this before writing
5
+ `deck.json` or debugging an upload.
6
+
7
+ ## Contents
8
+
9
+ 1. [ZIP pack structure](#1-zip-pack-structure)
10
+ 2. [cards.json schema](#2-cardsjson-schema)
11
+ 3. [deck.json — the skill's input format](#3-deckjson--the-skills-input-format)
12
+ 4. [Upload protocol (API)](#4-upload-protocol-api)
13
+ 5. [Versioning & conflicts](#5-versioning--conflicts)
14
+ 6. [Limits & validation](#6-limits--validation)
15
+
16
+ ## 1. ZIP pack structure
17
+
18
+ Each deck's full content is packaged into a single ZIP. The server stores deck metadata
19
+ and pack-version records only — it never parses the ZIP body.
20
+
21
+ ```
22
+ pack.zip
23
+ ├── manifest.json # deck-level metadata
24
+ ├── cards.json # all cards + sections
25
+ └── media/ # local media files, named {sectionId}.{ext}
26
+ ├── 3f2a…c1.jpg
27
+ └── 9b07…e4.mp3
28
+ ```
29
+
30
+ `manifest.json` (provenance only — the app does not validate `builtBy`):
31
+
32
+ ```json
33
+ {
34
+ "schemaVersion": 1,
35
+ "deckTitle": "Japanese N3 Verbs",
36
+ "description": "…",
37
+ "language": "zh-TW",
38
+ "tags": ["japanese", "N3"],
39
+ "cardCount": 50,
40
+ "createdAt": "2026-06-14T08:00:00Z",
41
+ "builtBy": "memory-toast-make-card"
42
+ }
43
+ ```
44
+
45
+ ## 2. cards.json schema
46
+
47
+ ```json
48
+ {
49
+ "schemaVersion": 1,
50
+ "cards": [
51
+ {
52
+ "id": "<uuid>",
53
+ "position": 0,
54
+ "frontContent": "front text (may be empty, but not empty at the same time as sections)",
55
+ "backContent": "back text",
56
+ "frontSections": [
57
+ {
58
+ "id": "<uuid>",
59
+ "position": 0,
60
+ "kind": "image", // image | audio | video
61
+ "storageKind": "local", // local (file in ZIP) | external (URL)
62
+ "storageRef": "media/<sectionId>.jpg", // local: path in ZIP; external: full URL
63
+ "caption": "caption text or null",
64
+ "mimeType": "image/jpeg", // required for local; may be null for external
65
+ "durationMs": null // audio/video length, may be null
66
+ }
67
+ ],
68
+ "backSections": []
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ Rules:
75
+
76
+ - `id` is always a UUID v4; `position` starts at 0 and increments by array order.
77
+ - For `storageKind: local`, `storageRef` must be `media/{sectionId}.{ext}` and the ZIP must
78
+ contain the matching file.
79
+ - `storageKind: external` is for YouTube/Vimeo and similar links; `storageRef` is the full URL.
80
+ - Each section needs at least one of `storageRef` / `caption` non-empty (the app's model
81
+ throws otherwise).
82
+ - For each card side: the text (`frontContent`/`backContent`) and that side's sections cannot
83
+ both be empty.
84
+
85
+ ## 3. deck.json — the skill's input format
86
+
87
+ `scripts/upload_pack.py` consumes a simplified format (it generates every UUID, position,
88
+ `storageRef`, and `mimeType`). Put it in a "deck directory" with media referenced by
89
+ relative path:
90
+
91
+ ```
92
+ my-deck/
93
+ ├── deck.json
94
+ └── assets/
95
+ ├── taberu.jpg # e.g. an image you generated with gen_image.py
96
+ └── pronounce.mp3
97
+ ```
98
+
99
+ ```json
100
+ {
101
+ "title": "Japanese N3 Verbs",
102
+ "description": "50 common verbs",
103
+ "language": "zh-TW",
104
+ "tags": ["japanese", "N3"],
105
+ "cards": [
106
+ {
107
+ "frontContent": "食べる",
108
+ "backContent": "to eat (taberu)",
109
+ "frontSections": [
110
+ { "kind": "image", "file": "assets/taberu.jpg", "caption": "optional" }
111
+ ],
112
+ "backSections": [
113
+ { "kind": "audio", "file": "assets/pronounce.mp3" },
114
+ { "kind": "video", "url": "https://www.youtube.com/watch?v=…" }
115
+ ]
116
+ }
117
+ ]
118
+ }
119
+ ```
120
+
121
+ - Each section must have exactly one of `file` (local → `storageKind: local`) or `url`
122
+ (→ `external`).
123
+ - Extension whitelist — image: jpg/jpeg/png/gif/webp; audio: mp3/m4a/wav/aac/ogg;
124
+ video: mp4/mov/webm.
125
+ - Text-only cards need just `frontContent` + `backContent`; sections may be omitted.
126
+ - Generated images (from `gen_image.py`) are just local image sections — save them under the
127
+ deck directory and reference them via `file`.
128
+
129
+ ## 4. Upload protocol (API)
130
+
131
+ `scripts/upload_pack.py` runs these steps (the same endpoints the mobile app's sync uses):
132
+
133
+ | Step | Endpoint | Notes |
134
+ |------|----------|-------|
135
+ | 1. auth | `POST /api/v1/auth/refresh` `{refreshToken}` | mints a 15-min `accessToken`; run `mt_login.py` once to obtain the stored refresh token |
136
+ | 2. create deck | `POST /api/v1/decks` `{title, description?, tags?}` | returns `deck.id`; skipped when updating an existing deck |
137
+ | 3. start sync | `POST /api/v1/decks/{deckId}/sync` `{localVersion, size, sha256, cardCount}` | returns an R2 signed PUT URL (10-min) + `packId` + `r2Key` |
138
+ | 4. upload ZIP | `PUT {uploadUrl}` (body = ZIP bytes) | query-signed URL, no extra header |
139
+ | 5. commit | `POST /api/v1/packs/{packId}/commit` `{expectedSize, sha256, cardCount, r2Key}` | server verifies the R2 object exists and matches → writes the pack row, bumps `decks.current_pack_id` |
140
+
141
+ Every request except step 4 carries `Authorization: Bearer {accessToken}`.
142
+
143
+ After commit the deck appears in the app's deck list; entering it shows a "new version
144
+ available v.N" banner with a **Download** button (the app compares the server pack version
145
+ with the local one and offers the pull when the server is newer and no local edits are pending).
146
+
147
+ ## 5. Versioning & conflicts
148
+
149
+ - New deck, first pack: `localVersion: 0`, which becomes version 1 after commit.
150
+ - Updating an existing deck: `localVersion` must equal the server's current version, or sync
151
+ returns **409** `{error: "version_mismatch", serverVersion, localVersion}`.
152
+ - Resolve a 409: re-run with `--local-version {serverVersion}` (confirming you want to
153
+ overwrite server content), or `--force` (skips the version check).
154
+ - **Important:** an upload replaces the server pack wholesale. If the phone has un-synced local
155
+ edits, push them from the app first ("同步"), then update with the new version number —
156
+ otherwise those edits are overwritten on the next pull.
157
+
158
+ ## 6. Limits & validation
159
+
160
+ | Item | Limit |
161
+ |------|-------|
162
+ | ZIP size | ≤ 100 MB |
163
+ | title | 1–200 characters |
164
+ | description | ≤ 1000 characters |
165
+ | tags | ≤ 10 |
166
+ | sha256 | 64 lowercase hex; must match the ZIP at commit |
167
+ | expectedSize | must equal the actual R2 object size, or commit returns `size_mismatch` and the object is deleted |
168
+ | signed PUT URL | expires in 10 minutes — upload immediately after sync |
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """Shared auth + config for the memory-toast-make-card skill.
3
+
4
+ Credential store: ``~/.memory-toast/credentials.json`` (chmod 600), holding the
5
+ rotating 7-day refresh token — never the password. Imported by ``mt_login.py``
6
+ and ``upload_pack.py``. Zero third-party deps (Python 3.9+ stdlib only).
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ DEFAULT_API_URL = "https://memory-toast-api.smallseven-87b.workers.dev"
18
+ CONFIG_DIR = Path.home() / ".memory-toast"
19
+ CRED_PATH = CONFIG_DIR / "credentials.json"
20
+ # Cloudflare blocks the default Python-urllib User-Agent (error 1010).
21
+ USER_AGENT = "memory-toast-make-card/1.0"
22
+
23
+
24
+ def fail(msg: str) -> None:
25
+ print(f"ERROR: {msg}", file=sys.stderr)
26
+ sys.exit(1)
27
+
28
+
29
+ def load_credentials() -> dict:
30
+ """Return the stored credentials dict, or {} if not logged in."""
31
+ if not CRED_PATH.is_file():
32
+ return {}
33
+ try:
34
+ return json.loads(CRED_PATH.read_text())
35
+ except json.JSONDecodeError:
36
+ fail(f"{CRED_PATH} is corrupted — run: python3 scripts/mt_login.py logout, then log in again")
37
+
38
+
39
+ def save_credentials(data: dict) -> None:
40
+ """Write the credential store, owner read/write only (0600)."""
41
+ CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
42
+ CRED_PATH.write_text(json.dumps(data, indent=2))
43
+ os.chmod(CRED_PATH, stat.S_IRUSR | stat.S_IWUSR)
44
+
45
+
46
+ def clear_credentials() -> bool:
47
+ """Delete the credential store. Returns True if a file was removed."""
48
+ if CRED_PATH.is_file():
49
+ CRED_PATH.unlink()
50
+ return True
51
+ return False
52
+
53
+
54
+ def resolve_api_url(cli_arg: str = None) -> str:
55
+ """Resolve the API base URL: --api flag > env > stored > built-in default."""
56
+ creds = load_credentials()
57
+ url = (cli_arg or os.environ.get("MEMORY_TOAST_API_URL")
58
+ or creds.get("apiUrl") or DEFAULT_API_URL)
59
+ return url.rstrip("/")
60
+
61
+
62
+ def api_call(method, url, body=None, token=None, raw=None, timeout=300):
63
+ """Minimal JSON/binary HTTP call against the Memory Toast API.
64
+
65
+ Returns (status_code, parsed_json_or_dict). Never raises on HTTP errors.
66
+ """
67
+ headers = {"User-Agent": USER_AGENT}
68
+ data = None
69
+ if raw is not None:
70
+ data = raw
71
+ headers["Content-Type"] = "application/zip"
72
+ elif body is not None:
73
+ data = json.dumps(body).encode()
74
+ headers["Content-Type"] = "application/json"
75
+ if token:
76
+ headers["Authorization"] = f"Bearer {token}"
77
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
80
+ text = resp.read().decode() or "{}"
81
+ return resp.status, (json.loads(text) if text.strip().startswith("{") else {})
82
+ except urllib.error.HTTPError as e:
83
+ text = e.read().decode()
84
+ try:
85
+ return e.code, json.loads(text)
86
+ except json.JSONDecodeError:
87
+ return e.code, {"raw": text}
88
+
89
+
90
+ def login(api: str, email: str, password: str) -> dict:
91
+ """POST /auth/login. Returns the parsed response {user, accessToken, refreshToken}."""
92
+ status, res = api_call("POST", f"{api}/api/v1/auth/login",
93
+ {"email": email, "password": password})
94
+ if status != 200:
95
+ msg = res.get("message") or res.get("error") or res
96
+ fail(f"login failed ({status}): {msg}")
97
+ if "refreshToken" not in res:
98
+ fail(f"login response missing refreshToken: {res}")
99
+ return res
100
+
101
+
102
+ def get_access_token(api: str) -> str:
103
+ """Mint a fresh access token from the stored refresh token.
104
+
105
+ Persists the rotated refresh token (slides the 7-day idle window forward).
106
+ Exits with a friendly message if not logged in or the session has expired.
107
+ """
108
+ creds = load_credentials()
109
+ refresh = creds.get("refreshToken")
110
+ if not refresh:
111
+ fail("not logged in — run: python3 scripts/mt_login.py")
112
+ status, res = api_call("POST", f"{api}/api/v1/auth/refresh",
113
+ {"refreshToken": refresh})
114
+ if status == 401:
115
+ fail("session expired (refresh token older than 7 days or invalid) — "
116
+ "run: python3 scripts/mt_login.py")
117
+ if status != 200 or "accessToken" not in res:
118
+ msg = res.get("message") or res.get("error") or res
119
+ fail(f"token refresh failed ({status}): {msg}")
120
+ # Persist the rotated refresh token; keep the stored apiUrl untouched so a
121
+ # one-off --api override does not clobber the user's configured server.
122
+ if res.get("refreshToken"):
123
+ creds["refreshToken"] = res["refreshToken"]
124
+ email = res.get("user", {}).get("email")
125
+ if email:
126
+ creds["email"] = email
127
+ save_credentials(creds)
128
+ return res["accessToken"]
129
+
130
+
131
+ def store_refresh_token(api: str, refresh_token: str) -> str:
132
+ """Validate a pasted refresh token via /auth/refresh and store it.
133
+
134
+ For Google/Facebook (social-login) users who have no password: they copy this
135
+ token from the app (Settings → Copy upload token) and paste it here. Returns
136
+ the account email on success; exits with a friendly message otherwise.
137
+ """
138
+ status, res = api_call("POST", f"{api}/api/v1/auth/refresh",
139
+ {"refreshToken": refresh_token})
140
+ if status == 401:
141
+ fail("that token is invalid or expired — copy a fresh one from the app "
142
+ "(Settings → Copy upload token)")
143
+ if status != 200 or "refreshToken" not in res:
144
+ msg = res.get("message") or res.get("error") or res
145
+ fail(f"token validation failed ({status}): {msg}")
146
+ email = res.get("user", {}).get("email", "(unknown)")
147
+ save_credentials({
148
+ "apiUrl": api,
149
+ "email": email,
150
+ "refreshToken": res["refreshToken"], # store the freshly-rotated token
151
+ })
152
+ return email
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """Generate one image for a flashcard using the user's OWN OpenAI or Gemini API key.
3
+
4
+ Zero third-party deps — Python 3.9+ stdlib only. The API key is read from the
5
+ environment (or the credential store) and is never printed or stored by this script.
6
+
7
+ Usage:
8
+ gen_image.py --provider openai|gemini --prompt "..." --out PATH
9
+ [--size WxH] [--model ID] [--dry-run]
10
+
11
+ API key (env var, or "imageKeys" in ~/.memory-toast/credentials.json):
12
+ openai → OPENAI_API_KEY
13
+ gemini → GEMINI_API_KEY
14
+
15
+ Defaults:
16
+ openai → gpt-image-1 (sizes: 1024x1024, 1536x1024, 1024x1536, auto)
17
+ gemini → imagen-4.0-generate-001 (Imagen "predict" path; pass a gemini-*-image
18
+ model, e.g. gemini-2.5-flash-image, to use the "generateContent" path)
19
+
20
+ WARNING: this calls a PAID API billed to the user's key. The skill must confirm the
21
+ cost with the user before generating images in bulk. Output is written as raw bytes
22
+ (PNG by default) to --out.
23
+ """
24
+
25
+ import argparse
26
+ import base64
27
+ import json
28
+ import os
29
+ import sys
30
+ import urllib.error
31
+ import urllib.request
32
+ from pathlib import Path
33
+
34
+ USER_AGENT = "memory-toast-make-card/1.0"
35
+ GEMINI_API_VERSION = "v1beta"
36
+ CRED_PATH = Path.home() / ".memory-toast" / "credentials.json"
37
+
38
+ ENV_VAR = {"openai": "OPENAI_API_KEY", "gemini": "GEMINI_API_KEY"}
39
+ DEFAULT_MODEL = {"openai": "gpt-image-1", "gemini": "imagen-4.0-generate-001"}
40
+
41
+
42
+ def fail(msg: str) -> None:
43
+ print(f"ERROR: {msg}", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+
47
+ def resolve_key(provider: str) -> str:
48
+ """Env var first, then imageKeys.<provider> in the credential store."""
49
+ key = os.environ.get(ENV_VAR[provider])
50
+ if key:
51
+ return key
52
+ try:
53
+ if CRED_PATH.is_file():
54
+ creds = json.loads(CRED_PATH.read_text())
55
+ return (creds.get("imageKeys") or {}).get(provider)
56
+ except (json.JSONDecodeError, OSError):
57
+ pass
58
+ return None
59
+
60
+
61
+ def _http_post(url: str, payload: dict, headers: dict, timeout: int = 180):
62
+ data = json.dumps(payload).encode()
63
+ h = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
64
+ h.update(headers)
65
+ req = urllib.request.Request(url, data=data, headers=h, method="POST")
66
+ try:
67
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
68
+ return resp.status, json.loads(resp.read().decode() or "{}")
69
+ except urllib.error.HTTPError as e:
70
+ text = e.read().decode()
71
+ try:
72
+ return e.code, json.loads(text)
73
+ except json.JSONDecodeError:
74
+ return e.code, {"raw": text}
75
+
76
+
77
+ def _size_to_aspect(size: str):
78
+ """Map a WxH size to the closest Imagen aspectRatio bucket (or None)."""
79
+ if not size or "x" not in size.lower():
80
+ return None
81
+ try:
82
+ w, h = (int(x) for x in size.lower().split("x"))
83
+ except ValueError:
84
+ return None
85
+ if w == h:
86
+ return "1:1"
87
+ return "4:3" if w > h else "3:4"
88
+
89
+
90
+ def gen_openai(key: str, model: str, prompt: str, size: str) -> bytes:
91
+ payload = {"model": model, "prompt": prompt, "n": 1, "size": size}
92
+ status, res = _http_post("https://api.openai.com/v1/images/generations",
93
+ payload, {"Authorization": f"Bearer {key}"})
94
+ if status != 200:
95
+ fail(f"OpenAI image API error ({status}): {(res.get('error') or {}).get('message') or res}")
96
+ 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"])
100
+
101
+
102
+ def gen_gemini_imagen(key: str, model: str, prompt: str, size: str) -> bytes:
103
+ url = f"https://generativelanguage.googleapis.com/{GEMINI_API_VERSION}/models/{model}:predict"
104
+ params = {"sampleCount": 1}
105
+ aspect = _size_to_aspect(size)
106
+ if aspect:
107
+ params["aspectRatio"] = aspect
108
+ payload = {"instances": [{"prompt": prompt}], "parameters": params}
109
+ status, res = _http_post(url, payload, {"x-goog-api-key": key})
110
+ if status != 200:
111
+ fail(f"Gemini Imagen API error ({status}): {(res.get('error') or {}).get('message') or res}")
112
+ preds = res.get("predictions") or []
113
+ for p in preds:
114
+ if p.get("bytesBase64Encoded"):
115
+ return base64.b64decode(p["bytesBase64Encoded"])
116
+ reasons = [p.get("raiFilteredReason") for p in preds if p.get("raiFilteredReason")]
117
+ fail("Gemini Imagen returned no image"
118
+ + (f" (filtered: {reasons})" if reasons else f": {res}"))
119
+
120
+
121
+ def gen_gemini_content(key: str, model: str, prompt: str) -> bytes:
122
+ url = f"https://generativelanguage.googleapis.com/{GEMINI_API_VERSION}/models/{model}:generateContent"
123
+ payload = {
124
+ "contents": [{"parts": [{"text": prompt}]}],
125
+ "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
126
+ }
127
+ status, res = _http_post(url, payload, {"x-goog-api-key": key})
128
+ if status != 200:
129
+ fail(f"Gemini API error ({status}): {(res.get('error') or {}).get('message') or res}")
130
+ for cand in res.get("candidates") or []:
131
+ for part in (cand.get("content") or {}).get("parts") or []:
132
+ inline = part.get("inlineData") or part.get("inline_data") or {}
133
+ if inline.get("data"):
134
+ return base64.b64decode(inline["data"])
135
+ finish = (res.get("candidates") or [{}])[0].get("finishReason")
136
+ fail(f"Gemini returned no image (finishReason={finish}): {res}")
137
+
138
+
139
+ def main() -> None:
140
+ parser = argparse.ArgumentParser(
141
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
142
+ parser.add_argument("--provider", required=True, choices=["openai", "gemini"])
143
+ parser.add_argument("--prompt", required=True)
144
+ parser.add_argument("--out", required=True, type=Path)
145
+ parser.add_argument("--size", default="1024x1024",
146
+ help="OpenAI: literal size; Gemini Imagen: mapped to aspectRatio")
147
+ parser.add_argument("--model", help="override the provider's default model")
148
+ parser.add_argument("--dry-run", action="store_true",
149
+ help="validate args + key presence without calling the API (no cost)")
150
+ args = parser.parse_args()
151
+
152
+ model = args.model or DEFAULT_MODEL[args.provider]
153
+ key = resolve_key(args.provider)
154
+ if not key:
155
+ fail(f"missing API key — set {ENV_VAR[args.provider]} in your environment, "
156
+ f'or add "imageKeys": {{"{args.provider}": "..."}} to {CRED_PATH}')
157
+
158
+ if args.dry_run:
159
+ print(f"[dry-run] provider={args.provider} model={model} size={args.size} "
160
+ f"out={args.out} key=set — no API call made (no cost).")
161
+ return
162
+
163
+ if args.provider == "openai":
164
+ img = gen_openai(key, model, args.prompt, args.size)
165
+ elif model.startswith("imagen"):
166
+ img = gen_gemini_imagen(key, model, args.prompt, args.size)
167
+ else:
168
+ img = gen_gemini_content(key, model, args.prompt)
169
+
170
+ args.out.parent.mkdir(parents=True, exist_ok=True)
171
+ args.out.write_bytes(img)
172
+ print(f"Wrote {args.out} ({len(img):,} bytes) via {args.provider}/{model}")
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """Memory Toast login helper for the make-card skill.
3
+
4
+ Commands:
5
+ login Prompt for email + password, exchange them for a refresh token, store it.
6
+ token Log in by pasting a token copied from the app — for Google/Facebook users
7
+ who have no password (app → Settings → Copy upload token).
8
+ whoami Show the logged-in account (prints no secrets).
9
+ logout Delete the stored credentials.
10
+
11
+ Secrets are read interactively (never echoed) and never written to disk; only the
12
+ rotating 7-day refresh token is stored, in ~/.memory-toast/credentials.json
13
+ (chmod 600). Run `login` (or `token`) once, then `upload_pack.py` refreshes automatically.
14
+
15
+ Examples:
16
+ python3 scripts/mt_login.py # same as `login`
17
+ python3 scripts/mt_login.py token # paste a token (social-login users)
18
+ python3 scripts/mt_login.py whoami
19
+ python3 scripts/mt_login.py logout
20
+ python3 scripts/mt_login.py --api https://my-server.example.com login
21
+ """
22
+
23
+ import argparse
24
+ import getpass
25
+
26
+ import _mt_auth as auth
27
+
28
+
29
+ def cmd_login(args) -> None:
30
+ api = auth.resolve_api_url(args.api)
31
+ print(f"Memory Toast — {api}")
32
+ email = input("Email: ").strip()
33
+ if not email:
34
+ auth.fail("email is required")
35
+ password = getpass.getpass("Password: ")
36
+ if not password:
37
+ auth.fail("password is required")
38
+ res = auth.login(api, email, password)
39
+ stored_email = res.get("user", {}).get("email", email)
40
+ auth.save_credentials({
41
+ "apiUrl": api,
42
+ "email": stored_email,
43
+ "refreshToken": res["refreshToken"],
44
+ })
45
+ print(f"Logged in as {stored_email}.")
46
+ print(f"Credentials saved to {auth.CRED_PATH} (chmod 600). "
47
+ "Token stays valid as long as you upload at least once every 7 days.")
48
+
49
+
50
+ def cmd_token(args) -> None:
51
+ api = auth.resolve_api_url(args.api)
52
+ print(f"Memory Toast — {api}")
53
+ token = args.token or getpass.getpass(
54
+ "Paste your upload token (app → Settings → Copy upload token): ")
55
+ token = token.strip()
56
+ if not token:
57
+ auth.fail("no token provided")
58
+ email = auth.store_refresh_token(api, token)
59
+ print(f"Logged in as {email} (via pasted token).")
60
+ print(f"Credentials saved to {auth.CRED_PATH} (chmod 600). "
61
+ "Token stays valid as long as you upload at least once every 7 days.")
62
+
63
+
64
+ def cmd_whoami(args) -> None:
65
+ creds = auth.load_credentials()
66
+ if not creds.get("refreshToken"):
67
+ print("Not logged in. Run: python3 scripts/mt_login.py login")
68
+ return
69
+ print(f"Logged in as {creds.get('email', '(unknown)')}")
70
+ print(f"Server: {creds.get('apiUrl', auth.DEFAULT_API_URL)}")
71
+
72
+
73
+ def cmd_logout(args) -> None:
74
+ if auth.clear_credentials():
75
+ print(f"Logged out — removed {auth.CRED_PATH}")
76
+ else:
77
+ print("Already logged out (no stored credentials).")
78
+
79
+
80
+ def main() -> None:
81
+ base = argparse.ArgumentParser(add_help=False)
82
+ base.add_argument("--api", help="override API base URL")
83
+ parser = argparse.ArgumentParser(
84
+ description=__doc__, parents=[base],
85
+ formatter_class=argparse.RawDescriptionHelpFormatter)
86
+ sub = parser.add_subparsers(dest="command")
87
+ sub.add_parser("login", parents=[base], help="log in and store a refresh token")
88
+ p_token = sub.add_parser("token", parents=[base],
89
+ help="log in by pasting a token from the app (social-login users)")
90
+ p_token.add_argument("--token", help="the token value (omit to be prompted, hidden)")
91
+ sub.add_parser("whoami", parents=[base], help="show the logged-in account")
92
+ sub.add_parser("logout", parents=[base], help="delete stored credentials")
93
+ args = parser.parse_args()
94
+ handler = {"login": cmd_login, "token": cmd_token,
95
+ "whoami": cmd_whoami, "logout": cmd_logout}
96
+ handler[args.command or "login"](args)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python3
2
+ """Build a Memory Toast deck ZIP pack from a deck directory and upload it via the API.
3
+
4
+ Zero third-party deps — Python 3.9+ stdlib only.
5
+
6
+ Usage:
7
+ upload_pack.py DECK_DIR [--dry-run] [--deck-id ID] [--local-version N] [--force]
8
+ [--api URL]
9
+
10
+ DECK_DIR must contain a `deck.json` (see references/pack-format.md for the schema).
11
+ Media files referenced by sections live anywhere under DECK_DIR (relative paths).
12
+
13
+ Auth: uses the refresh token stored by `mt_login.py` — no password needed. Run
14
+ `python3 scripts/mt_login.py` once before your first upload.
15
+
16
+ Modes:
17
+ --dry-run Build + validate the ZIP at DECK_DIR/build/pack.zip, no network.
18
+ (default) Create a NEW deck on the server and upload the pack.
19
+ --deck-id ID Upload to an EXISTING deck instead of creating one.
20
+ Requires --local-version N (current server pack version) or --force.
21
+ """
22
+
23
+ import argparse
24
+ import hashlib
25
+ import json
26
+ import mimetypes
27
+ import uuid
28
+ import zipfile
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ from _mt_auth import api_call, fail, get_access_token, resolve_api_url
33
+
34
+ MAX_ZIP_BYTES = 100 * 1024 * 1024 # server-side limit in validators/sync.ts
35
+
36
+ ALLOWED_EXTS = {
37
+ "image": {".jpg", ".jpeg", ".png", ".gif", ".webp"},
38
+ "audio": {".mp3", ".m4a", ".wav", ".aac", ".ogg"},
39
+ "video": {".mp4", ".mov", ".webm"},
40
+ }
41
+ EXTRA_MIME = {
42
+ ".m4a": "audio/mp4",
43
+ ".aac": "audio/aac",
44
+ ".ogg": "audio/ogg",
45
+ ".mov": "video/quicktime",
46
+ ".webm": "video/webm",
47
+ ".webp": "image/webp",
48
+ }
49
+
50
+
51
+ def guess_mime(ext: str) -> str:
52
+ if ext in EXTRA_MIME:
53
+ return EXTRA_MIME[ext]
54
+ mime, _ = mimetypes.guess_type(f"f{ext}")
55
+ return mime or "application/octet-stream"
56
+
57
+
58
+ def normalize_section(sec: dict, deck_dir: Path, where: str, errors: list, media: dict) -> dict:
59
+ """Validate one section from deck.json and convert it to the cards.json shape.
60
+
61
+ Local files get a generated section id and a `media/{id}{ext}` storageRef;
62
+ the file is registered in `media` (zip path -> absolute path).
63
+ """
64
+ kind = sec.get("kind")
65
+ if kind not in ("image", "audio", "video"):
66
+ errors.append(f"{where}: kind must be image|audio|video, got {kind!r}")
67
+ return {}
68
+ sec_id = str(uuid.uuid4())
69
+ caption = sec.get("caption")
70
+ has_file = bool(sec.get("file"))
71
+ has_url = bool(sec.get("url"))
72
+ if has_file == has_url:
73
+ errors.append(f"{where}: exactly one of 'file' or 'url' is required")
74
+ return {}
75
+
76
+ if has_url:
77
+ url = sec["url"]
78
+ if not url.startswith(("http://", "https://")):
79
+ errors.append(f"{where}: url must be http(s), got {url!r}")
80
+ return {}
81
+ storage_kind, storage_ref, mime = "external", url, None
82
+ else:
83
+ rel = sec["file"]
84
+ src = (deck_dir / rel).resolve()
85
+ if not src.is_file():
86
+ errors.append(f"{where}: file not found: {rel}")
87
+ return {}
88
+ ext = src.suffix.lower()
89
+ if ext not in ALLOWED_EXTS[kind]:
90
+ errors.append(f"{where}: extension {ext!r} not allowed for kind {kind!r}")
91
+ return {}
92
+ storage_kind = "local"
93
+ storage_ref = f"media/{sec_id}{ext}"
94
+ mime = guess_mime(ext)
95
+ media[storage_ref] = src
96
+
97
+ return {
98
+ "id": sec_id,
99
+ "kind": kind,
100
+ "storageKind": storage_kind,
101
+ "storageRef": storage_ref,
102
+ "caption": caption if caption else None,
103
+ "mimeType": mime,
104
+ "durationMs": sec.get("durationMs"),
105
+ }
106
+
107
+
108
+ def build_pack(deck_dir: Path) -> dict:
109
+ deck_json_path = deck_dir / "deck.json"
110
+ if not deck_json_path.is_file():
111
+ fail(f"missing {deck_json_path}")
112
+ try:
113
+ deck = json.loads(deck_json_path.read_text())
114
+ except json.JSONDecodeError as e:
115
+ fail(f"deck.json is not valid JSON: {e}")
116
+
117
+ errors: list = []
118
+ title = deck.get("title", "")
119
+ if not isinstance(title, str) or not (1 <= len(title) <= 200):
120
+ errors.append("title is required (1-200 chars)")
121
+ description = deck.get("description", "")
122
+ if len(description) > 1000:
123
+ errors.append("description exceeds 1000 chars")
124
+ tags = deck.get("tags", [])
125
+ if not isinstance(tags, list) or len(tags) > 10:
126
+ errors.append("tags must be a list of at most 10 strings")
127
+ cards_in = deck.get("cards")
128
+ if not isinstance(cards_in, list) or not cards_in:
129
+ fail("deck.json must contain a non-empty 'cards' array")
130
+
131
+ media: dict = {}
132
+ cards_out = []
133
+ for i, card in enumerate(cards_in):
134
+ where = f"cards[{i}]"
135
+ front = card.get("frontContent", "")
136
+ back = card.get("backContent", "")
137
+ if not isinstance(front, str) or not isinstance(back, str):
138
+ errors.append(f"{where}: frontContent/backContent must be strings")
139
+ continue
140
+ front_secs, back_secs = [], []
141
+ for side, key, out in (("front", "frontSections", front_secs), ("back", "backSections", back_secs)):
142
+ for j, sec in enumerate(card.get(key) or []):
143
+ norm = normalize_section(sec, deck_dir, f"{where}.{key}[{j}]", errors, media)
144
+ if norm:
145
+ norm["position"] = len(out)
146
+ out.append(norm)
147
+ if not front and not front_secs:
148
+ errors.append(f"{where}: card front is empty (no text, no sections)")
149
+ if not back and not back_secs:
150
+ errors.append(f"{where}: card back is empty (no text, no sections)")
151
+ cards_out.append({
152
+ "id": str(uuid.uuid4()),
153
+ "position": i,
154
+ "frontContent": front,
155
+ "backContent": back,
156
+ "frontSections": front_secs,
157
+ "backSections": back_secs,
158
+ })
159
+
160
+ if errors:
161
+ fail("deck.json validation failed:\n - " + "\n - ".join(errors))
162
+
163
+ manifest = {
164
+ "schemaVersion": 1,
165
+ "deckTitle": title,
166
+ "description": description,
167
+ "language": deck.get("language", "zh-TW"),
168
+ "tags": tags,
169
+ "cardCount": len(cards_out),
170
+ "createdAt": datetime.now(timezone.utc).isoformat(),
171
+ "builtBy": "memory-toast-make-card",
172
+ }
173
+
174
+ build_dir = deck_dir / "build"
175
+ build_dir.mkdir(exist_ok=True)
176
+ zip_path = build_dir / "pack.zip"
177
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
178
+ zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
179
+ zf.writestr("cards.json", json.dumps({"schemaVersion": 1, "cards": cards_out}, ensure_ascii=False, indent=2))
180
+ for zip_name, src in media.items():
181
+ zf.write(src, zip_name)
182
+
183
+ data = zip_path.read_bytes()
184
+ if len(data) > MAX_ZIP_BYTES:
185
+ fail(f"ZIP is {len(data)} bytes — exceeds the 100MB server limit")
186
+ return {
187
+ "zip_path": zip_path,
188
+ "size": len(data),
189
+ "sha256": hashlib.sha256(data).hexdigest(),
190
+ "card_count": len(cards_out),
191
+ "media_count": len(media),
192
+ "title": title,
193
+ "description": description,
194
+ "tags": tags,
195
+ }
196
+
197
+
198
+ def main() -> None:
199
+ parser = argparse.ArgumentParser(
200
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
201
+ parser.add_argument("deck_dir", type=Path)
202
+ parser.add_argument("--dry-run", action="store_true")
203
+ parser.add_argument("--deck-id", help="upload to an existing deck instead of creating one")
204
+ parser.add_argument("--local-version", type=int, default=0,
205
+ help="current server pack version of the deck (for --deck-id updates)")
206
+ parser.add_argument("--force", action="store_true", help="overwrite server pack on version conflict")
207
+ parser.add_argument("--api", help="override API base URL")
208
+ args = parser.parse_args()
209
+
210
+ deck_dir = args.deck_dir.resolve()
211
+ if not deck_dir.is_dir():
212
+ fail(f"deck dir not found: {deck_dir}")
213
+
214
+ pack = build_pack(deck_dir)
215
+ print(f"Built {pack['zip_path']}")
216
+ print(f" cards: {pack['card_count']} media: {pack['media_count']} "
217
+ f"size: {pack['size']:,} bytes sha256: {pack['sha256']}")
218
+ if args.dry_run:
219
+ print("Dry run — not uploading.")
220
+ return
221
+
222
+ api = resolve_api_url(args.api)
223
+ token = get_access_token(api)
224
+ print(f"Authenticated to {api}")
225
+
226
+ if args.deck_id:
227
+ deck_id = args.deck_id
228
+ else:
229
+ body = {"title": pack["title"]}
230
+ if pack["description"]:
231
+ body["description"] = pack["description"]
232
+ if pack["tags"]:
233
+ body["tags"] = pack["tags"]
234
+ status, res = api_call("POST", f"{api}/api/v1/decks", body, token)
235
+ if status != 201:
236
+ fail(f"create deck failed ({status}): {res}")
237
+ deck_id = res["deck"]["id"]
238
+ print(f"Created deck {deck_id} ({pack['title']})")
239
+
240
+ sync_url = f"{api}/api/v1/decks/{deck_id}/sync"
241
+ if args.force:
242
+ sync_url += "?force=true"
243
+ status, res = api_call("POST", sync_url, {
244
+ "localVersion": args.local_version,
245
+ "size": pack["size"],
246
+ "sha256": pack["sha256"],
247
+ "cardCount": pack["card_count"],
248
+ }, token)
249
+ if status == 409:
250
+ fail(
251
+ f"version conflict: server is at version {res.get('serverVersion')}, "
252
+ f"you sent {res.get('localVersion')}.\n"
253
+ f"Re-run with --local-version {res.get('serverVersion')} (or --force to overwrite)."
254
+ )
255
+ if status != 200:
256
+ fail(f"sync start failed ({status}): {res}")
257
+ upload_url, pack_id, r2_key = res["uploadUrl"], res["packId"], res["r2Key"]
258
+
259
+ print(f"Uploading {pack['size']:,} bytes to R2…")
260
+ status, res = api_call("PUT", upload_url, raw=pack["zip_path"].read_bytes())
261
+ if status not in (200, 201):
262
+ fail(f"R2 upload failed ({status}): {res}")
263
+
264
+ status, res = api_call("POST", f"{api}/api/v1/packs/{pack_id}/commit", {
265
+ "expectedSize": pack["size"],
266
+ "sha256": pack["sha256"],
267
+ "cardCount": pack["card_count"],
268
+ "r2Key": r2_key,
269
+ }, token)
270
+ if status != 200:
271
+ fail(f"commit failed ({status}): {res}")
272
+
273
+ version = res.get("pack", {}).get("version")
274
+ print(f"Done. deck={deck_id} pack={pack_id} version={version}")
275
+ print("Open the app deck list and pull the deck to see it on device.")
276
+
277
+
278
+ if __name__ == "__main__":
279
+ main()