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.5.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 always a UUID v4; `position` starts at 0 and increments by array order.
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 | ≤ 200 MB |
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
@@ -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 = 200 * 1024 * 1024 # server-side limit in validators/sync.ts
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
- "id": str(uuid.uuid4()),
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,