@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.
- package/README-zh-TW.md +28 -4
- package/README.md +93 -60
- package/bin/maggie.js +67 -12
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-blog/SKILL.md +8 -0
- package/bundled-skills/maggie-booking/SKILL.md +66 -14
- 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 +48 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +98 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +27 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +56 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +24 -12
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
- package/bundled-tools/clis/maggie_design.py +15 -2
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +205 -3
- package/bundled-tools/clis/maggie_feedback.py +138 -28
- 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 +219 -4
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +174 -7
- package/bundled-tools/integrations/maggie-api-pull.md +22 -0
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
- 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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dry run of moving a business's clients, future appointments and gift cards (``maggie service migrate-check``).
|
|
3
|
+
|
|
4
|
+
Switching a real business from Fresha (or any system with CSV exports) needs
|
|
5
|
+
more than the service catalogue: clients with consent, future appointments
|
|
6
|
+
and gift-card balances. This command reads the exports and writes nothing:
|
|
7
|
+
|
|
8
|
+
- recognises columns by synonyms (export headers vary by report and locale)
|
|
9
|
+
and lists anything it could not map, so the owner can fix the mapping;
|
|
10
|
+
- clients: de-duplicates by e-mail, then by phone normalised to E.164 for the
|
|
11
|
+
market; keeps marketing consent only when the export says opted in;
|
|
12
|
+
- appointments: keeps only future, not-cancelled rows; converts local times
|
|
13
|
+
with the **venue's** zone; matches services and staff by normalised name;
|
|
14
|
+
reports clashes (one team member, overlapping times) instead of forcing
|
|
15
|
+
them;
|
|
16
|
+
- gift cards: keeps code + remaining balance (+ expiry) in their own
|
|
17
|
+
namespace (``IMPORT:<code>``) so they can never collide with new codes;
|
|
18
|
+
- returns counts, a sample, row errors and the import plan with an undo key.
|
|
19
|
+
|
|
20
|
+
Values (names, e-mails, phones, codes) never appear in the report: only
|
|
21
|
+
counts, column names, row numbers and unmatched service/staff names.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import csv
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from datetime import datetime, timedelta, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from zoneinfo import ZoneInfo
|
|
35
|
+
|
|
36
|
+
SYNONYMS = {
|
|
37
|
+
"first_name": ["first name", "firstname", "given name"],
|
|
38
|
+
"last_name": ["last name", "lastname", "surname", "family name"],
|
|
39
|
+
"name": ["client", "client name", "customer", "customer name", "full name", "name"],
|
|
40
|
+
"email": ["email", "e-mail", "email address", "client email"],
|
|
41
|
+
"phone": ["mobile number", "mobile", "phone", "phone number", "telephone", "client phone"],
|
|
42
|
+
"marketing": ["marketing consent", "accepts marketing", "marketing opt-in", "email marketing", "subscribed", "marketing notifications"],
|
|
43
|
+
"notes": ["notes", "client notes", "note"],
|
|
44
|
+
"external_id": ["client id", "customer id", "id", "ref #", "reference"],
|
|
45
|
+
"date": ["appointment date", "date", "scheduled date"],
|
|
46
|
+
"time": ["start time", "time", "scheduled time"],
|
|
47
|
+
"start": ["start", "start date time", "scheduled at", "appointment start"],
|
|
48
|
+
"service": ["service", "service name", "treatment"],
|
|
49
|
+
"staff": ["team member", "staff", "staff member", "employee", "therapist"],
|
|
50
|
+
"duration": ["duration", "duration (min)", "duration minutes"],
|
|
51
|
+
"price": ["price", "amount", "total", "sale price"],
|
|
52
|
+
"status": ["status", "appointment status"],
|
|
53
|
+
"code": ["code", "voucher code", "gift card code", "card code"],
|
|
54
|
+
"balance": ["balance", "remaining balance", "remaining", "value remaining"],
|
|
55
|
+
"expiry": ["expiry", "expires", "expiry date", "expiration date"],
|
|
56
|
+
}
|
|
57
|
+
YES = {"yes", "y", "true", "1", "opted in", "subscribed", "accepted"}
|
|
58
|
+
CANCELLED = re.compile(r"cancel|no.?show|deleted", re.I)
|
|
59
|
+
COUNTRY_CODES = {"uk": "44", "us": "1"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def norm(value: str) -> str:
|
|
63
|
+
return re.sub(r"[^a-z0-9]+", " ", (value or "").lower()).strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def map_columns(headers: list[str], wanted: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
67
|
+
mapping: dict[str, str] = {}
|
|
68
|
+
for header in headers:
|
|
69
|
+
key = norm(header)
|
|
70
|
+
for field in wanted:
|
|
71
|
+
if field not in mapping and key in {norm(s) for s in SYNONYMS[field]}:
|
|
72
|
+
mapping[field] = header
|
|
73
|
+
break
|
|
74
|
+
unmapped = [h for h in headers if h not in mapping.values()]
|
|
75
|
+
return mapping, unmapped
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def e164(phone: str, market: str) -> str | None:
|
|
79
|
+
digits = re.sub(r"\D", "", phone or "")
|
|
80
|
+
if not digits:
|
|
81
|
+
return None
|
|
82
|
+
if (phone or "").strip().startswith("+"):
|
|
83
|
+
return "+" + digits
|
|
84
|
+
code = COUNTRY_CODES[market]
|
|
85
|
+
if digits.startswith("00"):
|
|
86
|
+
return "+" + digits[2:]
|
|
87
|
+
if market == "uk" and digits.startswith("0"):
|
|
88
|
+
return "+44" + digits[1:]
|
|
89
|
+
if market == "us" and len(digits) == 10:
|
|
90
|
+
return "+1" + digits
|
|
91
|
+
return "+" + (digits if digits.startswith(code) else code + digits)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def money(value: str) -> int | None:
|
|
95
|
+
text = re.sub(r"[^\d.,-]", "", value or "").replace(",", "")
|
|
96
|
+
if not text:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
return int(round(float(text) * 100))
|
|
100
|
+
except ValueError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_local(row: dict, mapping: dict[str, str], zone: ZoneInfo) -> datetime | None:
|
|
105
|
+
if "start" in mapping and row.get(mapping["start"]):
|
|
106
|
+
text = row[mapping["start"]].strip()
|
|
107
|
+
elif "date" in mapping and "time" in mapping:
|
|
108
|
+
text = f"{row.get(mapping['date'], '').strip()} {row.get(mapping['time'], '').strip()}"
|
|
109
|
+
else:
|
|
110
|
+
return None
|
|
111
|
+
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%d/%m/%Y %H:%M", "%m/%d/%Y %H:%M", "%d %b %Y %H:%M", "%Y-%m-%d %I:%M %p", "%d/%m/%Y %I:%M %p", "%m/%d/%Y %I:%M %p"):
|
|
112
|
+
try:
|
|
113
|
+
return datetime.strptime(text, fmt).replace(tzinfo=zone)
|
|
114
|
+
except ValueError:
|
|
115
|
+
continue
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_csv(path: Path) -> tuple[list[str], list[dict]]:
|
|
120
|
+
with path.open(encoding="utf-8-sig", newline="") as handle:
|
|
121
|
+
reader = csv.DictReader(handle)
|
|
122
|
+
return list(reader.fieldnames or []), list(reader)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def migrate_check(clients: Path | None, appointments: Path | None, gift_cards: Path | None, timezone_name: str, market: str, now: datetime,
|
|
126
|
+
services: list[str], staff: list[str], date_order: str = "auto") -> dict:
|
|
127
|
+
zone = ZoneInfo(timezone_name)
|
|
128
|
+
report: dict = {"schemaVersion": "maggie-service-migration-dry-run.v1", "writes": False, "timezone": timezone_name, "market": market}
|
|
129
|
+
errors: list[str] = []
|
|
130
|
+
warnings: list[str] = []
|
|
131
|
+
digest = hashlib.sha256()
|
|
132
|
+
if clients:
|
|
133
|
+
headers, rows = read_csv(clients)
|
|
134
|
+
digest.update(clients.read_bytes())
|
|
135
|
+
mapping, unmapped = map_columns(headers, ["first_name", "last_name", "name", "email", "phone", "marketing", "notes", "external_id"])
|
|
136
|
+
if not ({"email", "phone"} & set(mapping)):
|
|
137
|
+
errors.append("clients: no e-mail or phone column recognised; map one before importing")
|
|
138
|
+
seen_email, seen_phone, dupes, consented, kept, row_errors = set(), set(), 0, 0, 0, []
|
|
139
|
+
for index, row in enumerate(rows, start=2):
|
|
140
|
+
email = (row.get(mapping.get("email", ""), "") or "").strip().lower()
|
|
141
|
+
phone = e164(row.get(mapping.get("phone", ""), ""), market) if "phone" in mapping else None
|
|
142
|
+
if not email and not phone and not (row.get(mapping.get("name", ""), "") or row.get(mapping.get("first_name", ""), "")):
|
|
143
|
+
row_errors.append(f"row {index}: empty")
|
|
144
|
+
continue
|
|
145
|
+
if (email and email in seen_email) or (not email and phone and phone in seen_phone):
|
|
146
|
+
dupes += 1
|
|
147
|
+
continue
|
|
148
|
+
if email:
|
|
149
|
+
seen_email.add(email)
|
|
150
|
+
if phone:
|
|
151
|
+
seen_phone.add(phone)
|
|
152
|
+
kept += 1
|
|
153
|
+
if "marketing" in mapping and (row.get(mapping["marketing"], "") or "").strip().lower() in YES:
|
|
154
|
+
consented += 1
|
|
155
|
+
report["clients"] = {"rows": len(rows), "toImport": kept, "duplicatesMerged": dupes, "marketingConsent": consented, "columns": mapping, "unmappedColumns": unmapped, "rowErrors": row_errors[:20]}
|
|
156
|
+
if "marketing" not in mapping:
|
|
157
|
+
warnings.append("clients: no marketing-consent column; imported clients get no marketing (consent is never assumed)")
|
|
158
|
+
if appointments:
|
|
159
|
+
headers, rows = read_csv(appointments)
|
|
160
|
+
digest.update(appointments.read_bytes())
|
|
161
|
+
mapping, unmapped = map_columns(headers, ["start", "date", "time", "service", "staff", "duration", "price", "status", "email", "phone", "name", "external_id"])
|
|
162
|
+
if not ("start" in mapping or {"date", "time"} <= set(mapping)) or "service" not in mapping:
|
|
163
|
+
errors.append("appointments: need a start (or date + time) and a service column")
|
|
164
|
+
service_keys = {norm(name): name for name in services}
|
|
165
|
+
staff_keys = {norm(name): name for name in staff}
|
|
166
|
+
future, past, cancelled, unmatched_services, unmatched_staff, bad_rows = [], 0, 0, set(), set(), []
|
|
167
|
+
for index, row in enumerate(rows, start=2):
|
|
168
|
+
if "status" in mapping and CANCELLED.search(row.get(mapping["status"], "") or ""):
|
|
169
|
+
cancelled += 1
|
|
170
|
+
continue
|
|
171
|
+
start = parse_local(row, mapping, zone)
|
|
172
|
+
if start is None:
|
|
173
|
+
bad_rows.append(f"row {index}: unreadable date/time")
|
|
174
|
+
continue
|
|
175
|
+
if start.astimezone(timezone.utc) <= now:
|
|
176
|
+
past += 1
|
|
177
|
+
continue
|
|
178
|
+
duration = int(re.sub(r"\D", "", row.get(mapping.get("duration", ""), "") or "") or 60)
|
|
179
|
+
service = row.get(mapping["service"], "")
|
|
180
|
+
person = row.get(mapping.get("staff", ""), "") if "staff" in mapping else ""
|
|
181
|
+
if services and norm(service) not in service_keys:
|
|
182
|
+
unmatched_services.add(service)
|
|
183
|
+
if person and staff and norm(person) not in staff_keys:
|
|
184
|
+
unmatched_staff.add(person)
|
|
185
|
+
future.append({"row": index, "startUtc": start.astimezone(timezone.utc), "end": start.astimezone(timezone.utc) + timedelta(minutes=duration), "staff": norm(person)})
|
|
186
|
+
clashes = []
|
|
187
|
+
by_staff: dict[str, list[dict]] = {}
|
|
188
|
+
for item in sorted(future, key=lambda a: a["startUtc"]):
|
|
189
|
+
if not item["staff"]:
|
|
190
|
+
continue
|
|
191
|
+
for other in by_staff.get(item["staff"], []):
|
|
192
|
+
if other["end"] > item["startUtc"]:
|
|
193
|
+
clashes.append(f"rows {other['row']} and {item['row']}: same team member at overlapping times")
|
|
194
|
+
by_staff.setdefault(item["staff"], []).append(item)
|
|
195
|
+
report["appointments"] = {"rows": len(rows), "future": len(future), "pastSkipped": past, "cancelledSkipped": cancelled, "clashes": clashes[:20], "unmatchedServices": sorted(unmatched_services)[:20],
|
|
196
|
+
"unmatchedStaff": sorted(unmatched_staff)[:20], "rowErrors": bad_rows[:20], "columns": mapping, "unmappedColumns": unmapped,
|
|
197
|
+
"firstUtc": _iso(min(a["startUtc"] for a in future)) if future else None}
|
|
198
|
+
if unmatched_services:
|
|
199
|
+
warnings.append(f"appointments: {len(unmatched_services)} service name(s) match no imported service; map them or create them first")
|
|
200
|
+
if clashes:
|
|
201
|
+
warnings.append(f"appointments: {len(clashes)} clash(es) are reported, not forced; resolve them with the business")
|
|
202
|
+
if gift_cards:
|
|
203
|
+
headers, rows = read_csv(gift_cards)
|
|
204
|
+
digest.update(gift_cards.read_bytes())
|
|
205
|
+
mapping, unmapped = map_columns(headers, ["code", "balance", "expiry"])
|
|
206
|
+
if not {"code", "balance"} <= set(mapping):
|
|
207
|
+
errors.append("gift cards: need code and balance columns")
|
|
208
|
+
codes, total, zero, bad = set(), 0, 0, []
|
|
209
|
+
for index, row in enumerate(rows, start=2):
|
|
210
|
+
code = (row.get(mapping.get("code", ""), "") or "").strip().upper()
|
|
211
|
+
balance = money(row.get(mapping.get("balance", ""), ""))
|
|
212
|
+
if not code or balance is None:
|
|
213
|
+
bad.append(f"row {index}: missing code or balance")
|
|
214
|
+
continue
|
|
215
|
+
if balance <= 0:
|
|
216
|
+
zero += 1
|
|
217
|
+
continue
|
|
218
|
+
if code in codes:
|
|
219
|
+
bad.append(f"row {index}: duplicate code")
|
|
220
|
+
continue
|
|
221
|
+
codes.add(code)
|
|
222
|
+
total += balance
|
|
223
|
+
report["giftCards"] = {"rows": len(rows), "toImport": len(codes), "zeroBalanceSkipped": zero, "totalBalanceMinor": total, "namespace": "IMPORT:<code>", "rowErrors": bad[:20], "columns": mapping, "unmappedColumns": unmapped}
|
|
224
|
+
report["undoKey"] = "import-" + digest.hexdigest()[:16]
|
|
225
|
+
report["plan"] = [
|
|
226
|
+
"create the import record (undo key) before writing",
|
|
227
|
+
"clients: insert or merge by e-mail then phone; marketing consent only when opted in",
|
|
228
|
+
"appointments: create bookings with source=import in the venue time zone; clashes stay unimported",
|
|
229
|
+
"gift cards: issue codes in the IMPORT: namespace with their balance and expiry in a paused 'Imported gift cards' voucher",
|
|
230
|
+
"undo deletes the import's bookings and the clients/team it created when unused, and voids untouched imported codes",
|
|
231
|
+
"send the optional \"we've moved\" e-mail once, only to imported clients with marketing consent, while online booking is live",
|
|
232
|
+
]
|
|
233
|
+
report["errors"], report["warnings"] = errors, warnings
|
|
234
|
+
report["status"] = "failed" if errors else ("warning" if warnings else "ready")
|
|
235
|
+
return report
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _iso(value: datetime) -> str:
|
|
239
|
+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main(argv: list[str] | None = None) -> int:
|
|
243
|
+
parser = argparse.ArgumentParser(prog="maggie service migrate-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
244
|
+
parser.add_argument("--project", type=Path, default=Path("."))
|
|
245
|
+
parser.add_argument("--clients", type=Path)
|
|
246
|
+
parser.add_argument("--appointments", type=Path)
|
|
247
|
+
parser.add_argument("--gift-cards", type=Path)
|
|
248
|
+
parser.add_argument("--timezone", required=True, help="the venue's IANA zone, e.g. Europe/London")
|
|
249
|
+
parser.add_argument("--market", choices=sorted(COUNTRY_CODES), default="uk")
|
|
250
|
+
parser.add_argument("--service", action="append", default=[], help="service name already in the catalogue (repeat)")
|
|
251
|
+
parser.add_argument("--staff", action="append", default=[], help="team member name (repeat)")
|
|
252
|
+
parser.add_argument("--now", help="ISO instant (default: now)")
|
|
253
|
+
parser.add_argument("--output", type=Path)
|
|
254
|
+
args = parser.parse_args(argv)
|
|
255
|
+
if not (args.clients or args.appointments or args.gift_cards):
|
|
256
|
+
parser.error("pass at least one of --clients, --appointments, --gift-cards")
|
|
257
|
+
now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc)
|
|
258
|
+
try:
|
|
259
|
+
report = migrate_check(args.clients, args.appointments, args.gift_cards, args.timezone, args.market, now, args.service, args.staff)
|
|
260
|
+
except (OSError, ValueError, csv.Error) as error:
|
|
261
|
+
print(f"BLOCKED: maggie service migrate-check: {error}", file=sys.stderr)
|
|
262
|
+
return 2
|
|
263
|
+
text = json.dumps(report, indent=2, ensure_ascii=False, default=str)
|
|
264
|
+
if args.output:
|
|
265
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
args.output.write_text(text + "\n", encoding="utf-8")
|
|
267
|
+
print(text)
|
|
268
|
+
return 1 if report["status"] == "failed" else 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
raise SystemExit(main())
|
|
@@ -318,6 +318,8 @@ def check_campaigns(document: dict) -> dict:
|
|
|
318
318
|
errors.append(f"campaign {cid} has invalid status {status}")
|
|
319
319
|
continue
|
|
320
320
|
blockers = campaign_readiness(campaign)
|
|
321
|
+
if campaign.get("plan_status") == "failed" and not campaign.get("ideas"):
|
|
322
|
+
errors.append(f"campaign {cid} was created only to be planned and the plan failed: delete it (or create it only after the plan succeeds) so retries do not pile up empty drafts")
|
|
321
323
|
if status == "started" and blockers:
|
|
322
324
|
errors.append(f"campaign {cid} is started but not startable: {'; '.join(blockers)}")
|
|
323
325
|
report.append({"id": cid, "status": status, "can_start": not blockers, "blockers": blockers,
|
|
@@ -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
|
|
@@ -15,6 +17,18 @@ from maggie_social_queue import check_campaigns, connection_health, parse_instan
|
|
|
15
17
|
|
|
16
18
|
LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
|
|
17
19
|
REQUIRED = ("source_content_id", "title", "canonical_url")
|
|
20
|
+
PUBLISH_MODES = ("image", "link_preview")
|
|
21
|
+
LIVE_STATUSES = {"approved", "scheduled", "publishing", "published"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def host_of(url: str) -> str:
|
|
25
|
+
return (urlsplit(str(url or "")).hostname or "").lower().removeprefix("www.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def on_workspace_domain(url: str, domain: str) -> bool:
|
|
29
|
+
"""True when the URL's host is the workspace domain or one of its subdomains."""
|
|
30
|
+
host, domain = host_of(url), str(domain or "").lower().strip().removeprefix("www.").rstrip(".")
|
|
31
|
+
return bool(host and domain) and (host == domain or host.endswith("." + domain))
|
|
18
32
|
|
|
19
33
|
|
|
20
34
|
def read(path: Path) -> dict:
|
|
@@ -31,38 +45,163 @@ def tracked_url(url: str, channel: str) -> str:
|
|
|
31
45
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
|
|
32
46
|
|
|
33
47
|
|
|
34
|
-
|
|
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
|
+
|
|
108
|
+
def plan(content: dict, channels: list[str], workspace_domain: str | None = None) -> dict:
|
|
35
109
|
errors = [f"missing {key}" for key in REQUIRED if not content.get(key)]
|
|
36
110
|
invalid = [channel for channel in channels if channel not in LIMITS]
|
|
37
111
|
if invalid:
|
|
38
112
|
errors.append("unsupported channels: " + ", ".join(invalid))
|
|
113
|
+
domain = workspace_domain or content.get("workspace_domain")
|
|
114
|
+
if not domain:
|
|
115
|
+
errors.append("workspace_domain is required: posts may only share articles published on the workspace's own site")
|
|
116
|
+
elif content.get("canonical_url") and not on_workspace_domain(content["canonical_url"], domain):
|
|
117
|
+
errors.append(f"canonical_url is on {host_of(content['canonical_url'])}, not {domain}: pick an article published on this workspace's own domain")
|
|
39
118
|
if errors:
|
|
40
119
|
raise ValueError("; ".join(errors))
|
|
120
|
+
image = content.get("image_url")
|
|
41
121
|
return {
|
|
42
122
|
"schema_version": "1.0",
|
|
43
123
|
"status": "draft",
|
|
44
124
|
"source_content_id": content["source_content_id"],
|
|
45
125
|
"title": content["title"],
|
|
46
126
|
"canonical_url": content["canonical_url"],
|
|
127
|
+
"workspace_domain": domain,
|
|
47
128
|
"cta": content.get("cta"),
|
|
48
|
-
"
|
|
129
|
+
"image_url": image,
|
|
130
|
+
# A post with its own image goes out as an image post with the link in the text;
|
|
131
|
+
# link_preview is the default only without an image, or when the person switches it.
|
|
132
|
+
"channels": [{"name": channel, "character_limit": LIMITS[channel], "tracked_url": tracked_url(content["canonical_url"], channel), "publish_mode": "image" if image else "link_preview", "copy": None, "status": "needs-copy"} for channel in channels],
|
|
49
133
|
"approval_required": True,
|
|
50
134
|
"publish_requires": ["approved", "active provider connection", "explicit execute flag"],
|
|
51
135
|
}
|
|
52
136
|
|
|
53
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
|
+
|
|
54
172
|
def validate(value: dict) -> list[str]:
|
|
55
173
|
errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
|
|
56
174
|
if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
|
|
57
175
|
errors.append("invalid status")
|
|
58
176
|
if value.get("status") == "published" and not value.get("provider_post_id"):
|
|
59
177
|
errors.append("published draft requires provider_post_id")
|
|
60
|
-
|
|
178
|
+
domain = value.get("workspace_domain")
|
|
179
|
+
if domain and value.get("canonical_url") and not on_workspace_domain(value["canonical_url"], domain):
|
|
180
|
+
errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
|
|
181
|
+
elif not domain and value.get("status") in LIVE_STATUSES:
|
|
182
|
+
errors.append("workspace_domain is required before approval: the source must be published on the workspace's own domain")
|
|
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:
|
|
61
187
|
name = channel.get("name")
|
|
62
188
|
if name not in LIMITS:
|
|
63
189
|
errors.append(f"unsupported channel: {name}")
|
|
64
|
-
|
|
65
|
-
|
|
190
|
+
mode = channel.get("publish_mode")
|
|
191
|
+
if mode is not None and mode not in PUBLISH_MODES:
|
|
192
|
+
errors.append(f"{name}: publish_mode must be image or link_preview")
|
|
193
|
+
has_image = bool(channel.get("image_url") or value.get("image_url"))
|
|
194
|
+
if has_image and mode is None:
|
|
195
|
+
errors.append(f"{name}: the post has an image, so publish_mode must be set (image by default)")
|
|
196
|
+
if has_image and mode == "link_preview" and channel.get("publish_mode_source") != "user":
|
|
197
|
+
errors.append(f"{name}: an attached image would not be published as a link preview; use publish_mode image, or record publish_mode_source user when the person chose the preview")
|
|
198
|
+
if mode == "image" and not has_image:
|
|
199
|
+
errors.append(f"{name}: publish_mode image needs an image")
|
|
200
|
+
copy = channel.get("copy")
|
|
201
|
+
if copy and mode == "image" and channel.get("tracked_url") and channel["tracked_url"] not in copy and value.get("canonical_url", "") not in copy:
|
|
202
|
+
errors.append(f"{name}: an image post carries the link in its text; add the tracked URL to the copy")
|
|
203
|
+
if copy and len(copy) > LIMITS.get(name, 0):
|
|
204
|
+
errors.append(f"copy exceeds {name} limit" + (" (the link counts)" if mode == "image" else ""))
|
|
66
205
|
return errors
|
|
67
206
|
|
|
68
207
|
|
|
@@ -72,7 +211,15 @@ def main() -> int:
|
|
|
72
211
|
create = sub.add_parser("plan", help="create a draft channel contract from one content object")
|
|
73
212
|
create.add_argument("input")
|
|
74
213
|
create.add_argument("--channels", default="linkedin,facebook")
|
|
214
|
+
create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
|
|
75
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)
|
|
76
223
|
check = sub.add_parser("validate", help="validate a draft channel contract")
|
|
77
224
|
check.add_argument("input")
|
|
78
225
|
adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
|
|
@@ -89,14 +236,30 @@ def main() -> int:
|
|
|
89
236
|
queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
|
|
90
237
|
queue.add_argument("--slots", type=int, default=14)
|
|
91
238
|
queue.add_argument("--output")
|
|
239
|
+
schema = sub.add_parser("schema-check", help="estimate a structured-output JSON schema's complexity before a model rejects it")
|
|
240
|
+
schema.add_argument("input")
|
|
92
241
|
args = parser.parse_args()
|
|
93
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
|
|
94
252
|
value = read(Path(args.input))
|
|
95
253
|
now = datetime.now(timezone.utc)
|
|
96
|
-
if args.command == "
|
|
254
|
+
if args.command == "schema-check":
|
|
255
|
+
from maggie_schema_complexity import check_schema
|
|
256
|
+
result = check_schema(value)
|
|
257
|
+
elif args.command == "adapters":
|
|
97
258
|
result = validate_adapters(value)
|
|
98
259
|
elif args.command == "connections":
|
|
99
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})
|
|
100
263
|
elif args.command == "reconcile":
|
|
101
264
|
result = reconcile(value)
|
|
102
265
|
elif args.command == "campaigns":
|
|
@@ -110,7 +273,11 @@ def main() -> int:
|
|
|
110
273
|
return 0
|
|
111
274
|
else:
|
|
112
275
|
if args.command == "plan":
|
|
113
|
-
|
|
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)")
|
|
280
|
+
value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
|
|
114
281
|
if args.output:
|
|
115
282
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
116
283
|
Path(args.output).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -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
|