@topy-ai/maggie 0.7.52 → 0.7.54

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.
Files changed (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -20,6 +20,10 @@ from urllib.request import Request, urlopen
20
20
 
21
21
 
22
22
  DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
23
+ # Both hosts accept the same anonymous contract and de-duplicate on feedbackId,
24
+ # so a draft is offered to the next one only when the previous host failed
25
+ # before storing it (network error or a 5xx such as Cloudflare's 52x).
26
+ DEFAULT_ENDPOINTS = (DEFAULT_ENDPOINT, "https://feedback.cmo.so/api/feedback")
23
27
  SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
24
28
  LOCAL_PATH_RE = re.compile(
25
29
  r"(?<![A-Za-z0-9])(?:~/(?:[^\s,;:)\]}]+/)*[^\s,;:)\]}]+|"
@@ -348,28 +352,52 @@ def hosted_payload(data: dict) -> dict:
348
352
  return payload
349
353
 
350
354
 
355
+ def submit_endpoints(value: object) -> list[str]:
356
+ endpoints = [value] if isinstance(value, str) else list(value or DEFAULT_ENDPOINTS)
357
+ result = []
358
+ for endpoint in endpoints:
359
+ endpoint = str(endpoint).rstrip("/")
360
+ parsed = urlparse(endpoint)
361
+ if parsed.scheme != "https" or not parsed.netloc:
362
+ raise ValueError("endpoint must be an absolute HTTPS URL")
363
+ if endpoint not in result:
364
+ result.append(endpoint)
365
+ return result
366
+
367
+
351
368
  def submit(args: argparse.Namespace) -> int:
352
369
  if not args.confirm:
353
370
  raise ValueError("submit requires --confirm; feedback is never sent implicitly")
354
371
  path = Path(args.feedback)
355
372
  data = read_feedback(path)
356
- endpoint = args.endpoint.rstrip("/")
357
- parsed = urlparse(endpoint)
358
- if parsed.scheme != "https" or not parsed.netloc:
359
- raise ValueError("endpoint must be an absolute HTTPS URL")
373
+ endpoints = submit_endpoints(args.endpoint)
360
374
  payload = hosted_payload(data)
361
375
  payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
362
- request = Request(endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
363
- try:
364
- with urlopen(request, timeout=20) as response:
365
- raw = response.read(1_000_000).decode("utf-8", "replace")
366
- result = json.loads(raw) if raw else {}
367
- except (HTTPError, URLError, TimeoutError) as error:
368
- raise RuntimeError(f"feedback submission failed: {error}") from error
376
+ body = json.dumps(payload, ensure_ascii=False).encode()
377
+ failures = []
378
+ for endpoint in endpoints:
379
+ request = Request(endpoint, data=body, headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
380
+ try:
381
+ with urlopen(request, timeout=20) as response:
382
+ raw = response.read(1_000_000).decode("utf-8", "replace")
383
+ result = json.loads(raw) if raw else {}
384
+ break
385
+ except HTTPError as error:
386
+ failures.append({"endpoint": endpoint, "error": f"HTTP {error.code}"})
387
+ # A 4xx is the host rejecting this draft; another host would too.
388
+ if error.code < 500:
389
+ raise RuntimeError(f"feedback submission failed: {endpoint} answered HTTP {error.code}") from error
390
+ except (URLError, TimeoutError, OSError) as error:
391
+ failures.append({"endpoint": endpoint, "error": safe_text(getattr(error, "reason", error))})
392
+ else:
393
+ tried = "; ".join(f"{item['endpoint']} ({item['error']})" for item in failures)
394
+ raise RuntimeError(f"feedback submission failed on every endpoint: {tried}")
369
395
  ack = acknowledgement(result)
370
396
  data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
397
+ if failures:
398
+ data["submission"]["failedEndpoints"] = failures
371
399
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
372
- print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
400
+ print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "endpoint": endpoint, "failedEndpoints": failures, "acknowledgement": ack}, indent=2, ensure_ascii=False))
373
401
  return 0
374
402
 
375
403
 
@@ -459,6 +487,12 @@ def collect_batch(args: argparse.Namespace) -> int:
459
487
  return 0
460
488
 
461
489
 
490
+ AREA_SUBJECT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9 /&._-]{0,30}):\s+(\S.*)$")
491
+ SCOPE_RE = re.compile(r"^[a-z]+\(([^)]{1,40})\)!?:", re.IGNORECASE)
492
+ SKILL_HINT_RE = re.compile(r"\b(?:maggie[\w-]*|skills?|cli|mcp|codex|claude)\b", re.IGNORECASE)
493
+ SUMMARY_ITEM_CAP = 49
494
+
495
+
462
496
  def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
463
497
  """Return (type, text) for one commit subject, or ``None`` to skip it."""
464
498
  merge = MERGE_PR_RE.match(subject)
@@ -474,22 +508,58 @@ def classify_subject(subject: str, include_all: bool) -> tuple[str, str] | None:
474
508
  return "bug", subject
475
509
  if PLAIN_FEATURE_RE.search(subject):
476
510
  return "improvement", subject
511
+ # "Area: sentence" is how many product teams describe a change; it is a
512
+ # change in that area even when the sentence has no recognised verb.
513
+ if AREA_SUBJECT_RE.match(subject):
514
+ return "improvement", subject
477
515
  return ("general", subject) if include_all else None
478
516
 
479
517
 
480
- def _summary_repos(values: list[str]) -> list[tuple[str, Path]]:
481
- """Parse ``--repo [LABEL=]PATH`` values; unlabeled repositories become repo1, repo2, ..."""
518
+ def subject_scope(subject: str) -> str:
519
+ """The conventional-commit scope or ``Area:`` prefix of a subject, lower-cased; ``""`` when absent."""
520
+ match = SCOPE_RE.match(subject)
521
+ if match:
522
+ return match.group(1).strip().lower()
523
+ if FIX_SUBJECT_RE.match(subject) or FEATURE_SUBJECT_RE.match(subject) or CHORE_SUBJECT_RE.match(subject):
524
+ return ""
525
+ area = AREA_SUBJECT_RE.match(subject)
526
+ return area.group(1).strip().lower() if area else ""
527
+
528
+
529
+ def _summary_repos(values: list[str], default_rev: str = "") -> list[tuple[str, Path, str]]:
530
+ """Parse ``--repo [LABEL=]PATH[@REV]`` values; unlabeled repositories become repo1, repo2, ..."""
482
531
  repos = []
483
532
  for index, value in enumerate(values or ["."], start=1):
484
533
  label, sep, path = value.partition("=")
485
534
  if not sep or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,40}", label):
486
535
  label, path = f"repo{index}", value
487
- repos.append((label, Path(path).expanduser().resolve()))
488
- if len({label for label, _ in repos}) != len(repos):
536
+ rev = default_rev
537
+ head, at, tail = path.rpartition("@")
538
+ if at and head and not Path(path).expanduser().exists():
539
+ path, rev = head, tail
540
+ if rev and (rev.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", rev)):
541
+ raise ValueError("revision must be a branch, tag or commit such as origin/main")
542
+ repos.append((label, Path(path).expanduser().resolve(), rev or "HEAD"))
543
+ if len({label for label, _, _ in repos}) != len(repos):
489
544
  raise ValueError("--repo labels must be unique")
490
545
  return repos
491
546
 
492
547
 
548
+ def _behind_upstream(repo: Path, rev: str) -> tuple[str, int] | None:
549
+ """When reading HEAD, report (upstream, commits HEAD lacks) from local refs; never fetches."""
550
+ if rev != "HEAD":
551
+ return None
552
+ upstream = subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], capture_output=True, text=True, check=False)
553
+ if upstream.returncode or not upstream.stdout.strip():
554
+ return None
555
+ count = subprocess.run(["git", "-C", str(repo), "rev-list", "--count", "HEAD..@{u}"], capture_output=True, text=True, check=False)
556
+ try:
557
+ behind = int(count.stdout.strip() or 0)
558
+ except ValueError:
559
+ return None
560
+ return (upstream.stdout.strip(), behind) if behind else None
561
+
562
+
493
563
  def session_summary(args: argparse.Namespace) -> int:
494
564
  """Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
495
565
  since = str(args.since or "").strip()
@@ -503,32 +573,53 @@ def session_summary(args: argparse.Namespace) -> int:
503
573
  batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
504
574
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
505
575
  raise ValueError("batch ID is invalid")
506
- repos = _summary_repos(args.repo)
576
+ repos = _summary_repos(args.repo, str(getattr(args, "rev", "") or "").strip())
507
577
  include_all = bool(getattr(args, "all", False))
508
- items = []
578
+ skill_relevant = bool(getattr(args, "skill_relevant", False))
579
+ skill_hint = safe_text(args.skill).lower().removeprefix("maggie-")
580
+ candidates = []
509
581
  skipped = 0
582
+ project_commits = 0
583
+ seen = 0
510
584
  per_repo = {}
511
- for label, repo in repos:
585
+ warnings = []
586
+ for label, repo, rev in repos:
512
587
  command = ["git", "-C", str(repo), "log", "--format=%h%x1f%s"]
513
588
  if not getattr(args, "merges", False):
514
589
  command.append("--no-merges")
515
- command += [f"--since={since_date}", "HEAD"] if since_date else [f"{since}..HEAD"]
590
+ command += [f"--since={since_date}", rev] if since_date else [f"{since}..{rev}"]
516
591
  completed = subprocess.run(command, capture_output=True, text=True, check=False)
517
592
  if completed.returncode:
518
- raise ValueError(f"git log failed for {label}; check --repo and --since")
593
+ raise ValueError(f"git log failed for {label}; check --repo, --rev and --since")
594
+ lag = _behind_upstream(repo, rev)
595
+ if lag:
596
+ warnings.append(f"{label}: local HEAD is {lag[1]} commit(s) behind {lag[0]}; commits pushed from worktrees are missing. Re-run with --repo {label}=PATH@{lag[0]} (git fetch first).")
519
597
  counted = 0
520
598
  for line in completed.stdout.splitlines():
521
599
  sha, _, subject = line.partition("\x1f")
522
600
  subject = safe_text(subject)[:200]
523
601
  if not sha or not subject:
524
602
  continue
603
+ seen += 1
525
604
  classified = classify_subject(subject, include_all)
526
605
  if classified is None:
527
606
  skipped += 1
528
607
  continue
608
+ scope = subject_scope(subject)
609
+ if skill_relevant and not (SKILL_HINT_RE.search(subject) or (skill_hint and skill_hint in subject.lower())):
610
+ project_commits += 1
611
+ continue
529
612
  kind, text = classified
530
613
  text = text[:1].upper() + text[1:] if text else subject
531
614
  commit = f"{label}@{sha}" if len(repos) > 1 else sha
615
+ candidates.append({"kind": kind, "text": text, "commit": commit, "scope": scope or label})
616
+ counted += 1
617
+ per_repo[label] = counted
618
+ grouped = len(candidates) > SUMMARY_ITEM_CAP
619
+ items = []
620
+ if not grouped:
621
+ for candidate in candidates:
622
+ commit, text, kind = candidate["commit"], candidate["text"], candidate["kind"]
532
623
  item = {
533
624
  "type": kind,
534
625
  "summary": text,
@@ -540,10 +631,24 @@ def session_summary(args: argparse.Namespace) -> int:
540
631
  if kind in {"bug", "improvement"}:
541
632
  item.update({"fixed": True, "resolution": text})
542
633
  items.append(item)
543
- counted += 1
544
- if len(items) >= 49:
545
- break
546
- per_repo[label] = counted
634
+ else:
635
+ # Too many commits for one batch: one item per scope, so nothing is dropped silently.
636
+ scopes: dict[str, list[dict]] = {}
637
+ for candidate in candidates:
638
+ scopes.setdefault(candidate["scope"], []).append(candidate)
639
+ ordered = sorted(scopes.items(), key=lambda pair: (-len(pair[1]), pair[0]))
640
+ for scope, entries in ordered[:SUMMARY_ITEM_CAP]:
641
+ bugs = sum(1 for entry in entries if entry["kind"] == "bug")
642
+ items.append({
643
+ "type": "bug" if bugs * 2 > len(entries) else "improvement",
644
+ "summary": f"{scope}: {len(entries)} commit(s) ({bugs} fix) — keep only if a Maggie skill or CLI fell short",
645
+ "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
646
+ "actual": f"{REVIEW_MARKER} summarise the {scope} commits that came from a skill gap",
647
+ "validation": f"{REVIEW_MARKER} the test or check that verified the {scope} commits",
648
+ "evidence": [f"commit:{entry['commit']}" for entry in entries[:20]],
649
+ })
650
+ if len(ordered) > SUMMARY_ITEM_CAP:
651
+ warnings.append(f"{len(ordered) - SUMMARY_ITEM_CAP} scope(s) did not fit in one batch; narrow --since-date or use --skill-relevant.")
547
652
  items.append({
548
653
  "type": "feature",
549
654
  "summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
@@ -557,6 +662,7 @@ def session_summary(args: argparse.Namespace) -> int:
557
662
  "phase": safe_text(args.phase) or "session-summary",
558
663
  "reviewRequired": True,
559
664
  "note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
665
+ "commits": {"seen": seen, "drafted": len(candidates), "skipped": skipped, "projectOnly": project_commits, "groupedByScope": grouped},
560
666
  "items": items,
561
667
  }
562
668
  project = Path(args.project).resolve()
@@ -567,7 +673,9 @@ def session_summary(args: argparse.Namespace) -> int:
567
673
  raise ValueError("output already exists; choose another --output or --batch-id")
568
674
  output.parent.mkdir(parents=True, exist_ok=True)
569
675
  output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
570
- print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "repositories": per_repo, "output": str(output), "submitted": False}, ensure_ascii=False))
676
+ if grouped:
677
+ warnings.append(f"{len(candidates)} commits exceed the {SUMMARY_ITEM_CAP}-item batch limit, so they were grouped into one item per scope.")
678
+ print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "commitsSeen": seen, "draftedCommits": len(candidates), "skippedCommits": skipped, "projectOnlyCommits": project_commits, "groupedByScope": grouped, "repositories": per_repo, "warnings": warnings, "output": str(output), "submitted": False}, ensure_ascii=False))
571
679
  return 0
572
680
 
573
681
 
@@ -716,7 +824,9 @@ def main() -> int:
716
824
  summary_parser.add_argument("--project", default=argparse.SUPPRESS)
717
825
  summary_parser.add_argument("--since", default="", help="git revision; commits after it are summarized")
718
826
  summary_parser.add_argument("--since-date", default="", help="ISO date/time; use across several repositories")
719
- summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH of a git repository; repeat for app, backend, edge (default: current directory)")
827
+ summary_parser.add_argument("--repo", action="append", default=[], help="[LABEL=]PATH[@REV] of a git repository; repeat for app, backend, edge (default: current directory at HEAD)")
828
+ summary_parser.add_argument("--rev", default="", help="revision to read in every repository without its own @REV, e.g. origin/main")
829
+ summary_parser.add_argument("--skill-relevant", action="store_true", help="keep only commits that mention Maggie, a skill, a CLI or MCP (or the --skill name); count the rest as project-only")
720
830
  summary_parser.add_argument("--all", action="store_true", help="also draft unclassified and chore subjects as general items")
721
831
  summary_parser.add_argument("--merges", action="store_true", help="include merge commits (PR branch names are classified)")
722
832
  summary_parser.add_argument("--batch-id", default="")
@@ -733,7 +843,7 @@ def main() -> int:
733
843
  preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
734
844
  submit_parser = sub.add_parser("submit")
735
845
  submit_parser.add_argument("feedback")
736
- submit_parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
846
+ submit_parser.add_argument("--endpoint", action="append", help="HTTPS endpoint; repeat for an ordered fallback list (default: the NoBlox host, then feedback.cmo.so)")
737
847
  submit_parser.add_argument("--confirm", action="store_true")
738
848
  list_parser = sub.add_parser("list")
739
849
  list_parser.add_argument("--project", default=argparse.SUPPRESS)
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Record where API Pull deliveries went live, from the site's sitemap.
3
+
4
+ A delivered post is useful to other channels (social posts, internal links)
5
+ only once its live URL is known. The site should report it when it publishes;
6
+ this matcher fills the gaps from the sitemap with two rules:
7
+
8
+ * additive only: a record that already has a live URL keeps it, and a record
9
+ that matches nothing in this run is left as it was (never marked unmatched),
10
+ so the match can run on a schedule;
11
+ * the original page first: when translated copies share the slug
12
+ (``/blog/x`` and ``/zh-hans/blog/x``), the URL without a locale segment wins.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import sys
20
+ import xml.etree.ElementTree as ET
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from urllib.parse import urlsplit
24
+ from urllib.request import Request, urlopen
25
+
26
+ LOCALE_SEGMENT = re.compile(r"^[a-z]{2}(?:[-_][a-z0-9]{2,8})?$", re.I)
27
+ MAX_SITEMAPS = 50
28
+
29
+
30
+ def now() -> str:
31
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
32
+
33
+
34
+ def fetch(location: str) -> str:
35
+ if not location.startswith(("http://", "https://")):
36
+ return Path(location).read_text(encoding="utf-8")
37
+ with urlopen(Request(location, headers={"User-Agent": "MaggieLiveUrls/1.0"}), timeout=20) as response:
38
+ return response.read(20_000_000).decode("utf-8", "replace")
39
+
40
+
41
+ def sitemap_urls(location: str) -> list[str]:
42
+ """Every <loc> of a sitemap, following sitemap indexes (bounded)."""
43
+ seen: set[str] = set()
44
+ urls: list[str] = []
45
+ queue = [location]
46
+ while queue and len(seen) < MAX_SITEMAPS:
47
+ current = queue.pop(0)
48
+ if current in seen:
49
+ continue
50
+ seen.add(current)
51
+ root = ET.fromstring(fetch(current))
52
+ tag = root.tag.rsplit("}", 1)[-1]
53
+ locs = [element.text.strip() for element in root.iter() if element.tag.rsplit("}", 1)[-1] == "loc" and element.text]
54
+ if tag == "sitemapindex":
55
+ queue.extend(locs)
56
+ else:
57
+ urls.extend(locs)
58
+ return list(dict.fromkeys(urls))
59
+
60
+
61
+ def has_locale_segment(url: str) -> bool:
62
+ parts = [part for part in urlsplit(url).path.split("/") if part]
63
+ return len(parts) > 1 and bool(LOCALE_SEGMENT.match(parts[0]))
64
+
65
+
66
+ def best_match(slug: str, urls: list[str]) -> str | None:
67
+ candidates = [url for url in urls if [part for part in urlsplit(url).path.split("/") if part][-1:] == [slug]]
68
+ if not candidates:
69
+ return None
70
+ # Original before translated copies, then the shortest path.
71
+ return sorted(candidates, key=lambda url: (has_locale_segment(url), len(urlsplit(url).path), url))[0]
72
+
73
+
74
+ def match(state: dict, urls: list[str]) -> dict:
75
+ records = state.get("content", {}) if isinstance(state.get("content"), dict) else {}
76
+ matched, kept, missing = [], [], []
77
+ for key, record in records.items():
78
+ if not isinstance(record, dict):
79
+ continue
80
+ if record.get("live_url"):
81
+ kept.append({"remote_content_id": record.get("remote_content_id") or key, "live_url": record["live_url"], "source": record.get("live_url_source", "")})
82
+ continue
83
+ slug = str(record.get("slug") or "").strip("/")
84
+ url = best_match(slug, urls) if slug else None
85
+ if url:
86
+ record["live_url"] = url
87
+ record["live_url_source"] = "sitemap"
88
+ record["live_url_matched_at"] = now()
89
+ matched.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug, "live_url": url})
90
+ else:
91
+ missing.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug})
92
+ return {"matched": matched, "kept": kept, "notFound": missing}
93
+
94
+
95
+ def main() -> int:
96
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
97
+ sub = parser.add_subparsers(dest="command", required=True)
98
+ run = sub.add_parser("match", help="fill missing live URLs of delivered posts from the sitemap (additive)")
99
+ run.add_argument("--project", default=".")
100
+ run.add_argument("--state", help="lifecycle state (default: .maggie/ai-cmo-lifecycle.json)")
101
+ run.add_argument("--sitemap", required=True, help="sitemap URL or file (indexes are followed)")
102
+ run.add_argument("--write", action="store_true", help="save the new live URLs into the state file")
103
+ run.add_argument("--report-body", help="write a report-state body for the newly matched posts (send with `maggie api report-state --body-file`)")
104
+ record = sub.add_parser("record", help="record a live URL the site reported when it published a delivered post")
105
+ record.add_argument("--project", default=".")
106
+ record.add_argument("--state")
107
+ record.add_argument("--content-id", required=True)
108
+ record.add_argument("--url", required=True)
109
+ args = parser.parse_args()
110
+ state_path = Path(args.state) if args.state else Path(args.project) / ".maggie" / "ai-cmo-lifecycle.json"
111
+ try:
112
+ state = json.loads(state_path.read_text(encoding="utf-8"))
113
+ if args.command == "record":
114
+ if urlsplit(args.url).scheme not in {"http", "https"}:
115
+ raise ValueError("--url must be an absolute http(s) URL")
116
+ target = state.get("content", {}).get(args.content_id)
117
+ if not isinstance(target, dict):
118
+ raise ValueError(f"no delivered post {args.content_id} in {state_path}")
119
+ # The site's own report is authoritative and replaces a sitemap guess.
120
+ target.update({"live_url": args.url, "live_url_source": "site", "live_url_matched_at": now()})
121
+ state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
122
+ print(json.dumps({"status": "recorded", "remote_content_id": args.content_id, "live_url": args.url}, indent=2))
123
+ return 0
124
+ result = match(state, sitemap_urls(args.sitemap))
125
+ if args.write:
126
+ state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
127
+ if args.report_body and result["matched"]:
128
+ body = {"items": [{"content_id": item["remote_content_id"], "url": item["live_url"], "status": "published"} for item in result["matched"]][:500]}
129
+ Path(args.report_body).write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
130
+ print(json.dumps({"status": "matched" if args.write else "dry-run", **{key: value for key, value in result.items()}, "counts": {key: len(value) for key, value in result.items()}}, indent=2, ensure_ascii=False))
131
+ return 0
132
+ except (OSError, ValueError, json.JSONDecodeError, ET.ParseError) as error:
133
+ print(f"maggie live-urls: {error}", file=sys.stderr)
134
+ return 1
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
@@ -17,6 +17,7 @@ from localization_runner import generate, process_adapter
17
17
 
18
18
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
19
  import maggie_email_localization as email_localization
20
+ import maggie_localization_keys as localization_keys
20
21
 
21
22
 
22
23
  def generate_job(args: argparse.Namespace) -> int:
@@ -399,6 +400,7 @@ def main() -> int:
399
400
  for name in ("stale", "glossary"):
400
401
  command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
401
402
  email_localization.add_parsers(sub)
403
+ localization_keys.add_parsers(sub)
402
404
  args = parser.parse_args()
403
405
  try:
404
406
  if args.command == "generate": return generate_job(args)
@@ -411,6 +413,7 @@ def main() -> int:
411
413
  if args.command == "publish": return publish(args.job, args.confirm)
412
414
  if args.command == "stale": return stale_jobs(args.project)
413
415
  if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
416
+ if args.command in ("key-check", "fragments"): return localization_keys.run(args)
414
417
  return glossary(args.project)
415
418
  except (OSError, ValueError, json.JSONDecodeError) as error:
416
419
  print(f"ERROR: {error}", file=sys.stderr); return 1
@@ -0,0 +1,151 @@
1
+ """Translation keys for generated pages: text keys are site-wide, positions are not.
2
+
3
+ A translation stored under its source text applies to every page that shows
4
+ that text. That is right for a shared header, wrong for generic phrases on
5
+ generated pages ("your practice" is a clinic on one page and a firm on
6
+ another). `key-check` reads per-page translation rows and lists the texts that
7
+ need position keys. `fragments` reads rendered HTML and lists text split
8
+ around an inline span (a styled brand name) into pieces too short to translate
9
+ on their own ("Can", "No.", "What").
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import re
16
+ from html.parser import HTMLParser
17
+ from pathlib import Path
18
+
19
+ INLINE = {"span", "strong", "em", "b", "i", "a", "mark", "small", "sup", "sub", "u"}
20
+ BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "button", "label", "summary", "caption"}
21
+ SKIP = {"script", "style", "noscript", "template", "svg", "code", "pre"}
22
+ WORD = re.compile(r"[\w'’-]+", re.UNICODE)
23
+
24
+
25
+ def load_rows(path: Path) -> list[dict]:
26
+ value = json.loads(path.read_text(encoding="utf-8"))
27
+ rows = value.get("rows", []) if isinstance(value, dict) else value
28
+ if not isinstance(rows, list):
29
+ raise ValueError("rows must be a list of {page, position?, text, translation}")
30
+ return [row for row in rows if isinstance(row, dict)]
31
+
32
+
33
+ def key_check(rows: list[dict], locale: str | None = None) -> dict:
34
+ """Source texts whose translation differs between pages cannot share a text key."""
35
+ by_text: dict[str, dict[str, set[str]]] = {}
36
+ for row in rows:
37
+ if locale and row.get("locale") not in (None, locale):
38
+ continue
39
+ text = " ".join(str(row.get("text") or "").split())
40
+ translation = " ".join(str(row.get("translation") or "").split())
41
+ if not text or not translation:
42
+ continue
43
+ by_text.setdefault(text, {}).setdefault(translation, set()).add(str(row.get("page") or "?"))
44
+ conflicts = []
45
+ for text, translations in sorted(by_text.items()):
46
+ if len(translations) > 1:
47
+ pages = sorted({page for group in translations.values() for page in group})
48
+ conflicts.append({"text": text, "translations": {translation: sorted(group)[:5] for translation, group in translations.items()}, "pages": len(pages)})
49
+ shared = sum(1 for translations in by_text.values() if len({page for group in translations.values() for page in group}) > 1)
50
+ return {
51
+ "valid": not conflicts,
52
+ "texts": len(by_text),
53
+ "sharedAcrossPages": shared,
54
+ "needPositionKeys": len(conflicts),
55
+ "conflicts": conflicts[:200],
56
+ "advice": "Store these under per-page position keys (render the source page, then key each node by page + position with a source hash); keep text keys only as a fallback for genuinely shared text." if conflicts else "",
57
+ }
58
+
59
+
60
+ class FragmentParser(HTMLParser):
61
+ """Collect, per block element, the run of text pieces and inline spans."""
62
+
63
+ def __init__(self, brand: re.Pattern[str]) -> None:
64
+ super().__init__(convert_charrefs=True)
65
+ self.brand = brand
66
+ self.stack: list[str] = []
67
+ self.blocks: list[list[tuple[str, str]]] = [] # (kind, text): kind is "text" or "inline"
68
+ self.inline_depth = 0
69
+ self.inline_text = ""
70
+ self.skip = 0
71
+ self.findings: list[dict] = []
72
+
73
+ def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
74
+ if tag in SKIP:
75
+ self.skip += 1
76
+ return
77
+ if tag in BLOCK:
78
+ self.blocks.append([])
79
+ elif tag in INLINE and self.blocks:
80
+ if self.inline_depth == 0:
81
+ self.inline_text = ""
82
+ self.inline_depth += 1
83
+
84
+ def handle_endtag(self, tag: str) -> None:
85
+ if tag in SKIP:
86
+ self.skip = max(0, self.skip - 1)
87
+ return
88
+ if tag in INLINE and self.inline_depth:
89
+ self.inline_depth -= 1
90
+ if self.inline_depth == 0 and self.blocks:
91
+ self.blocks[-1].append(("inline", self.inline_text))
92
+ elif tag in BLOCK and self.blocks:
93
+ self.finish(self.blocks.pop())
94
+
95
+ def handle_data(self, data: str) -> None:
96
+ if self.skip or not self.blocks:
97
+ return
98
+ if self.inline_depth:
99
+ self.inline_text += data
100
+ elif data.strip():
101
+ self.blocks[-1].append(("text", data))
102
+
103
+ def finish(self, parts: list[tuple[str, str]]) -> None:
104
+ if not any(kind == "inline" and self.brand.search(text) for kind, text in parts):
105
+ return
106
+ sentence = " ".join(" ".join(text.split()) for _, text in parts).strip()
107
+ for index, (kind, text) in enumerate(parts):
108
+ if kind != "text":
109
+ continue
110
+ neighbours = [parts[i] for i in (index - 1, index + 1) if 0 <= i < len(parts)]
111
+ if not any(other_kind == "inline" and self.brand.search(other_text) for other_kind, other_text in neighbours):
112
+ continue
113
+ words = WORD.findall(text)
114
+ if 1 <= len(words) <= 2:
115
+ self.findings.append({"fragment": " ".join(text.split()), "words": len(words), "sentence": sentence[:200]})
116
+
117
+
118
+ def fragments(paths: list[Path], brand: str) -> dict:
119
+ pattern = re.compile(re.escape(brand), re.I)
120
+ findings = []
121
+ for path in paths:
122
+ parser = FragmentParser(pattern)
123
+ parser.feed(path.read_text(encoding="utf-8", errors="replace"))
124
+ findings.extend({"file": str(path), **item} for item in parser.findings)
125
+ return {
126
+ "valid": not findings,
127
+ "files": len(paths),
128
+ "fragments": findings[:500],
129
+ "advice": f"Translate the whole sentence around {brand!r} as one unit (keep the span as a placeholder such as {{brand}}) instead of storing these short pieces." if findings else "",
130
+ }
131
+
132
+
133
+ def add_parsers(sub: argparse._SubParsersAction) -> None:
134
+ keys = sub.add_parser("key-check", help="list source texts translated differently on different pages (they need per-page position keys)")
135
+ keys.add_argument("rows", type=Path, help='JSON rows [{"page", "position"?, "text", "translation", "locale"?}]')
136
+ keys.add_argument("--locale")
137
+ split = sub.add_parser("fragments", help="find text split around an inline brand span into pieces too short to translate")
138
+ split.add_argument("--html", type=Path, nargs="+", required=True, help="rendered HTML files or directories (searched for *.html)")
139
+ split.add_argument("--brand", required=True, help="the inline span's text, e.g. the brand name")
140
+
141
+
142
+ def run(args: argparse.Namespace) -> int:
143
+ if args.command == "key-check":
144
+ result = key_check(load_rows(args.rows), args.locale)
145
+ else:
146
+ files: list[Path] = []
147
+ for item in args.html:
148
+ files.extend(sorted(item.rglob("*.html")) if item.is_dir() else [item])
149
+ result = fragments(files, args.brand)
150
+ print(json.dumps(result, indent=2, ensure_ascii=False))
151
+ return 0 if result["valid"] else 1