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 +21 -0
- package/README.md +111 -0
- package/SKILL.md +113 -0
- package/bin/install.js +74 -0
- package/package.json +30 -0
- package/references/pack-format.md +168 -0
- package/scripts/_mt_auth.py +152 -0
- package/scripts/gen_image.py +176 -0
- package/scripts/mt_login.py +100 -0
- package/scripts/upload_pack.py +279 -0
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()
|