@topy-ai/maggie 0.7.53 → 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.
- package/README-zh-TW.md +28 -50
- package/README.md +85 -64
- package/bin/maggie.js +51 -7
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +28 -2
- package/bundled-skills/maggie-deployment/SKILL.md +27 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-social-share/SKILL.md +30 -0
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_live_urls.py +138 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +84 -1
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_social_share.py +122 -1
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/package.json +1 -1
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -28,6 +28,16 @@ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
|
|
|
28
28
|
``eas fingerprint:compare``, publish the update only when it matches, and
|
|
29
29
|
always put the working copies back (verified by hash), even on failure. ``ota``
|
|
30
30
|
is a dry run unless ``--confirm`` is given, because the update is published.
|
|
31
|
+
|
|
32
|
+
``ota-guard`` checks the runtime an update would target against the runtimes
|
|
33
|
+
of builds people have installed (a runtimes file kept with the app) and
|
|
34
|
+
refuses when none matches. In a pnpm monorepo the fingerprint also hashes
|
|
35
|
+
pnpm's virtual-store paths (``node_modules/.pnpm/<pkg>_<peer hash>``), so a
|
|
36
|
+
dependency change in any workspace package can move the runtime;
|
|
37
|
+
``ignorePaths`` and ``fileHookTransform`` cannot help because native module
|
|
38
|
+
folders are added by their real path. With ``--publish --confirm`` it runs
|
|
39
|
+
``eas update`` after a match and checks that the published runtime is the one
|
|
40
|
+
that was checked.
|
|
31
41
|
"""
|
|
32
42
|
|
|
33
43
|
from __future__ import annotations
|
|
@@ -180,6 +190,9 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
|
|
|
180
190
|
package = _load(app / "package.json") if (app / "package.json").is_file() else {}
|
|
181
191
|
ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
|
|
182
192
|
errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
|
|
193
|
+
if is_pnpm_monorepo(app) and any(runtime_policy(expo, platform)[0] == {"policy": "fingerprint"} or runtime_policy(expo, platform)[0] == "fingerprint" for platform in platforms):
|
|
194
|
+
warnings.append("pnpm monorepo with a fingerprint runtime: pnpm's virtual-store paths are part of the fingerprint, so a dependency change in another workspace package can move the runtime. Publish updates through `maggie mobile ota-guard --runtimes FILE` and set an explicit runtimeVersion (plus a native-change check) at the next native build")
|
|
195
|
+
facts["pnpmMonorepo"] = True
|
|
183
196
|
return {
|
|
184
197
|
"schemaVersion": SCHEMA,
|
|
185
198
|
"appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
|
|
@@ -304,10 +317,70 @@ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str
|
|
|
304
317
|
report["steps"] = steps
|
|
305
318
|
|
|
306
319
|
|
|
320
|
+
def is_pnpm_monorepo(app: Path) -> bool:
|
|
321
|
+
for folder in (app, *app.parents):
|
|
322
|
+
if (folder / "pnpm-workspace.yaml").is_file():
|
|
323
|
+
return True
|
|
324
|
+
if (folder / ".git").exists():
|
|
325
|
+
break
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def load_runtimes(path: Path, platform: str) -> list[dict]:
|
|
330
|
+
"""`maggie-mobile-runtimes.v1` ({"ios": [{"build", "eas_build_id", "runtime"}], "android": [...]})."""
|
|
331
|
+
data = _load(path)
|
|
332
|
+
builds = data.get(platform) or []
|
|
333
|
+
if not isinstance(builds, list) or not all(isinstance(item, dict) and item.get("runtime") for item in builds):
|
|
334
|
+
raise ValueError(f"{path} has no `{platform}` list of {{build, runtime}} entries")
|
|
335
|
+
return builds
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def fingerprint_command(platform: str) -> list[str]:
|
|
339
|
+
return shlex.split(os.environ.get("MAGGIE_MOBILE_FINGERPRINT", f"npx expo-updates fingerprint:generate --platform {platform}"))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def ota_guard(app: Path, runtimes: Path, platform: str, runtime: str | None, publish: bool, channel: str | None, environment: str, message: str | None, confirm: bool) -> dict:
|
|
343
|
+
builds = load_runtimes(runtimes, platform)
|
|
344
|
+
report: dict = {"schemaVersion": "maggie-mobile-ota-guard.v1", "platform": platform, "installedBuilds": [{"build": item.get("build"), "runtime": item["runtime"]} for item in builds], "executed": False}
|
|
345
|
+
if not runtime:
|
|
346
|
+
result = subprocess.run(fingerprint_command(platform), cwd=app, capture_output=True, text=True, check=False)
|
|
347
|
+
try:
|
|
348
|
+
runtime = str(json.loads(result.stdout[result.stdout.find("{"):])["hash"]) if result.returncode == 0 else None
|
|
349
|
+
except (ValueError, KeyError):
|
|
350
|
+
runtime = None
|
|
351
|
+
if not runtime:
|
|
352
|
+
return {**report, "status": "failed", "reason": "could not compute the fingerprint (expo-updates fingerprint:generate)"}
|
|
353
|
+
report["runtime"] = runtime
|
|
354
|
+
match = next((item for item in builds if item["runtime"] == runtime), None)
|
|
355
|
+
if not match:
|
|
356
|
+
report.update(status="failed", reason=f"no installed build has runtime {runtime}; an update published now would reach nobody",
|
|
357
|
+
fix=["the change needs a native build (new native module, permission, native config): build, then add its runtime to the runtimes file",
|
|
358
|
+
"or only install paths moved (a dependency change elsewhere in the monorepo): publish from a temporary worktree with the build's lockfile and workspace file, after a frozen install, and confirm the fingerprint equals the build's runtime",
|
|
359
|
+
"next native build: set an explicit runtimeVersion and keep a native-change check, so unrelated workspace changes cannot move it"])
|
|
360
|
+
return report
|
|
361
|
+
report["matchedBuild"] = match.get("build")
|
|
362
|
+
if not publish:
|
|
363
|
+
return {**report, "status": "passed"}
|
|
364
|
+
if not (channel and message):
|
|
365
|
+
raise ValueError("--publish needs an EAS Update channel on the profile and --message")
|
|
366
|
+
update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform, "--message", message, "--non-interactive"]
|
|
367
|
+
report["command"] = shlex.join(update)
|
|
368
|
+
if not confirm:
|
|
369
|
+
return {**report, "status": "dry-run", "note": "runtime matches; re-run with --confirm to publish"}
|
|
370
|
+
result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
|
|
371
|
+
published = re.search(r"Runtime version\s+([0-9a-f]{8,})", result.stdout or "")
|
|
372
|
+
report.update(executed=True, publishedRuntime=published.group(1) if published else None)
|
|
373
|
+
if result.returncode:
|
|
374
|
+
return {**report, "status": "failed", "reason": "eas update failed; see its output"}
|
|
375
|
+
if not published or published.group(1) != runtime:
|
|
376
|
+
return {**report, "status": "failed", "reason": "the published runtime differs from the checked one; check `eas update:list`"}
|
|
377
|
+
return {**report, "status": "published"}
|
|
378
|
+
|
|
379
|
+
|
|
307
380
|
def main(argv: list[str] | None = None) -> int:
|
|
308
381
|
parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
309
382
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
310
|
-
for name in ("preflight", "plan", "ota"):
|
|
383
|
+
for name in ("preflight", "plan", "ota", "ota-guard"):
|
|
311
384
|
command = sub.add_parser(name)
|
|
312
385
|
command.add_argument("--project", type=Path, default=Path("."))
|
|
313
386
|
command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
|
|
@@ -319,6 +392,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
319
392
|
command.add_argument("--output", type=Path)
|
|
320
393
|
if name in {"plan", "ota"}:
|
|
321
394
|
command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
|
|
395
|
+
if name == "ota-guard":
|
|
396
|
+
command.add_argument("--runtimes", type=Path, required=True, help="maggie-mobile-runtimes.v1 JSON: installed builds' runtimes per platform")
|
|
397
|
+
command.add_argument("--runtime", help="use this runtime instead of computing the fingerprint")
|
|
398
|
+
command.add_argument("--publish", action="store_true", help="publish the update after a match")
|
|
399
|
+
command.add_argument("--message")
|
|
400
|
+
command.add_argument("--confirm", action="store_true")
|
|
322
401
|
if name == "ota":
|
|
323
402
|
command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
|
|
324
403
|
command.add_argument("--message", required=True)
|
|
@@ -341,6 +420,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
341
420
|
report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, channel, args.environment, args.build_ref, drift), "costNotice": report["costNotice"], "executed": False}
|
|
342
421
|
if drift is not None:
|
|
343
422
|
report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
|
|
423
|
+
elif args.command == "ota-guard":
|
|
424
|
+
if args.platform == "all":
|
|
425
|
+
raise ValueError("--platform ios or android: runtimes differ per platform")
|
|
426
|
+
report = ota_guard(app, (project / args.runtimes) if not args.runtimes.is_absolute() else args.runtimes, args.platform, args.runtime, args.publish, channel, environment, args.message, args.confirm)
|
|
344
427
|
elif args.command == "ota":
|
|
345
428
|
if report["status"] == "failed":
|
|
346
429
|
raise ValueError("preflight failed: " + "; ".join(report["errors"]))
|
|
@@ -27,6 +27,7 @@ import json
|
|
|
27
27
|
import re
|
|
28
28
|
import shutil
|
|
29
29
|
import subprocess
|
|
30
|
+
import time
|
|
30
31
|
import sys
|
|
31
32
|
import tempfile
|
|
32
33
|
from datetime import date, datetime, timezone
|
|
@@ -264,6 +265,44 @@ def review_status(reviews_path: Path, package_version: str | None, chatgpt_plugi
|
|
|
264
265
|
return {"schemaVersion": "maggie-product-review-status.v1", "passed": not errors, "reviews": rows, "errors": errors, "warnings": warnings}
|
|
265
266
|
|
|
266
267
|
|
|
268
|
+
STAGED_ERROR = re.compile(r"cannot publish over (?:the )?previously (?:staged|published) version", re.I)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def npm_view(package: str, npm_bin: str) -> dict:
|
|
272
|
+
completed = subprocess.run([npm_bin, "view", package, "dist-tags", "versions", "--json"], capture_output=True, text=True, check=False, timeout=60)
|
|
273
|
+
if completed.returncode:
|
|
274
|
+
return {}
|
|
275
|
+
try:
|
|
276
|
+
value = json.loads(completed.stdout or "{}")
|
|
277
|
+
except json.JSONDecodeError:
|
|
278
|
+
return {}
|
|
279
|
+
return value if isinstance(value, dict) else {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def published(package: str, version: str, tag: str, wait: int, interval: float, publish_log: Path | None, view=None, sleep=time.sleep) -> dict:
|
|
283
|
+
"""Wait until `version` is the registry's `tag` (latest). A staged publish can lag for minutes."""
|
|
284
|
+
npm_bin = shutil.which("npm") or "npm"
|
|
285
|
+
view = view or (lambda: npm_view(package, npm_bin))
|
|
286
|
+
log_says_staged = bool(publish_log and publish_log.is_file() and STAGED_ERROR.search(publish_log.read_text(encoding="utf-8", errors="replace")))
|
|
287
|
+
deadline = time.monotonic() + wait
|
|
288
|
+
checks = 0
|
|
289
|
+
while True:
|
|
290
|
+
checks += 1
|
|
291
|
+
data = view()
|
|
292
|
+
tags = data.get("dist-tags") or {}
|
|
293
|
+
versions = data.get("versions") or []
|
|
294
|
+
listed = version in (versions if isinstance(versions, list) else [versions])
|
|
295
|
+
current = tags.get(tag)
|
|
296
|
+
if current == version:
|
|
297
|
+
return {"passed": True, "package": package, "version": version, "tag": tag, "state": "live", "checks": checks,
|
|
298
|
+
"note": "the earlier publish attempt was already accepted (staged), not a failure" if log_says_staged else None}
|
|
299
|
+
if time.monotonic() >= deadline:
|
|
300
|
+
state = "staged" if listed or log_says_staged else "missing"
|
|
301
|
+
return {"passed": False, "package": package, "version": version, "tag": tag, "state": state, "registryTag": current, "checks": checks,
|
|
302
|
+
"note": "still propagating: check again before reporting it" if state == "staged" else "not on the registry: the publish did not happen"}
|
|
303
|
+
sleep(interval)
|
|
304
|
+
|
|
305
|
+
|
|
267
306
|
def main(argv: list[str] | None = None) -> int:
|
|
268
307
|
parser = argparse.ArgumentParser(prog="maggie product", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
269
308
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -281,9 +320,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
281
320
|
review.add_argument("--reviews", type=Path, required=True, help="maggie-product-reviews.v1 JSON")
|
|
282
321
|
review.add_argument("--package", type=Path)
|
|
283
322
|
review.add_argument("--chatgpt-plugin", type=Path, help="ChatGPT app plugin.json with review.test_cases")
|
|
323
|
+
pub = sub.add_parser("published", help="wait until a just-published version is the registry's latest (staged publishes lag)")
|
|
324
|
+
pub.add_argument("--package-name", required=True)
|
|
325
|
+
pub.add_argument("--version", required=True)
|
|
326
|
+
pub.add_argument("--tag", default="latest")
|
|
327
|
+
pub.add_argument("--wait", type=int, default=600, help="seconds to keep checking (default 600)")
|
|
328
|
+
pub.add_argument("--interval", type=float, default=20)
|
|
329
|
+
pub.add_argument("--publish-log", type=Path, help="output of the publish command; 'Cannot publish over previously staged version' means it was already accepted")
|
|
284
330
|
args = parser.parse_args(argv)
|
|
285
331
|
try:
|
|
286
|
-
if args.command == "
|
|
332
|
+
if args.command == "published":
|
|
333
|
+
report = published(args.package_name, args.version, args.tag, args.wait, args.interval, args.publish_log)
|
|
334
|
+
elif args.command == "release-check":
|
|
287
335
|
report = release_check(args.package.resolve(), args.server_json, args.changelog, args.pack)
|
|
288
336
|
elif args.command == "smoke":
|
|
289
337
|
report = smoke(args.package.resolve(), args.timeout)
|
|
@@ -7,7 +7,9 @@ import json
|
|
|
7
7
|
import sys
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
10
11
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
11
13
|
|
|
12
14
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
15
|
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
|
|
@@ -43,6 +45,66 @@ def tracked_url(url: str, channel: str) -> str:
|
|
|
43
45
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
# Only a definite "gone" answer drops an article. Blocked (403/429), failing
|
|
49
|
+
# (5xx) or unreachable pages are kept: a firewall or an outage must never empty
|
|
50
|
+
# the list of articles to share.
|
|
51
|
+
GONE = {404, 410}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_status(url: str, timeout: float = 10.0) -> int | None:
|
|
55
|
+
"""Final HTTP status after redirects, or None when the page could not be reached."""
|
|
56
|
+
for method in ("HEAD", "GET"):
|
|
57
|
+
request = Request(url, method=method, headers={"User-Agent": "MaggieSocialShare/1.0 (+link check)", "Accept": "text/html,*/*"})
|
|
58
|
+
try:
|
|
59
|
+
with urlopen(request, timeout=timeout) as response:
|
|
60
|
+
return int(response.status)
|
|
61
|
+
except HTTPError as error:
|
|
62
|
+
# Some servers refuse HEAD; ask again with GET before deciding.
|
|
63
|
+
if method == "HEAD" and error.code in {400, 403, 405, 501}:
|
|
64
|
+
continue
|
|
65
|
+
return int(error.code)
|
|
66
|
+
except (URLError, TimeoutError, OSError):
|
|
67
|
+
return None
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def link_verdict(status: int | None) -> str:
|
|
72
|
+
if status in GONE:
|
|
73
|
+
return "gone"
|
|
74
|
+
if status is not None and 200 <= status < 400:
|
|
75
|
+
return "live"
|
|
76
|
+
return "unknown"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_links(urls: list[str], cache_path: Path | None, ttl_hours: float, now: datetime, fetch=fetch_status) -> dict:
|
|
80
|
+
cache: dict = {}
|
|
81
|
+
if cache_path and cache_path.is_file():
|
|
82
|
+
try:
|
|
83
|
+
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, json.JSONDecodeError):
|
|
85
|
+
cache = {}
|
|
86
|
+
results = []
|
|
87
|
+
for url in dict.fromkeys(urls):
|
|
88
|
+
cached = cache.get(url)
|
|
89
|
+
fresh = False
|
|
90
|
+
if isinstance(cached, dict) and cached.get("checked_at"):
|
|
91
|
+
try:
|
|
92
|
+
fresh = (now - parse_instant(cached["checked_at"], "checked_at")).total_seconds() < ttl_hours * 3600
|
|
93
|
+
except ValueError:
|
|
94
|
+
fresh = False
|
|
95
|
+
if fresh:
|
|
96
|
+
status, source = cached.get("status"), "cache"
|
|
97
|
+
else:
|
|
98
|
+
status, source = fetch(url), "fetched"
|
|
99
|
+
cache[url] = {"status": status, "checked_at": now.isoformat().replace("+00:00", "Z")}
|
|
100
|
+
results.append({"url": url, "status": status, "verdict": link_verdict(status), "source": source})
|
|
101
|
+
if cache_path:
|
|
102
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
cache_path.write_text(json.dumps(cache, indent=2) + "\n", encoding="utf-8")
|
|
104
|
+
gone = [item for item in results if item["verdict"] == "gone"]
|
|
105
|
+
return {"valid": True, "checked": len(results), "skip": gone, "keep": [item for item in results if item["verdict"] != "gone"], "results": results}
|
|
106
|
+
|
|
107
|
+
|
|
46
108
|
def plan(content: dict, channels: list[str], workspace_domain: str | None = None) -> dict:
|
|
47
109
|
errors = [f"missing {key}" for key in REQUIRED if not content.get(key)]
|
|
48
110
|
invalid = [channel for channel in channels if channel not in LIMITS]
|
|
@@ -73,6 +135,40 @@ def plan(content: dict, channels: list[str], workspace_domain: str | None = None
|
|
|
73
135
|
}
|
|
74
136
|
|
|
75
137
|
|
|
138
|
+
def shared_picture_errors(posts: list[dict], label: str) -> list[str]:
|
|
139
|
+
"""Posts written together for several channels share one picture.
|
|
140
|
+
|
|
141
|
+
A channel may differ only when a person picked its picture
|
|
142
|
+
(`image_source: "user"`)."""
|
|
143
|
+
automatic = [post for post in posts if post.get("image_source") != "user"]
|
|
144
|
+
pictures = {post.get("image_url") or None for post in automatic}
|
|
145
|
+
if len(pictures) <= 1:
|
|
146
|
+
return []
|
|
147
|
+
by_picture: dict[str, list[str]] = {}
|
|
148
|
+
for post in automatic:
|
|
149
|
+
by_picture.setdefault(post.get("image_url") or "(none)", []).append(str(post.get("channel") or post.get("name") or post.get("id")))
|
|
150
|
+
detail = "; ".join(f"{', '.join(names)}: {picture}" for picture, names in by_picture.items())
|
|
151
|
+
return [f"{label}: channels written together must share one picture ({detail}); set image_source user on a channel whose picture a person chose"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def picture_groups(value: dict) -> dict:
|
|
155
|
+
"""Check that drafts written from one idea share one picture across channels."""
|
|
156
|
+
drafts = value.get("drafts", []) if isinstance(value, dict) else value
|
|
157
|
+
groups: dict[str, list[dict]] = {}
|
|
158
|
+
for draft in drafts:
|
|
159
|
+
key = draft.get("idea_id") or draft.get("group_id")
|
|
160
|
+
if key:
|
|
161
|
+
groups.setdefault(str(key), []).append(draft)
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
report = []
|
|
164
|
+
for key, items in sorted(groups.items()):
|
|
165
|
+
problems = shared_picture_errors(items, f"idea {key}")
|
|
166
|
+
errors.extend(problems)
|
|
167
|
+
automatic = [item.get("image_url") for item in items if item.get("image_source") != "user"]
|
|
168
|
+
report.append({"idea_id": key, "channels": [item.get("channel") for item in items], "picture": automatic[0] if automatic and not problems else None, "shared": not problems})
|
|
169
|
+
return {"valid": not errors, "errors": errors, "ideas": report}
|
|
170
|
+
|
|
171
|
+
|
|
76
172
|
def validate(value: dict) -> list[str]:
|
|
77
173
|
errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
|
|
78
174
|
if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
|
|
@@ -84,7 +180,10 @@ def validate(value: dict) -> list[str]:
|
|
|
84
180
|
errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
|
|
85
181
|
elif not domain and value.get("status") in LIVE_STATUSES:
|
|
86
182
|
errors.append("workspace_domain is required before approval: the source must be published on the workspace's own domain")
|
|
87
|
-
|
|
183
|
+
channels = value.get("channels", [])
|
|
184
|
+
if value.get("image_url"):
|
|
185
|
+
errors.extend(shared_picture_errors([{**channel, "image_url": channel.get("image_url", value["image_url"])} for channel in channels], "draft"))
|
|
186
|
+
for channel in channels:
|
|
88
187
|
name = channel.get("name")
|
|
89
188
|
if name not in LIMITS:
|
|
90
189
|
errors.append(f"unsupported channel: {name}")
|
|
@@ -114,6 +213,13 @@ def main() -> int:
|
|
|
114
213
|
create.add_argument("--channels", default="linkedin,facebook")
|
|
115
214
|
create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
|
|
116
215
|
create.add_argument("--output")
|
|
216
|
+
create.add_argument("--check-link", action="store_true", help="fetch canonical_url first and refuse a definite 404/410")
|
|
217
|
+
pictures = sub.add_parser("pictures", help="check that drafts written from one idea share one picture on every channel")
|
|
218
|
+
pictures.add_argument("input", help='JSON: {"drafts": [{"id", "idea_id", "channel", "image_url", "image_source"?}]}')
|
|
219
|
+
links = sub.add_parser("link-check", help="drop articles whose link is gone (404/410) before planning posts; keep blocked or failing pages")
|
|
220
|
+
links.add_argument("input", help="JSON: a list of URLs or article objects with canonical_url, or {\"articles\": [...]}")
|
|
221
|
+
links.add_argument("--cache", default=".maggie/social-link-cache.json", help="verdict cache (default: .maggie/social-link-cache.json); 'none' disables it")
|
|
222
|
+
links.add_argument("--ttl-hours", type=float, default=6.0)
|
|
117
223
|
check = sub.add_parser("validate", help="validate a draft channel contract")
|
|
118
224
|
check.add_argument("input")
|
|
119
225
|
adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
|
|
@@ -134,6 +240,15 @@ def main() -> int:
|
|
|
134
240
|
schema.add_argument("input")
|
|
135
241
|
args = parser.parse_args()
|
|
136
242
|
try:
|
|
243
|
+
if args.command == "link-check":
|
|
244
|
+
raw = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
245
|
+
articles = raw.get("articles", []) if isinstance(raw, dict) else raw
|
|
246
|
+
urls = [item if isinstance(item, str) else item.get("canonical_url") or item.get("url") for item in articles]
|
|
247
|
+
result = check_links([url for url in urls if url], None if args.cache == "none" else Path(args.cache), args.ttl_hours, datetime.now(timezone.utc))
|
|
248
|
+
by_url = {item["url"]: item for item in result["results"]}
|
|
249
|
+
result["articles"] = [item for item in articles if by_url.get(item if isinstance(item, str) else (item.get("canonical_url") or item.get("url")), {}).get("verdict") != "gone"]
|
|
250
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
251
|
+
return 0
|
|
137
252
|
value = read(Path(args.input))
|
|
138
253
|
now = datetime.now(timezone.utc)
|
|
139
254
|
if args.command == "schema-check":
|
|
@@ -143,6 +258,8 @@ def main() -> int:
|
|
|
143
258
|
result = validate_adapters(value)
|
|
144
259
|
elif args.command == "connections":
|
|
145
260
|
result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
|
|
261
|
+
elif args.command == "pictures":
|
|
262
|
+
result = picture_groups(value if isinstance(value, dict) else {"drafts": value})
|
|
146
263
|
elif args.command == "reconcile":
|
|
147
264
|
result = reconcile(value)
|
|
148
265
|
elif args.command == "campaigns":
|
|
@@ -156,6 +273,10 @@ def main() -> int:
|
|
|
156
273
|
return 0
|
|
157
274
|
else:
|
|
158
275
|
if args.command == "plan":
|
|
276
|
+
if args.check_link and value.get("canonical_url"):
|
|
277
|
+
status = fetch_status(value["canonical_url"])
|
|
278
|
+
if link_verdict(status) == "gone":
|
|
279
|
+
raise ValueError(f"canonical_url answers HTTP {status}: the article is gone; pick a live article (blocked or failing pages are not treated as gone)")
|
|
159
280
|
value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
|
|
160
281
|
if args.output:
|
|
161
282
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -74,6 +74,28 @@ The import is draft-only and never publishes. It rejects missing IDs, invalid
|
|
|
74
74
|
slugs, and empty content; changed source checksums stop for manual conflict
|
|
75
75
|
review. Approval and publication are separate MaggieDash transitions.
|
|
76
76
|
|
|
77
|
+
## Where a delivered post went live
|
|
78
|
+
|
|
79
|
+
A pulled post is useful to other channels (social posts that link it,
|
|
80
|
+
internal links) only once its live URL is known; without it a planner falls
|
|
81
|
+
back to an older stored URL, which after a platform move answers 404. So:
|
|
82
|
+
|
|
83
|
+
1. When the site publishes a delivered post, it reports the URL it went live
|
|
84
|
+
at (`POST /content-tracking/report-state` with the content ID, the URL and
|
|
85
|
+
`published`) and records it locally:
|
|
86
|
+
`maggie blog live-urls record --content-id ID --url URL`. The site's report
|
|
87
|
+
is authoritative.
|
|
88
|
+
2. A scheduled sitemap match fills the gaps:
|
|
89
|
+
`maggie blog live-urls match --sitemap https://example.com/sitemap.xml --write --report-body .maggie/live-urls-report.json`,
|
|
90
|
+
then send that body with `maggie api report-state --body-file … --execute`.
|
|
91
|
+
|
|
92
|
+
Matching is **additive**: a post that already has a live URL keeps it, and a
|
|
93
|
+
post this run did not find is left as it was — never marked unmatched — so the
|
|
94
|
+
match can run daily without retiring earlier matches. When translated copies
|
|
95
|
+
share the slug (`/blog/x`, `/zh-hans/blog/x`), the URL **without a locale
|
|
96
|
+
segment** wins. The same two rules apply to the platform's own sitemap
|
|
97
|
+
matching for API Pull destinations.
|
|
98
|
+
|
|
77
99
|
## Project context synchronization
|
|
78
100
|
|
|
79
101
|
`GET /project-context` returns sanitized, read-only context scoped by the API
|