@topy-ai/maggie 0.7.0 → 0.7.1
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.md +22 -7
- package/bin/maggie.js +6 -6
- package/bundled-references/maggiedash-dashboard-ui.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +9 -0
- package/bundled-skills/maggie-dash/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/SKILL.md +17 -0
- package/bundled-skills/maggie-ops/SKILL.md +27 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +21 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +6 -0
- package/bundled-templates/maggiedash/README.md +4 -0
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
- package/bundled-tools/clis/maggie_analytics.py +14 -1
- package/bundled-tools/clis/maggie_blog.py +6 -0
- package/bundled-tools/clis/maggie_dash.py +54 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -0
- package/bundled-tools/clis/maggie_feedback.py +16 -1
- package/bundled-tools/clis/maggie_ops.py +21 -1
- package/bundled-tools/clis/maggie_service_booking.py +6 -3
- package/bundled-tools/clis/maggie_sitemap.py +16 -2
- package/bundled-tools/runtime/analytics_traffic.py +30 -0
- package/bundled-tools/runtime/content_localization.py +63 -1
- package/bundled-tools/runtime/dependency_lock.py +43 -0
- package/bundled-tools/runtime/integration_state.py +17 -0
- package/bundled-tools/runtime/maggie_dash_store.py +150 -6
- package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
- package/bundled-tools/runtime/maggie_sitemap.py +50 -4
- package/bundled-tools/runtime/route_imports.py +51 -0
- package/bundled-tools/runtime/seed_evidence.py +25 -0
- package/package.json +1 -1
- package/references/maggiedash-dashboard-ui.md +28 -0
|
@@ -9,6 +9,10 @@ import sys
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
|
+
from dependency_lock import audit as audit_lockfiles
|
|
14
|
+
from seed_evidence import validate_manifest
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
REQUIRED_ROUTES = (
|
|
14
18
|
"/ops", "/ops/posts", "/ops/sitemap", "/ops/reports",
|
|
@@ -132,14 +136,30 @@ def command_verify(args: argparse.Namespace) -> int:
|
|
|
132
136
|
return 0 if result["checks"]["ready"] and result["stateFile"] else 1
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
def command_lockfiles(args: argparse.Namespace) -> int:
|
|
140
|
+
result = audit_lockfiles(root(args))
|
|
141
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
142
|
+
return 0 if result["passed"] else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def command_seed_manifest(args: argparse.Namespace) -> int:
|
|
146
|
+
value = read_json(Path(args.manifest).resolve())
|
|
147
|
+
result = validate_manifest(value)
|
|
148
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
149
|
+
return 0 if result["passed"] else 1
|
|
150
|
+
|
|
151
|
+
|
|
135
152
|
def main() -> int:
|
|
136
153
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
137
154
|
parser.add_argument("--project", default=".")
|
|
138
155
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
139
|
-
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify)):
|
|
156
|
+
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify), ("lockfiles", command_lockfiles)):
|
|
140
157
|
command = sub.add_parser(name)
|
|
141
158
|
if name == "preflight": command.add_argument("--write", action="store_true")
|
|
142
159
|
command.set_defaults(func=handler)
|
|
160
|
+
seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
|
|
161
|
+
seed.add_argument("--manifest", required=True)
|
|
162
|
+
seed.set_defaults(func=command_seed_manifest)
|
|
143
163
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
144
164
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
145
165
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -10,6 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
from urllib.parse import quote, urljoin, urlsplit
|
|
11
11
|
from urllib.request import Request, urlopen
|
|
12
12
|
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
14
|
+
from route_imports import imported_components # noqa: E402
|
|
15
|
+
|
|
13
16
|
NOW = lambda: datetime.now(timezone.utc).isoformat()
|
|
14
17
|
MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
|
|
15
18
|
DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
|
|
@@ -751,7 +754,7 @@ def inventory_candidates(project, pages_root):
|
|
|
751
754
|
found={}
|
|
752
755
|
for page in pages_root.rglob("*") if pages_root.exists() else []:
|
|
753
756
|
if page.is_file() and page.suffix.lower() in {".astro",".md",".mdx",".html"} and page.stem not in {"index","404"}:
|
|
754
|
-
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem"}
|
|
757
|
+
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem","imports":imported_components(page)}
|
|
755
758
|
for name in ("pages.json","page-content.json"):
|
|
756
759
|
source=project/"docs"/name
|
|
757
760
|
if not source.exists(): continue
|
|
@@ -762,7 +765,7 @@ def inventory_candidates(project, pages_root):
|
|
|
762
765
|
if not isinstance(item,dict): continue
|
|
763
766
|
value=item.get("path") or item.get("url") or item.get("route")
|
|
764
767
|
if not value: continue
|
|
765
|
-
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory"}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
768
|
+
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory","imports":[]}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
766
769
|
return list(found.values())
|
|
767
770
|
def cmd_match_pages_review(args):
|
|
768
771
|
project=root(args); data=load(project); candidates=inventory_candidates(project,(project/args.pages_dir).resolve()); aliases={"lymphatic drainage":{"lymphatic","lymph drainage","lymphdrainage","manual lymphatic","ml d"},"deep tissue":{"deep tissue","therapeutic massage"},"thai":{"thai massage","thai"},"postpartum":{"postpartum","new mom"},"facial":{"facial","hydra facial","hydrafacial"},"massage":{"massage"}}
|
|
@@ -779,7 +782,7 @@ def cmd_match_pages_review(args):
|
|
|
779
782
|
matches=[]
|
|
780
783
|
for page in candidates:
|
|
781
784
|
path_slug=slug(page["path"].rsplit("/",1)[-1].rsplit(".",1)[0]); page_words=page_tokens(path_slug+" "+page["text"][:2000]); exact=path_slug==service.get("slug"); overlap=len(expanded & page_words)/max(1,len(expanded)); generic=bool(concepts & {"massage","facial","acupuncture","waxing"}) and overlap<0.75; score=1.0 if exact else min(0.49 if generic else overlap,0.99)
|
|
782
|
-
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
785
|
+
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"imports":page.get("imports",[]),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
783
786
|
matches.sort(key=lambda x:(-x["confidence"],x["path"])); report.append({"serviceId":service["id"],"title":service["title"],"candidates":matches})
|
|
784
787
|
for path_value in selections.get(service["id"], []):
|
|
785
788
|
if path_value in {m["path"] for m in matches}: selected.append((service,path_value))
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
|
-
from maggie_sitemap import build_plan, parse_routes, validate_plan_data # noqa: E402
|
|
13
|
+
from maggie_sitemap import agent_files, build_plan, parse_routes, validate_plan_data # noqa: E402
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def write_json(path: str, value: dict) -> None:
|
|
@@ -31,6 +31,12 @@ def main() -> int:
|
|
|
31
31
|
plan.add_argument("--output", required=True)
|
|
32
32
|
validate = sub.add_parser("validate")
|
|
33
33
|
validate.add_argument("--plan", required=True)
|
|
34
|
+
validate.add_argument("--strict-semantic", action="store_true")
|
|
35
|
+
agent = sub.add_parser("agent-files")
|
|
36
|
+
agent.add_argument("--origin", required=True)
|
|
37
|
+
agent.add_argument("--routes-file", required=True)
|
|
38
|
+
agent.add_argument("--output-dir", required=True)
|
|
39
|
+
agent.add_argument("--locale", default="en")
|
|
34
40
|
apply = sub.add_parser("apply")
|
|
35
41
|
apply.add_argument("--plan", required=True)
|
|
36
42
|
apply.add_argument("--public-dir", required=True)
|
|
@@ -65,9 +71,17 @@ def main() -> int:
|
|
|
65
71
|
restored += 1
|
|
66
72
|
print(json.dumps({"status": "rolled-back", "restored": restored}, indent=2))
|
|
67
73
|
return 0
|
|
74
|
+
if args.command == "agent-files":
|
|
75
|
+
output = Path(args.output_dir)
|
|
76
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
files = agent_files(parse_routes(Path(args.routes_file).read_text(encoding="utf-8")), args.origin, args.locale)
|
|
78
|
+
for name, content in files.items():
|
|
79
|
+
(output / name).write_text(content, encoding="utf-8")
|
|
80
|
+
print(json.dumps({"status": "generated", "locale": args.locale, "files": [str(output / name) for name in files]}, indent=2))
|
|
81
|
+
return 0
|
|
68
82
|
plan_value = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
69
83
|
if args.command == "validate":
|
|
70
|
-
result = validate_plan_data(plan_value)
|
|
84
|
+
result = validate_plan_data(plan_value, args.strict_semantic)
|
|
71
85
|
print(json.dumps(result, indent=2))
|
|
72
86
|
return 0 if result["status"] == "pass" else 1
|
|
73
87
|
if not args.confirm:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Safe classification of known first-party/toolchain traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
TOOL_USER_AGENT = re.compile(r"(?:maggie|playwright|puppeteer|selenium|lighthouse|headlesschrome|synthetic|test[-_ ]?sweep)", re.I)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_tool_traffic(event: dict) -> bool:
|
|
13
|
+
if str(event.get("maggieTest") or "").lower() == "true":
|
|
14
|
+
return True
|
|
15
|
+
if str(event.get("xMaggieTest") or "").lower() == "true":
|
|
16
|
+
return True
|
|
17
|
+
return bool(TOOL_USER_AGENT.search(str(event.get("userAgent") or "")))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def audit_events(events: Iterable[dict]) -> dict:
|
|
21
|
+
totals = Counter()
|
|
22
|
+
excluded = Counter()
|
|
23
|
+
included = 0
|
|
24
|
+
for event in events:
|
|
25
|
+
totals[str(event.get("event") or "unknown")] += 1
|
|
26
|
+
if is_tool_traffic(event):
|
|
27
|
+
excluded[str(event.get("event") or "unknown")] += 1
|
|
28
|
+
else:
|
|
29
|
+
included += 1
|
|
30
|
+
return {"schemaVersion": "maggie-analytics-traffic.v1", "total": sum(totals.values()), "included": included, "excluded": sum(excluded.values()), "byEvent": dict(sorted(totals.items())), "excludedByEvent": dict(sorted(excluded.items())), "policy": "exclude only explicit Maggie/toolchain markers; no IP or identity inference"}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Iterable
|
|
7
7
|
|
|
8
8
|
# Deliberately broad, dependency-free registry of the major web/content
|
|
9
9
|
# languages. Script variants remain distinct where they affect copy, search,
|
|
@@ -19,6 +19,68 @@ OPERATIONS = {"translate", "polish", "rewrite", "localise", "rebrand", "manual_e
|
|
|
19
19
|
PROTECTED_FIELDS = {"price", "currency", "rating", "provider", "providerId", "bookingUrl", "paymentUrl", "legalClaims", "healthClaims", "contentId", "slug", "canonicalUrl", "translationGroupId"}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class TranslationIndex:
|
|
23
|
+
"""One canonical index for translated records and their public wording.
|
|
24
|
+
|
|
25
|
+
Hosts may persist this structure wherever they keep content, but they must
|
|
26
|
+
build it once and reject conflicting duplicate `(contentId, locale)` rows.
|
|
27
|
+
This prevents a registry and a second dictionary from silently disagreeing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, records: Iterable[dict[str, Any]] = ()):
|
|
31
|
+
self._records: dict[tuple[str, str], dict[str, Any]] = {}
|
|
32
|
+
self.conflicts: list[dict[str, Any]] = []
|
|
33
|
+
for record in records:
|
|
34
|
+
self.add(record)
|
|
35
|
+
|
|
36
|
+
def add(self, record: dict[str, Any]) -> None:
|
|
37
|
+
content_id = str(record.get("contentId") or "").strip()
|
|
38
|
+
locale = str(record.get("locale") or "").strip()
|
|
39
|
+
if not content_id or not locale:
|
|
40
|
+
raise ValueError("translation index records require contentId and locale")
|
|
41
|
+
key = (content_id, locale)
|
|
42
|
+
previous = self._records.get(key)
|
|
43
|
+
if previous and previous != record:
|
|
44
|
+
self.conflicts.append({"key": key, "existing": previous, "incoming": record})
|
|
45
|
+
raise ValueError(f"conflicting translation record for {content_id}/{locale}")
|
|
46
|
+
self._records[key] = dict(record)
|
|
47
|
+
|
|
48
|
+
def get(self, content_id: str, locale: str) -> dict[str, Any] | None:
|
|
49
|
+
record = self._records.get((content_id, locale))
|
|
50
|
+
return dict(record) if record else None
|
|
51
|
+
|
|
52
|
+
def records(self) -> list[dict[str, Any]]:
|
|
53
|
+
return [dict(self._records[key]) for key in sorted(self._records)]
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict[str, dict[str, Any]]:
|
|
56
|
+
return {f"{content_id}:{locale}": dict(record) for (content_id, locale), record in sorted(self._records.items())}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_translated_path(path: str, exact_paths: Iterable[str] = (), prefixes: Iterable[str] = ()) -> bool:
|
|
60
|
+
"""Use exact routes and normalized prefix routes with identical semantics."""
|
|
61
|
+
normalized = "/" + str(path or "").lstrip("/")
|
|
62
|
+
normalized = normalized.rstrip("/") or "/"
|
|
63
|
+
exact = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in exact_paths}
|
|
64
|
+
normalized_prefixes = {("/" + str(item).lstrip("/")).rstrip("/") or "/" for item in prefixes}
|
|
65
|
+
return normalized in exact or any(normalized == prefix or normalized.startswith(prefix + "/") for prefix in normalized_prefixes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def should_localize_path(path: str, locale_prefixes: Iterable[str], static_extensions: Iterable[str] = (".txt", ".md", ".xml", ".json")) -> bool:
|
|
69
|
+
"""Locale middleware only handles localized routes, never root static files."""
|
|
70
|
+
normalized = "/" + str(path or "").lstrip("/")
|
|
71
|
+
if any(normalized.lower().endswith(extension.lower()) for extension in static_extensions):
|
|
72
|
+
return False
|
|
73
|
+
return is_translated_path(normalized, prefixes=locale_prefixes)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rewrite_once(request_key: str, seen: set[str]) -> bool:
|
|
77
|
+
"""Return true only for the first rewrite observation of a request."""
|
|
78
|
+
if request_key in seen:
|
|
79
|
+
return False
|
|
80
|
+
seen.add(request_key)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
22
84
|
def parse_locale(value: Any) -> tuple[str | None, str | None, str | None]:
|
|
23
85
|
if not isinstance(value, str):
|
|
24
86
|
return None, None, None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Detect package-manager drift before an npm-ci deployment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def package_dependencies(project: Path) -> set[str]:
|
|
9
|
+
data = json.loads((project / "package.json").read_text(encoding="utf-8"))
|
|
10
|
+
return set(data.get("dependencies", {})) | set(data.get("devDependencies", {})) | set(data.get("optionalDependencies", {}))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def npm_lock_dependencies(path: Path) -> set[str]:
|
|
14
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
15
|
+
packages = data.get("packages", {})
|
|
16
|
+
names = {key.removeprefix("node_modules/") for key in packages
|
|
17
|
+
if key.startswith("node_modules/") and "/node_modules/" not in key}
|
|
18
|
+
if not packages and isinstance(data.get("dependencies"), dict):
|
|
19
|
+
names = set(data["dependencies"])
|
|
20
|
+
return names
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pnpm_lock_dependencies(path: Path) -> set[str]:
|
|
24
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
25
|
+
return {match.group(1) for match in re.finditer(r"^\s{4,}/?(@[^/\s]+/[^/\s]+|[A-Za-z0-9_.-]+)@[^:]+:", text, re.M)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def audit(project: Path) -> dict:
|
|
29
|
+
errors = []
|
|
30
|
+
try:
|
|
31
|
+
declared = package_dependencies(project)
|
|
32
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
33
|
+
return {"passed": False, "errors": [f"package.json: {error}"]}
|
|
34
|
+
npm = project / "package-lock.json"
|
|
35
|
+
pnpm = project / "pnpm-lock.yaml"
|
|
36
|
+
npm_names = npm_lock_dependencies(npm) if npm.exists() else set()
|
|
37
|
+
pnpm_names = pnpm_lock_dependencies(pnpm) if pnpm.exists() else set()
|
|
38
|
+
if not npm.exists(): errors.append("package-lock.json is required by npm ci")
|
|
39
|
+
missing = sorted(declared - npm_names) if npm.exists() else sorted(declared)
|
|
40
|
+
if missing: errors.append("package-lock missing declared dependencies: " + ", ".join(missing))
|
|
41
|
+
return {"schemaVersion": "maggie-dependency-lock-audit.v1", "passed": not errors,
|
|
42
|
+
"errors": errors, "declared": sorted(declared), "npmLock": sorted(npm_names),
|
|
43
|
+
"pnpmLock": sorted(pnpm_names), "lockfiles": {"npm": npm.exists(), "pnpm": pnpm.exists()}}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Explicit state model for optional Search/AI visibility integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def integration_state(*, configured: bool, consent_required: bool = False, consent: bool = False, authorized: bool = False, error: str | None = None) -> dict:
|
|
7
|
+
if error:
|
|
8
|
+
status = "error"
|
|
9
|
+
elif not configured:
|
|
10
|
+
status = "not-configured"
|
|
11
|
+
elif consent_required and not consent:
|
|
12
|
+
status = "awaiting-consent"
|
|
13
|
+
elif not authorized:
|
|
14
|
+
status = "awaiting-authorization"
|
|
15
|
+
else:
|
|
16
|
+
status = "ready"
|
|
17
|
+
return {"status": status, "configured": configured, "consentRequired": consent_required, "consent": consent, "authorized": authorized, "reason": error or status}
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import hashlib
|
|
7
|
+
import base64
|
|
8
|
+
import hmac
|
|
7
9
|
import json
|
|
8
10
|
import sqlite3
|
|
9
11
|
import uuid
|
|
10
|
-
from datetime import datetime, timezone
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Any
|
|
13
15
|
|
|
@@ -55,6 +57,20 @@ class MaggieDashStore:
|
|
|
55
57
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
56
58
|
UNIQUE(project_id, slug)
|
|
57
59
|
);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS maggiedash_document_revisions (
|
|
61
|
+
id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES maggiedash_documents(id),
|
|
62
|
+
revision INTEGER NOT NULL, title TEXT NOT NULL, slug TEXT NOT NULL,
|
|
63
|
+
locale TEXT NOT NULL, excerpt TEXT NOT NULL, content_json TEXT NOT NULL,
|
|
64
|
+
canonical_url TEXT, provenance_json TEXT NOT NULL, checksum TEXT NOT NULL,
|
|
65
|
+
created_at TEXT NOT NULL, created_by TEXT NOT NULL,
|
|
66
|
+
UNIQUE(document_id, revision)
|
|
67
|
+
);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS maggiedash_redirects (
|
|
69
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES maggiedash_projects(id),
|
|
70
|
+
from_path TEXT NOT NULL, to_path TEXT NOT NULL, status_code INTEGER NOT NULL DEFAULT 301,
|
|
71
|
+
reason TEXT NOT NULL, actor_id TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
72
|
+
UNIQUE(project_id, from_path)
|
|
73
|
+
);
|
|
58
74
|
CREATE TABLE IF NOT EXISTS maggiedash_approvals (
|
|
59
75
|
id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES maggiedash_documents(id),
|
|
60
76
|
from_status TEXT NOT NULL, to_status TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
@@ -68,6 +84,15 @@ class MaggieDashStore:
|
|
|
68
84
|
);
|
|
69
85
|
"""
|
|
70
86
|
)
|
|
87
|
+
self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
|
|
88
|
+
self._ensure_column("maggiedash_documents", "trashed_from_status", "TEXT")
|
|
89
|
+
self._ensure_column("maggiedash_documents", "publish_at", "TEXT")
|
|
90
|
+
self.connection.commit()
|
|
91
|
+
|
|
92
|
+
def _ensure_column(self, table: str, column: str, definition: str) -> None:
|
|
93
|
+
columns = {row[1] for row in self.connection.execute(f"PRAGMA table_info({table})")}
|
|
94
|
+
if column not in columns:
|
|
95
|
+
self.connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
|
71
96
|
|
|
72
97
|
def close(self) -> None:
|
|
73
98
|
self.connection.close()
|
|
@@ -97,22 +122,27 @@ class MaggieDashStore:
|
|
|
97
122
|
raise ValueError(f"invalid document status: {status}")
|
|
98
123
|
stable = {key: value for key, value in document.items() if key not in {"provenance", "checksum"}}
|
|
99
124
|
document_checksum = document.get("provenance", {}).get("checksum") or checksum(stable)
|
|
100
|
-
existing = self.connection.execute("SELECT
|
|
125
|
+
existing = self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone()
|
|
101
126
|
if existing and existing["status"] != status:
|
|
102
127
|
raise ValueError("status changes must use transition()")
|
|
128
|
+
if existing and existing["checksum"] != document_checksum:
|
|
129
|
+
self._store_revision(existing, actor_id)
|
|
103
130
|
self.connection.execute(
|
|
104
131
|
"""INSERT INTO maggiedash_documents
|
|
105
|
-
(id,project_id,kind,title,slug,locale,status,excerpt,content_json,canonical_url,provenance_json,checksum,created_at,updated_at)
|
|
106
|
-
VALUES (
|
|
132
|
+
(id,project_id,kind,title,slug,locale,status,excerpt,content_json,canonical_url,provenance_json,checksum,created_at,updated_at,publish_at)
|
|
133
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
107
134
|
ON CONFLICT(id) DO UPDATE SET title=excluded.title, slug=excluded.slug,
|
|
108
135
|
locale=excluded.locale, excerpt=excluded.excerpt, content_json=excluded.content_json,
|
|
109
136
|
canonical_url=excluded.canonical_url, provenance_json=excluded.provenance_json,
|
|
110
|
-
checksum=excluded.checksum, updated_at=excluded.updated_at
|
|
137
|
+
checksum=excluded.checksum, updated_at=excluded.updated_at,
|
|
138
|
+
publish_at=excluded.publish_at, trashed_at=NULL, trashed_from_status=NULL""",
|
|
111
139
|
(document["id"], project_id, document["kind"], document["title"], document["slug"], document["locale"],
|
|
112
140
|
status, document.get("excerpt", ""), json.dumps(document["content"], ensure_ascii=False),
|
|
113
141
|
document.get("canonicalUrl"), json.dumps(document["provenance"], ensure_ascii=False), document_checksum,
|
|
114
|
-
timestamp, timestamp),
|
|
142
|
+
timestamp, timestamp, document.get("publishAt")),
|
|
115
143
|
)
|
|
144
|
+
if not existing:
|
|
145
|
+
self._store_revision(self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone(), actor_id)
|
|
116
146
|
self._audit(project_id, "document.upserted", "document", document["id"], actor_id, "draft content stored")
|
|
117
147
|
self.connection.commit()
|
|
118
148
|
return self.get_document(project_id, document["id"]) or {}
|
|
@@ -125,8 +155,122 @@ class MaggieDashStore:
|
|
|
125
155
|
result["content"] = json.loads(result.pop("content_json"))
|
|
126
156
|
result["provenance"] = json.loads(result.pop("provenance_json"))
|
|
127
157
|
result["canonicalUrl"] = result.pop("canonical_url")
|
|
158
|
+
result["trashedAt"] = result.pop("trashed_at")
|
|
159
|
+
result["trashedFromStatus"] = result.pop("trashed_from_status")
|
|
160
|
+
result["publishAt"] = result.pop("publish_at")
|
|
128
161
|
return result
|
|
129
162
|
|
|
163
|
+
def _store_revision(self, row: sqlite3.Row, actor_id: str) -> None:
|
|
164
|
+
revision = self.connection.execute(
|
|
165
|
+
"SELECT COALESCE(MAX(revision), 0) + 1 FROM maggiedash_document_revisions WHERE document_id=?",
|
|
166
|
+
(row["id"],),
|
|
167
|
+
).fetchone()[0]
|
|
168
|
+
self.connection.execute(
|
|
169
|
+
"INSERT INTO maggiedash_document_revisions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
170
|
+
(str(uuid.uuid4()), row["id"], revision, row["title"], row["slug"], row["locale"], row["excerpt"],
|
|
171
|
+
row["content_json"], row["canonical_url"], row["provenance_json"], row["checksum"], row["updated_at"], actor_id),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def list_revisions(self, project_id: str, document_id: str) -> list[dict[str, Any]]:
|
|
175
|
+
if not self.get_document(project_id, document_id):
|
|
176
|
+
raise ValueError("document not found")
|
|
177
|
+
rows = self.connection.execute(
|
|
178
|
+
"SELECT * FROM maggiedash_document_revisions WHERE document_id=? ORDER BY revision DESC", (document_id,)
|
|
179
|
+
).fetchall()
|
|
180
|
+
return [dict(row) for row in rows]
|
|
181
|
+
|
|
182
|
+
def trash(self, project_id: str, document_id: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
183
|
+
row = self.connection.execute("SELECT status, trashed_at FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
|
|
184
|
+
if not row:
|
|
185
|
+
raise ValueError("document not found")
|
|
186
|
+
if row["trashed_at"]:
|
|
187
|
+
return self.get_document(project_id, document_id) or {}
|
|
188
|
+
timestamp = now()
|
|
189
|
+
self.connection.execute("UPDATE maggiedash_documents SET trashed_at=?, trashed_from_status=?, updated_at=? WHERE id=?", (timestamp, row["status"], timestamp, document_id))
|
|
190
|
+
self._audit(project_id, "document.trashed", "document", document_id, actor_id, reason)
|
|
191
|
+
self.connection.commit()
|
|
192
|
+
return self.get_document(project_id, document_id) or {}
|
|
193
|
+
|
|
194
|
+
def restore(self, project_id: str, document_id: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
195
|
+
row = self.connection.execute("SELECT trashed_at, trashed_from_status FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
|
|
196
|
+
if not row:
|
|
197
|
+
raise ValueError("document not found")
|
|
198
|
+
if not row["trashed_at"]:
|
|
199
|
+
return self.get_document(project_id, document_id) or {}
|
|
200
|
+
status = row["trashed_from_status"] if row["trashed_from_status"] in STATUSES else "draft"
|
|
201
|
+
timestamp = now()
|
|
202
|
+
self.connection.execute("UPDATE maggiedash_documents SET status=?, trashed_at=NULL, trashed_from_status=NULL, updated_at=? WHERE id=?", (status, timestamp, document_id))
|
|
203
|
+
self._audit(project_id, "document.restored", "document", document_id, actor_id, reason)
|
|
204
|
+
self.connection.commit()
|
|
205
|
+
return self.get_document(project_id, document_id) or {}
|
|
206
|
+
|
|
207
|
+
def schedule_publish(self, project_id: str, document_id: str, publish_at: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
208
|
+
try:
|
|
209
|
+
target = datetime.fromisoformat(publish_at.replace("Z", "+00:00"))
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
raise ValueError("publish_at must be an ISO-8601 timestamp") from exc
|
|
212
|
+
if target.tzinfo is None:
|
|
213
|
+
raise ValueError("publish_at must include a timezone")
|
|
214
|
+
document = self.get_document(project_id, document_id)
|
|
215
|
+
if not document:
|
|
216
|
+
raise ValueError("document not found")
|
|
217
|
+
if document["status"] not in {"approved", "scheduled"}:
|
|
218
|
+
raise ValueError("only approved documents can be scheduled")
|
|
219
|
+
if document["status"] == "approved":
|
|
220
|
+
self.transition(project_id, document_id, "scheduled", actor_id, reason)
|
|
221
|
+
self.connection.execute("UPDATE maggiedash_documents SET publish_at=?, updated_at=? WHERE id=?", (publish_at, now(), document_id))
|
|
222
|
+
self._audit(project_id, "document.publish_scheduled", "document", document_id, actor_id, reason)
|
|
223
|
+
self.connection.commit()
|
|
224
|
+
return self.get_document(project_id, document_id) or {}
|
|
225
|
+
|
|
226
|
+
def duplicate(self, project_id: str, document_id: str, new_id: str, new_slug: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
227
|
+
source = self.get_document(project_id, document_id)
|
|
228
|
+
if not source:
|
|
229
|
+
raise ValueError("document not found")
|
|
230
|
+
if self.connection.execute("SELECT 1 FROM maggiedash_documents WHERE project_id=? AND slug=?", (project_id, new_slug)).fetchone():
|
|
231
|
+
raise ValueError("new slug already exists")
|
|
232
|
+
copy = dict(source)
|
|
233
|
+
copy.update({"id": new_id, "slug": new_slug, "status": "draft", "provenance": {**source["provenance"], "duplicatedFrom": document_id}})
|
|
234
|
+
copy.pop("trashedAt", None); copy.pop("trashedFromStatus", None); copy.pop("publishAt", None)
|
|
235
|
+
result = self.put_document(project_id, copy, actor_id)
|
|
236
|
+
self._audit(project_id, "document.duplicated", "document", new_id, actor_id, reason)
|
|
237
|
+
self.connection.commit()
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def add_redirect(self, project_id: str, from_path: str, to_path: str, actor_id: str, reason: str, status_code: int = 301) -> dict[str, Any]:
|
|
241
|
+
if not from_path.startswith("/") or not to_path.startswith("/") or from_path == to_path:
|
|
242
|
+
raise ValueError("redirect paths must be distinct absolute paths")
|
|
243
|
+
if status_code not in {301, 302, 307, 308}:
|
|
244
|
+
raise ValueError("status_code must be 301, 302, 307 or 308")
|
|
245
|
+
record = (str(uuid.uuid4()), project_id, from_path, to_path, status_code, reason, actor_id, now())
|
|
246
|
+
self.connection.execute("INSERT INTO maggiedash_redirects VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(project_id,from_path) DO UPDATE SET to_path=excluded.to_path, status_code=excluded.status_code, reason=excluded.reason, actor_id=excluded.actor_id, created_at=excluded.created_at", record)
|
|
247
|
+
self._audit(project_id, "redirect.upserted", "redirect", from_path, actor_id, reason)
|
|
248
|
+
self.connection.commit()
|
|
249
|
+
row = self.connection.execute("SELECT * FROM maggiedash_redirects WHERE project_id=? AND from_path=?", (project_id, from_path)).fetchone()
|
|
250
|
+
return dict(row)
|
|
251
|
+
|
|
252
|
+
def issue_preview(self, project_id: str, document_id: str, secret: str, ttl_seconds: int = 900) -> dict[str, Any]:
|
|
253
|
+
if not secret:
|
|
254
|
+
raise ValueError("preview secret is required")
|
|
255
|
+
if not self.get_document(project_id, document_id):
|
|
256
|
+
raise ValueError("document not found")
|
|
257
|
+
expires = int((datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).timestamp())
|
|
258
|
+
payload = f"{project_id}:{document_id}:{expires}".encode()
|
|
259
|
+
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
260
|
+
signature = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest()
|
|
261
|
+
return {"token": f"{encoded}.{signature}", "expiresAt": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z")}
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def verify_preview(token: str, secret: str, project_id: str, document_id: str) -> bool:
|
|
265
|
+
try:
|
|
266
|
+
encoded, signature = token.split(".", 1)
|
|
267
|
+
expected = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest()
|
|
268
|
+
payload = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
|
|
269
|
+
token_project, token_document, expires = payload.split(":", 2)
|
|
270
|
+
return hmac.compare_digest(signature, expected) and token_project == project_id and token_document == document_id and int(expires) >= int(datetime.now(timezone.utc).timestamp())
|
|
271
|
+
except (ValueError, TypeError, UnicodeDecodeError):
|
|
272
|
+
return False
|
|
273
|
+
|
|
130
274
|
def transition(self, project_id: str, document_id: str, target: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
131
275
|
if target not in STATUSES:
|
|
132
276
|
raise ValueError(f"invalid target status: {target}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Validate the provider-neutral MaggieDash dashboard chrome contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SCHEMA = "maggiedash-dashboard-ui.v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _component_source(name: str, source: str) -> str:
|
|
14
|
+
match = re.search(rf"(?:function\s+{re.escape(name)}\s*\([^)]*\)|const\s+{re.escape(name)}\s*=\s*\([^)]*\))(?P<body>[\s\S]{{0,12000}})", source)
|
|
15
|
+
return match.group(0) if match else ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate(contract: object, sources: Iterable[str] = ()) -> dict:
|
|
19
|
+
errors: list[str] = []
|
|
20
|
+
if not isinstance(contract, dict):
|
|
21
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["contract must be an object"]}
|
|
22
|
+
if contract.get("schemaVersion") != SCHEMA:
|
|
23
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
24
|
+
shell = contract.get("shell")
|
|
25
|
+
if not isinstance(shell, dict):
|
|
26
|
+
errors.append("shell is required")
|
|
27
|
+
elif shell.get("reference") != "users-workspace" or shell.get("navigation") != "sidebar-plus-content-tabs":
|
|
28
|
+
errors.append("shell must declare the users-workspace reference and sidebar-plus-content-tabs navigation")
|
|
29
|
+
components = contract.get("components")
|
|
30
|
+
if not isinstance(components, list) or not components:
|
|
31
|
+
errors.append("components must be a non-empty list")
|
|
32
|
+
components = []
|
|
33
|
+
source = "\n".join(str(item) for item in sources)
|
|
34
|
+
for component in components:
|
|
35
|
+
if not isinstance(component, dict) or not component.get("name"):
|
|
36
|
+
errors.append("each component needs a name")
|
|
37
|
+
continue
|
|
38
|
+
name = str(component["name"])
|
|
39
|
+
component_source = _component_source(name, source)
|
|
40
|
+
if source and not component_source:
|
|
41
|
+
errors.append(f"component source is missing: {name}")
|
|
42
|
+
continue
|
|
43
|
+
props = [str(prop) for prop in component.get("props", [])]
|
|
44
|
+
forbidden = [str(prop) for prop in component.get("forbiddenProps", [])]
|
|
45
|
+
if component_source:
|
|
46
|
+
signature = component_source.split("=>", 1)[0] if "=>" in component_source else component_source[:500]
|
|
47
|
+
for prop in props:
|
|
48
|
+
if len(re.findall(rf"\b{re.escape(prop)}\b", component_source)) < 2:
|
|
49
|
+
errors.append(f"{name}.{prop} is declared but not evidenced in rendered output")
|
|
50
|
+
for prop in forbidden:
|
|
51
|
+
if re.search(rf"\b{re.escape(prop)}\b", signature):
|
|
52
|
+
errors.append(f"{name} declares forbidden dead prop: {prop}")
|
|
53
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "components": [item.get("name") for item in components if isinstance(item, dict)]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_and_validate(contract_path: Path, source_paths: Iterable[Path] = ()) -> dict:
|
|
57
|
+
import json
|
|
58
|
+
contract = json.loads(contract_path.read_text(encoding="utf-8"))
|
|
59
|
+
sources = [path.read_text(encoding="utf-8", errors="replace") for path in source_paths]
|
|
60
|
+
return validate(contract, sources)
|