memory-toast-make-card 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-toast-make-card",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code skill to build Memory Toast flashcard decks (卡包) — with optional AI image generation using your own OpenAI/Gemini key — and upload, update, or publish them to your Memory Toast account.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"memory-toast-make-card": "bin/install.js"
|
|
@@ -73,7 +73,7 @@ pack.zip
|
|
|
73
73
|
|
|
74
74
|
Rules:
|
|
75
75
|
|
|
76
|
-
- `id` is
|
|
76
|
+
- `id` is a UUID v4. **On re-upload of the same deck, `upload_pack.py` matches each card against the previous build (`build/pack.zip`) by content and reuses the existing id for unchanged cards** (including back-only edits, e.g. adding an example) — only new cards or cards whose front text changed get a fresh UUID. This keeps the user's study progress across deck updates (the app keys study progress by card id and prunes orphaned progress on pull). `position` starts at 0 and increments by array order.
|
|
77
77
|
- For `storageKind: local`, `storageRef` must be `media/{sectionId}.{ext}` and the ZIP must
|
|
78
78
|
contain the matching file.
|
|
79
79
|
- `storageKind: external` is for YouTube/Vimeo and similar links; `storageRef` is the full URL.
|
|
@@ -261,7 +261,7 @@ with the local one and offers the pull when the server is newer and no local edi
|
|
|
261
261
|
|
|
262
262
|
| Item | Limit |
|
|
263
263
|
|------|-------|
|
|
264
|
-
| ZIP size | ≤
|
|
264
|
+
| ZIP size | ≤ 300 MB |
|
|
265
265
|
| title | 1–200 characters |
|
|
266
266
|
| description | ≤ 1000 characters |
|
|
267
267
|
| tags | ≤ 10 |
|
|
@@ -242,6 +242,101 @@ def test_empty_check_uses_plain_text():
|
|
|
242
242
|
raise AssertionError("expected validation failure for empty stripped front")
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Stable card ids across rebuilds — so updating a deck preserves the user's
|
|
247
|
+
# study progress. The app keys study progress by card id and prunes orphans on
|
|
248
|
+
# pull, so a re-upload that re-mints ids would wipe progress. Mirrors the Dart
|
|
249
|
+
# content match in features/decks/data/services/progress_remap.dart.
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _build_in(deck_dir, cards):
|
|
253
|
+
"""Build a pack inside a PERSISTENT dir, so a second build can reuse ids
|
|
254
|
+
from the first build's pack.zip. Returns the built cards.json list."""
|
|
255
|
+
deck = {"title": "T", "description": "", "tags": [], "cards": cards}
|
|
256
|
+
(deck_dir / "deck.json").write_text(json.dumps(deck))
|
|
257
|
+
info = up.build_pack(deck_dir)
|
|
258
|
+
import zipfile
|
|
259
|
+
|
|
260
|
+
with zipfile.ZipFile(info["zip_path"]) as zf:
|
|
261
|
+
return json.loads(zf.read("cards.json"))["cards"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_assign_stable_ids_reuses_unchanged_card_ids():
|
|
265
|
+
prior = [
|
|
266
|
+
{"id": "id-A", "frontContent": "apple", "backContent": "def-A"},
|
|
267
|
+
{"id": "id-B", "frontContent": "banana", "backContent": "def-B"},
|
|
268
|
+
]
|
|
269
|
+
new = [
|
|
270
|
+
{"frontContent": "banana", "backContent": "def-B"},
|
|
271
|
+
{"frontContent": "apple", "backContent": "def-A"},
|
|
272
|
+
]
|
|
273
|
+
up.assign_stable_ids(new, prior)
|
|
274
|
+
assert new[0]["id"] == "id-B" # matched by content, order-independent
|
|
275
|
+
assert new[1]["id"] == "id-A"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_assign_stable_ids_back_edit_keeps_id():
|
|
279
|
+
prior = [{"id": "id-A", "frontContent": "apple", "backContent": "one"}]
|
|
280
|
+
new = [{"frontContent": "apple", "backContent": "one; two"}]
|
|
281
|
+
up.assign_stable_ids(new, prior)
|
|
282
|
+
assert new[0]["id"] == "id-A" # front-stable match survives a back edit
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_assign_stable_ids_new_card_gets_fresh_id():
|
|
286
|
+
prior = [{"id": "id-A", "frontContent": "apple", "backContent": "def-A"}]
|
|
287
|
+
new = [
|
|
288
|
+
{"frontContent": "apple", "backContent": "def-A"},
|
|
289
|
+
{"frontContent": "cherry", "backContent": "def-C"},
|
|
290
|
+
]
|
|
291
|
+
up.assign_stable_ids(new, prior)
|
|
292
|
+
assert new[0]["id"] == "id-A"
|
|
293
|
+
assert new[1]["id"] and new[1]["id"] != "id-A"
|
|
294
|
+
assert len(new[1]["id"]) >= 32 # a real uuid, not a placeholder
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_assign_stable_ids_front_change_gets_fresh_id():
|
|
298
|
+
prior = [{"id": "id-A", "frontContent": "apple", "backContent": "same"}]
|
|
299
|
+
new = [{"frontContent": "orange", "backContent": "same"}]
|
|
300
|
+
up.assign_stable_ids(new, prior)
|
|
301
|
+
assert new[0]["id"] != "id-A"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_assign_stable_ids_duplicate_front_disambiguated_by_back():
|
|
305
|
+
prior = [
|
|
306
|
+
{"id": "id-1", "frontContent": "dup", "backContent": "b1"},
|
|
307
|
+
{"id": "id-2", "frontContent": "dup", "backContent": "b2"},
|
|
308
|
+
]
|
|
309
|
+
new = [
|
|
310
|
+
{"frontContent": "dup", "backContent": "b2"},
|
|
311
|
+
{"frontContent": "dup", "backContent": "b1"},
|
|
312
|
+
]
|
|
313
|
+
up.assign_stable_ids(new, prior)
|
|
314
|
+
assert new[0]["id"] == "id-2"
|
|
315
|
+
assert new[1]["id"] == "id-1"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_build_pack_keeps_card_ids_stable_across_rebuilds():
|
|
319
|
+
with tempfile.TemporaryDirectory() as d:
|
|
320
|
+
deck_dir = Path(d)
|
|
321
|
+
v1 = _build_in(deck_dir, [
|
|
322
|
+
{"frontContent": "apple", "backContent": "a fruit"},
|
|
323
|
+
{"frontContent": "banana", "backContent": "yellow"},
|
|
324
|
+
])
|
|
325
|
+
ids_v1 = {c["frontContent"]: c["id"] for c in v1}
|
|
326
|
+
|
|
327
|
+
# Rebuild: edit banana's back, append cherry; apple untouched.
|
|
328
|
+
v2 = _build_in(deck_dir, [
|
|
329
|
+
{"frontContent": "apple", "backContent": "a fruit"},
|
|
330
|
+
{"frontContent": "banana", "backContent": "yellow; a fruit"},
|
|
331
|
+
{"frontContent": "cherry", "backContent": "red"},
|
|
332
|
+
])
|
|
333
|
+
ids_v2 = {c["frontContent"]: c["id"] for c in v2}
|
|
334
|
+
|
|
335
|
+
assert ids_v2["apple"] == ids_v1["apple"], "unchanged card kept its id"
|
|
336
|
+
assert ids_v2["banana"] == ids_v1["banana"], "back-edit kept the id"
|
|
337
|
+
assert ids_v2["cherry"] not in ids_v1.values(), "new card got a fresh id"
|
|
338
|
+
|
|
339
|
+
|
|
245
340
|
def main():
|
|
246
341
|
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
247
342
|
passed = 0
|
package/scripts/upload_pack.py
CHANGED
|
@@ -41,7 +41,7 @@ from pathlib import Path
|
|
|
41
41
|
|
|
42
42
|
from _mt_auth import api_call, fail, get_access_token, resolve_api_url
|
|
43
43
|
|
|
44
|
-
MAX_ZIP_BYTES =
|
|
44
|
+
MAX_ZIP_BYTES = 300 * 1024 * 1024 # server-side limit in validators/sync.ts
|
|
45
45
|
DECK_RECORD = ".memory-toast.json" # per-deck AI state file (deckId, version, libraryPackId, …)
|
|
46
46
|
|
|
47
47
|
ALLOWED_EXTS = {
|
|
@@ -521,6 +521,68 @@ def normalize_blocks(blocks: list, deck_dir: Path, where: str, errors: list, med
|
|
|
521
521
|
return blocks_out, plain, html, sections
|
|
522
522
|
|
|
523
523
|
|
|
524
|
+
def _read_prior_cards(deck_dir):
|
|
525
|
+
"""Cards from the previous local build (deck_dir/build/pack.zip), used to
|
|
526
|
+
reuse card ids on rebuild. Empty when there's no prior pack or it can't be
|
|
527
|
+
read — the app's pull-time content match is the backstop in that case."""
|
|
528
|
+
zip_path = Path(deck_dir) / "build" / "pack.zip"
|
|
529
|
+
if not zip_path.exists():
|
|
530
|
+
return []
|
|
531
|
+
try:
|
|
532
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
533
|
+
data = json.loads(zf.read("cards.json").decode("utf-8"))
|
|
534
|
+
except Exception:
|
|
535
|
+
return []
|
|
536
|
+
return [
|
|
537
|
+
{
|
|
538
|
+
"id": c.get("id"),
|
|
539
|
+
"frontContent": c.get("frontContent", ""),
|
|
540
|
+
"backContent": c.get("backContent", ""),
|
|
541
|
+
}
|
|
542
|
+
for c in data.get("cards", [])
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def assign_stable_ids(new_cards, prior_cards):
|
|
547
|
+
"""Assign each new card an id, reusing the prior build's id when the card is
|
|
548
|
+
'the same', so a re-upload preserves study progress (the app keys progress
|
|
549
|
+
by card id). Tiered, consuming match against the prior build:
|
|
550
|
+
1. exact (frontContent, backContent)
|
|
551
|
+
2. frontContent alone (survives back-side edits, e.g. adding an example)
|
|
552
|
+
A card is reused only when its key is UNIQUE on both sides; ambiguous or
|
|
553
|
+
unmatched new cards get a fresh uuid4. Mirrors computeProgressRemap() in the
|
|
554
|
+
Flutter app. Mutates `new_cards` in place; returns it."""
|
|
555
|
+
matched = {} # new index -> reused prior id
|
|
556
|
+
rem_old = list(range(len(prior_cards)))
|
|
557
|
+
rem_new = list(range(len(new_cards)))
|
|
558
|
+
|
|
559
|
+
def run_pass(key_of):
|
|
560
|
+
old_keys, new_keys = {}, {}
|
|
561
|
+
for oi in rem_old:
|
|
562
|
+
old_keys.setdefault(key_of(prior_cards[oi]), []).append(oi)
|
|
563
|
+
for ni in rem_new:
|
|
564
|
+
new_keys.setdefault(key_of(new_cards[ni]), []).append(ni)
|
|
565
|
+
used_old, used_new = set(), set()
|
|
566
|
+
for ni in rem_new:
|
|
567
|
+
key = key_of(new_cards[ni])
|
|
568
|
+
olds, news = old_keys.get(key, []), new_keys.get(key, [])
|
|
569
|
+
if len(olds) == 1 and len(news) == 1:
|
|
570
|
+
matched[ni] = prior_cards[olds[0]].get("id")
|
|
571
|
+
used_old.add(olds[0])
|
|
572
|
+
used_new.add(ni)
|
|
573
|
+
rem_old[:] = [i for i in rem_old if i not in used_old]
|
|
574
|
+
rem_new[:] = [i for i in rem_new if i not in used_new]
|
|
575
|
+
|
|
576
|
+
if prior_cards:
|
|
577
|
+
run_pass(lambda c: (c.get("frontContent", ""), c.get("backContent", "")))
|
|
578
|
+
run_pass(lambda c: c.get("frontContent", ""))
|
|
579
|
+
|
|
580
|
+
for ni, card in enumerate(new_cards):
|
|
581
|
+
reused = matched.get(ni)
|
|
582
|
+
card["id"] = reused if reused else str(uuid.uuid4())
|
|
583
|
+
return new_cards
|
|
584
|
+
|
|
585
|
+
|
|
524
586
|
def build_pack(deck_dir: Path) -> dict:
|
|
525
587
|
deck_json_path = deck_dir / "deck.json"
|
|
526
588
|
if not deck_json_path.is_file():
|
|
@@ -591,7 +653,10 @@ def build_pack(deck_dir: Path) -> dict:
|
|
|
591
653
|
if not back and not back_secs:
|
|
592
654
|
errors.append(f"{where}: card back is empty (no text, no sections)")
|
|
593
655
|
card_out = {
|
|
594
|
-
|
|
656
|
+
# Assigned after the loop by assign_stable_ids(): reused from the
|
|
657
|
+
# previous build when the card is unchanged, so a re-upload keeps
|
|
658
|
+
# the user's study progress.
|
|
659
|
+
"id": None,
|
|
595
660
|
"position": i,
|
|
596
661
|
"frontContent": front,
|
|
597
662
|
"backContent": back,
|
|
@@ -611,6 +676,12 @@ def build_pack(deck_dir: Path) -> dict:
|
|
|
611
676
|
if errors:
|
|
612
677
|
fail("deck.json validation failed:\n - " + "\n - ".join(errors))
|
|
613
678
|
|
|
679
|
+
# Reuse card ids from the previous build for unchanged cards (and cards
|
|
680
|
+
# whose front is unchanged) so a re-upload preserves the user's study
|
|
681
|
+
# progress — the app keys progress by card id and prunes orphans on pull.
|
|
682
|
+
# New / changed-front cards get a fresh uuid.
|
|
683
|
+
assign_stable_ids(cards_out, _read_prior_cards(deck_dir))
|
|
684
|
+
|
|
614
685
|
manifest = {
|
|
615
686
|
"schemaVersion": 1,
|
|
616
687
|
"deckTitle": title,
|