sylo-ignition 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/assets/write-allowlist.json +19 -0
- package/extensions/index.ts +349 -0
- package/package.json +36 -0
- package/references/README.md +45 -0
- package/references/gateway-rest-api-8.3.md +119 -0
- package/references/quickref/README.md +12 -0
- package/references/quickref/formats-quickref.md +94 -0
- package/references/quickref/gateway-rest-api-8.3.md +119 -0
- package/scripts/_allowlist.py +119 -0
- package/scripts/_ignition.py +181 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/api_get.py +71 -0
- package/scripts/backup.py +49 -0
- package/scripts/fetch_docs.py +278 -0
- package/scripts/gateway_logs.py +57 -0
- package/scripts/project_create.py +64 -0
- package/scripts/project_resources.py +127 -0
- package/scripts/requirements.txt +3 -0
- package/scripts/resource_read.py +102 -0
- package/scripts/resource_write.py +171 -0
- package/scripts/scan.py +85 -0
- package/scripts/screenshot.py +96 -0
- package/scripts/status.py +91 -0
- package/scripts/validate.py +252 -0
- package/skills/ignition/SKILL.md +131 -0
- package/skills/ignition-reference/SKILL.md +157 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch Ignition documentation locally for the sylo-ignition package.
|
|
3
|
+
|
|
4
|
+
Downloads the Ignition 8.3 User Manual (docs.inductiveautomation.com) and the
|
|
5
|
+
Ignition SDK Programmer's Guide (sdk-docs.inductiveautomation.com) — both
|
|
6
|
+
Docusaurus static sites — and converts every page to offline Markdown.
|
|
7
|
+
|
|
8
|
+
Also downloads the official 8.1 PDF manual bundle (legacy-docs exports) so the
|
|
9
|
+
8.1 fallback path has offline coverage.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python fetch_docs.py # full run (download + convert + pdfs)
|
|
13
|
+
python fetch_docs.py --no-download # convert from existing html cache
|
|
14
|
+
python fetch_docs.py --no-pdfs
|
|
15
|
+
python fetch_docs.py --refresh # re-download even if cache exists
|
|
16
|
+
|
|
17
|
+
Requires: pandoc 3.x on PATH. Python 3.10+ (stdlib only).
|
|
18
|
+
|
|
19
|
+
Output layout (relative to packages/sylo-ignition/):
|
|
20
|
+
references/user-manual-8.3/<page-path>.md
|
|
21
|
+
references/sdk-docs/<page-path>.md
|
|
22
|
+
references/user-manual-8.1-pdfs/*.pdf
|
|
23
|
+
references/README.md is maintained by hand (provenance/index).
|
|
24
|
+
|
|
25
|
+
Scratch cache: ~/igscrape/html/{user,sdk}/...
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import concurrent.futures as cf
|
|
32
|
+
import posixpath
|
|
33
|
+
import re
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
import urllib.request
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
USER_SITE = "https://www.docs.inductiveautomation.com"
|
|
41
|
+
SDK_SITE = "https://www.sdk-docs.inductiveautomation.com"
|
|
42
|
+
USER_SITEMAP = USER_SITE + "/sitemap.xml"
|
|
43
|
+
SDK_SITEMAP = SDK_SITE + "/sitemap.xml"
|
|
44
|
+
|
|
45
|
+
# Official 8.1 PDF exports (legacy-docs page, taken 2024-02-21).
|
|
46
|
+
PDF_81 = [
|
|
47
|
+
"DOC-81-1-welcome-intro-other-editions",
|
|
48
|
+
"DOC-81-2-0-vision",
|
|
49
|
+
"DOC-81-2-1-perspective",
|
|
50
|
+
"DOC-81-2-2-opc-ua-and-drivers",
|
|
51
|
+
"DOC-81-2-3-tag-historian-and-sql-bridge",
|
|
52
|
+
"DOC-81-2-4-reporting",
|
|
53
|
+
"DOC-81-2-5-alarm-notification",
|
|
54
|
+
"DOC-81-2-6-enterprise-administration",
|
|
55
|
+
"DOC-81-2-7-sequential-function-charts",
|
|
56
|
+
"DOC-81-2-8-secsgem",
|
|
57
|
+
"DOC-81-2-9-symbol-factory-webdev-mongodb-connector",
|
|
58
|
+
"DOC-81-3-platform",
|
|
59
|
+
"DOC-81-4-scripting-functions",
|
|
60
|
+
"DOC-81-5-expression-functions",
|
|
61
|
+
"DOC-81-6-1-vision-components",
|
|
62
|
+
"DOC-81-6-2-perspective-components",
|
|
63
|
+
"DOC-81-6-3-reporting-components",
|
|
64
|
+
]
|
|
65
|
+
PDF_81_BASE = "https://d1v6u62vatllb2.cloudfront.net/81/"
|
|
66
|
+
|
|
67
|
+
PKG_ROOT = Path(__file__).resolve().parent.parent
|
|
68
|
+
REFS = PKG_ROOT / "references"
|
|
69
|
+
SCRATCH = Path.home() / "igscrape"
|
|
70
|
+
HTML_CACHE = SCRATCH / "html"
|
|
71
|
+
|
|
72
|
+
UA = {"User-Agent": "Mozilla/5.0 (sylo-ignition doc fetcher)"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def http_get(url: str, retries: int = 3) -> bytes:
|
|
76
|
+
last = None
|
|
77
|
+
for attempt in range(retries):
|
|
78
|
+
try:
|
|
79
|
+
req = urllib.request.Request(url, headers=UA)
|
|
80
|
+
with urllib.request.urlopen(req, timeout=45) as r:
|
|
81
|
+
return r.read()
|
|
82
|
+
except Exception as exc: # noqa: BLE001
|
|
83
|
+
last = exc
|
|
84
|
+
time.sleep(1.0 + attempt)
|
|
85
|
+
raise RuntimeError(f"failed {url}: {last}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sitemap_urls(sitemap_url: str, keep_prefixes: tuple[str, ...]) -> list[str]:
|
|
89
|
+
xml = http_get(sitemap_url).decode("utf-8", errors="replace")
|
|
90
|
+
urls = re.findall(r"<loc>([^<]+)</loc>", xml)
|
|
91
|
+
return [u for u in urls if any(u.startswith(p) for p in keep_prefixes)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def local_rel(site_url: str, url: str, version_prefix: str) -> str:
|
|
95
|
+
"""Map a docs URL to a local path under the manual root (no version dir)."""
|
|
96
|
+
rel = url[len(site_url):]
|
|
97
|
+
rel = rel[len(version_prefix):] if rel.startswith(version_prefix) else rel
|
|
98
|
+
rel = rel.strip("/")
|
|
99
|
+
return (rel + "/index.md") if rel == "" or url.endswith("/") else rel + ".md"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def download_all(jobs: list[tuple[str, Path]], workers: int = 8) -> tuple[int, int]:
|
|
103
|
+
ok, fail = 0, 0
|
|
104
|
+
|
|
105
|
+
def one(job):
|
|
106
|
+
url, dest = job
|
|
107
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
if dest.exists() and dest.stat().st_size > 0:
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
data = http_get(url)
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
dest.write_bytes(data)
|
|
115
|
+
return len(data) > 200
|
|
116
|
+
|
|
117
|
+
with cf.ThreadPoolExecutor(max_workers=workers) as ex:
|
|
118
|
+
for r in ex.map(one, jobs):
|
|
119
|
+
ok += 1 if r else 0
|
|
120
|
+
fail += 0 if r else 1
|
|
121
|
+
return ok, fail
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- conversion
|
|
125
|
+
|
|
126
|
+
PANDOC = ["pandoc", "-f", "html", "-t", "gfm", "--wrap=none"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pandoc(fragment: str, fmt: str = "gfm") -> str:
|
|
130
|
+
args = ["pandoc", "-f", "html", "-t", fmt, "--wrap=none"]
|
|
131
|
+
p = subprocess.run(args, input=fragment.encode(), capture_output=True)
|
|
132
|
+
return p.stdout.decode("utf-8", errors="replace")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def html_to_md(html: str, source_url: str) -> str | None:
|
|
136
|
+
"""Extract the Docusaurus markdown container and convert to Markdown."""
|
|
137
|
+
k = html.find("theme-doc-markdown")
|
|
138
|
+
if k < 0:
|
|
139
|
+
return None
|
|
140
|
+
i = html.find(">", k) + 1 # start AFTER the container's opening tag
|
|
141
|
+
j = html.rfind("</article>")
|
|
142
|
+
if i <= 0 or j < 0:
|
|
143
|
+
return None
|
|
144
|
+
frag = html[i:j] # container closes naturally inside the article
|
|
145
|
+
|
|
146
|
+
# Destructive simplifications so pandoc emits clean Markdown:
|
|
147
|
+
frag = re.sub(r"<colgroup>.*?</colgroup>", "", frag, flags=re.S) # widths -> pipe tables
|
|
148
|
+
frag = re.sub(r'<nav class="pagination-nav.*?</nav>', "", frag, flags=re.S)
|
|
149
|
+
frag = re.sub(r"<footer.*?</footer>", "", frag, flags=re.S)
|
|
150
|
+
# <details>/<summary> -> bold heading (avoids raw-HTML blocks)
|
|
151
|
+
frag = re.sub(r"</?(details|summary)( [^>]*)?>", "", frag)
|
|
152
|
+
# Trim the trailing "Edit this page" metadata row.
|
|
153
|
+
frag = re.sub(r'<div class="row margin-top--sm theme-doc-footer-edit-meta-row">.*', "", frag, flags=re.S)
|
|
154
|
+
|
|
155
|
+
# Flatten div/span/header/section wrappers — gfm cannot represent them
|
|
156
|
+
# anyway; stripping the tags keeps their content as clean flow.
|
|
157
|
+
frag = re.sub(r"</?div[^>]*>", "", frag)
|
|
158
|
+
frag = re.sub(r"</?span[^>]*>", "", frag)
|
|
159
|
+
frag = re.sub(r"</?header[^>]*>", "", frag)
|
|
160
|
+
frag = re.sub(r"</?section[^>]*>", "", frag)
|
|
161
|
+
|
|
162
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
|
|
163
|
+
title = m.group(1).split("|")[0].strip() if m else ""
|
|
164
|
+
|
|
165
|
+
md = pandoc(frag)
|
|
166
|
+
|
|
167
|
+
# De-duplicate the in-content H1 (we already emit `# {title}`).
|
|
168
|
+
first = md.split("\n", 1)[0].strip()
|
|
169
|
+
if first == f"# {title}":
|
|
170
|
+
md = md.split("\n", 1)[1].lstrip("\n") if "\n" in md else ""
|
|
171
|
+
# Hybrid table pass: tables that GFM could not express stay as raw <table>
|
|
172
|
+
# HTML blocks; re-run those through pandoc's markdown (grid tables).
|
|
173
|
+
def gridify(match: re.Match) -> str:
|
|
174
|
+
return pandoc(match.group(0), "markdown").strip() or match.group(0)
|
|
175
|
+
|
|
176
|
+
md = re.sub(r"<table>.*?</table>", gridify, md, flags=re.S)
|
|
177
|
+
|
|
178
|
+
# Post-conversion cleanup: drop heading hash-links and empty card anchors.
|
|
179
|
+
md = re.sub(r'<a href="#[^"]*" class="hash-link"[^>]*>.*?</a>', "", md)
|
|
180
|
+
md = re.sub(r'<a href="/docs/8\.3/[^"]*" class="card[^"]*"[^>]*>\s*</a>', "", md)
|
|
181
|
+
|
|
182
|
+
# Absolute-ize image URLs (images are not mirrored; keep them resolvable).
|
|
183
|
+
site = USER_SITE if "docs.inductiveautomation.com" in source_url else SDK_SITE
|
|
184
|
+
md = re.sub(r'\((/img/[^)]+)\)', lambda m2: f"({site}{m2.group(1)})", md)
|
|
185
|
+
|
|
186
|
+
header = f"# {title}\n\n> Source: {source_url}\n\n"
|
|
187
|
+
return header + md.strip() + "\n"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def rewrite_links(md: str, cur_rel: str, rels: set[str], version_prefix: str, site: str) -> str:
|
|
191
|
+
"""Rewrite internal docs links to relative .md paths when the target is mirrored."""
|
|
192
|
+
link_re = re.compile(r"\]\((/docs/8\.3/[^)#\s]+?)(/?(#[^)\s]*)?)\)")
|
|
193
|
+
|
|
194
|
+
def repl(m: re.Match) -> str:
|
|
195
|
+
path, _slash, anchor = m.group(1), m.group(2), m.group(3) or ""
|
|
196
|
+
rel = local_rel(site, site + path, version_prefix)
|
|
197
|
+
if rel not in rels:
|
|
198
|
+
return m.group(0) # not mirrored — leave the absolute link
|
|
199
|
+
here = posixpath.dirname(cur_rel)
|
|
200
|
+
target = posixpath.relpath(rel, here) if here else rel
|
|
201
|
+
return f"]({target}{anchor})"
|
|
202
|
+
|
|
203
|
+
return link_re.sub(repl, md)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def convert_site(site: str, sitemap_url: str, version_prefix: str, cache: Path, out_root: Path, download: bool = True) -> dict:
|
|
207
|
+
urls = sitemap_urls(sitemap_url, (site + version_prefix,))
|
|
208
|
+
# Drop auto-generated category index pages and version pickers.
|
|
209
|
+
urls = [u for u in urls if "/category/" not in u and u.rstrip("/") != site + "/versions"]
|
|
210
|
+
jobs = [(u, cache / (local_rel(site, u, version_prefix).removesuffix(".md") + ".html")) for u in urls]
|
|
211
|
+
if download:
|
|
212
|
+
print(f"[{site}] {len(jobs)} pages to download")
|
|
213
|
+
ok, fail = download_all(jobs)
|
|
214
|
+
print(f"[{site}] downloaded ok={ok} fail={fail}")
|
|
215
|
+
else:
|
|
216
|
+
jobs = [(u, p) for u, p in jobs if p.exists()]
|
|
217
|
+
print(f"[{site}] converting {len(jobs)} cached pages")
|
|
218
|
+
|
|
219
|
+
stats = {"pages": 0, "skipped": 0, "bytes": 0}
|
|
220
|
+
rels = {local_rel(site, u, version_prefix) for u in urls}
|
|
221
|
+
for url, html_path in jobs:
|
|
222
|
+
if not html_path.exists():
|
|
223
|
+
stats["skipped"] += 1
|
|
224
|
+
continue
|
|
225
|
+
html = html_path.read_text(encoding="utf-8", errors="replace")
|
|
226
|
+
md = html_to_md(html, url)
|
|
227
|
+
if md is None:
|
|
228
|
+
stats["skipped"] += 1
|
|
229
|
+
continue
|
|
230
|
+
rel = local_rel(site, url, version_prefix)
|
|
231
|
+
md = rewrite_links(md, rel, rels, version_prefix, site)
|
|
232
|
+
dest = out_root / rel
|
|
233
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
dest.write_text(md, encoding="utf-8")
|
|
235
|
+
stats["pages"] += 1
|
|
236
|
+
stats["bytes"] += len(md)
|
|
237
|
+
print(f"[{site}] converted {stats['pages']} pages, {stats['bytes']/1e6:.1f} MB md, skipped {stats['skipped']}")
|
|
238
|
+
return stats
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def fetch_pdfs() -> None:
|
|
242
|
+
out = REFS / "user-manual-8.1-pdfs"
|
|
243
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
for name in PDF_81:
|
|
245
|
+
dest = out / f"{name}.pdf"
|
|
246
|
+
if dest.exists() and dest.stat().st_size > 0:
|
|
247
|
+
continue
|
|
248
|
+
url = PDF_81_BASE + name + ".pdf"
|
|
249
|
+
try:
|
|
250
|
+
dest.write_bytes(http_get(url))
|
|
251
|
+
print(f"[pdf] {name}.pdf ({dest.stat().st_size/1e6:.1f} MB)")
|
|
252
|
+
except Exception as exc: # noqa: BLE001
|
|
253
|
+
print(f"[pdf] FAILED {name}: {exc}", file=sys.stderr)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> None:
|
|
257
|
+
ap = argparse.ArgumentParser()
|
|
258
|
+
ap.add_argument("--no-download", action="store_true")
|
|
259
|
+
ap.add_argument("--no-pdfs", action="store_true")
|
|
260
|
+
ap.add_argument("--refresh", action="store_true")
|
|
261
|
+
args = ap.parse_args()
|
|
262
|
+
|
|
263
|
+
if args.refresh and HTML_CACHE.exists():
|
|
264
|
+
for p in HTML_CACHE.rglob("*.html"):
|
|
265
|
+
p.unlink()
|
|
266
|
+
|
|
267
|
+
if args.no_download:
|
|
268
|
+
convert_site(USER_SITE, USER_SITEMAP, "/docs/8.3/", HTML_CACHE / "user", REFS / "user-manual-8.3", download=False)
|
|
269
|
+
convert_site(SDK_SITE, SDK_SITEMAP, "/docs/8.3/", HTML_CACHE / "sdk", REFS / "sdk-docs", download=False)
|
|
270
|
+
else:
|
|
271
|
+
convert_site(USER_SITE, USER_SITEMAP, "/docs/8.3/", HTML_CACHE / "user", REFS / "user-manual-8.3")
|
|
272
|
+
convert_site(SDK_SITE, SDK_SITEMAP, "/docs/8.3/", HTML_CACHE / "sdk", REFS / "sdk-docs")
|
|
273
|
+
if not args.no_pdfs:
|
|
274
|
+
fetch_pdfs()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read gateway logs via REST — for debugging scan failures / runtime errors.
|
|
3
|
+
|
|
4
|
+
For ignition_gateway_logs tool. GET /data/api/v1/logs with query filters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from _ignition import api, load_config
|
|
14
|
+
from _json_out import emit, emit_error
|
|
15
|
+
|
|
16
|
+
MAX_CHARS = 30_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser()
|
|
21
|
+
parser.add_argument("--limit", type=int, default=100)
|
|
22
|
+
parser.add_argument("--min-level", default="", help="e.g. WARN, ERROR")
|
|
23
|
+
parser.add_argument("--search", default="", help="substring search across log messages")
|
|
24
|
+
parser.add_argument("--logger", default="", help="logger name filter")
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
cfg = load_config()
|
|
28
|
+
params = [f"limit={args.limit}"]
|
|
29
|
+
if args.min_level:
|
|
30
|
+
params.append(f"minLevel={args.min_level}")
|
|
31
|
+
if args.search:
|
|
32
|
+
params.append(f"search={args.search}")
|
|
33
|
+
if args.logger:
|
|
34
|
+
params.append(f"logger={args.logger}")
|
|
35
|
+
route = "/data/api/v1/logs?" + "&".join(params)
|
|
36
|
+
|
|
37
|
+
status, data, _ = api(cfg, route, timeout=20)
|
|
38
|
+
if status >= 400:
|
|
39
|
+
emit_error(f"GET {route} -> HTTP {status}: {str(data)[:200]}")
|
|
40
|
+
|
|
41
|
+
text = json.dumps(data, indent=2) if not isinstance(data, str) else data
|
|
42
|
+
truncated = False
|
|
43
|
+
if len(text) > MAX_CHARS:
|
|
44
|
+
text = text[:MAX_CHARS]
|
|
45
|
+
truncated = True
|
|
46
|
+
|
|
47
|
+
out: dict[str, Any] = {
|
|
48
|
+
"ok": True,
|
|
49
|
+
"route": route,
|
|
50
|
+
"truncated": truncated,
|
|
51
|
+
"logs": data if not truncated else text + "\n...[truncated]",
|
|
52
|
+
}
|
|
53
|
+
emit(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a NEW Ignition project via REST, for ignition_project_create tool.
|
|
3
|
+
|
|
4
|
+
GATED by the write-allowlist (allow_project_create). Creating a project is
|
|
5
|
+
isolated (new empty folder) — the lowest-risk gateway write there is. After
|
|
6
|
+
creation the project appears on disk under data/projects/<name>/ after a scan.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from _allowlist import gate_project_create, load_allowlist
|
|
15
|
+
from _ignition import api, load_config, project_dir
|
|
16
|
+
from _json_out import emit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser()
|
|
21
|
+
parser.add_argument("--name", required=True)
|
|
22
|
+
parser.add_argument("--title", default="")
|
|
23
|
+
parser.add_argument("--description", default="")
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
|
|
26
|
+
allow = load_allowlist()
|
|
27
|
+
gate_project_create(allow, args.name)
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
|
|
30
|
+
body = {
|
|
31
|
+
"name": args.name,
|
|
32
|
+
"title": args.title or args.name,
|
|
33
|
+
"description": args.description,
|
|
34
|
+
"enabled": True,
|
|
35
|
+
"inheritable": False,
|
|
36
|
+
"parent": "",
|
|
37
|
+
}
|
|
38
|
+
status, resp, _ = api(cfg, "/data/api/v1/projects", method="POST", body=body, timeout=30)
|
|
39
|
+
if status >= 400:
|
|
40
|
+
hint = resp.get("message") if isinstance(resp, dict) else str(resp)[:200]
|
|
41
|
+
emit({"ok": False, "error": f"Create project -> HTTP {status}: {hint}"})
|
|
42
|
+
|
|
43
|
+
# Ask the gateway to scan so the folder lands on disk
|
|
44
|
+
scan_status, _, _ = api(cfg, "/data/api/v1/scan/projects", method="POST", body={}, timeout=30)
|
|
45
|
+
|
|
46
|
+
pdir = project_dir(cfg, args.name)
|
|
47
|
+
out: dict[str, Any] = {
|
|
48
|
+
"ok": True,
|
|
49
|
+
"project": args.name,
|
|
50
|
+
"created": True,
|
|
51
|
+
"scan_triggered": scan_status == 200,
|
|
52
|
+
"expected_dir": str(pdir),
|
|
53
|
+
"dir_on_disk": pdir.is_dir(),
|
|
54
|
+
"operator_chat": (
|
|
55
|
+
f"Project '{args.name}' created{' and scanned' if scan_status == 200 else ''}. "
|
|
56
|
+
"To let the agent write its resources, make sure it is enabled in the "
|
|
57
|
+
"write-allowlist (assets/write-allowlist.json) — the agent must not edit that file."
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
emit(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resource tree listing for a project, for ignition_project_resources tool.
|
|
3
|
+
|
|
4
|
+
Walks data/projects/<project>/ and returns resource folders (dirs with
|
|
5
|
+
resource.json) plus notable files. Module scope is the first path segment
|
|
6
|
+
(e.g. com.inductiveautomation.perspective).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from _ignition import load_config, project_dir, resolve_project
|
|
16
|
+
from _json_out import emit, emit_error
|
|
17
|
+
|
|
18
|
+
MAX_ENTRIES = 600
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
parser = argparse.ArgumentParser()
|
|
23
|
+
parser.add_argument("--project", default="")
|
|
24
|
+
parser.add_argument("--filter", default="", help="case-insensitive substring filter on path")
|
|
25
|
+
parser.add_argument("--files", action="store_true", help="also list individual files")
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
cfg = load_config(require_token=False)
|
|
29
|
+
name = resolve_project(cfg, args.project)
|
|
30
|
+
root = project_dir(cfg, name)
|
|
31
|
+
if not root.is_dir():
|
|
32
|
+
emit_error(
|
|
33
|
+
f"Project folder not found on disk: {root}. If the project exists in the gateway "
|
|
34
|
+
"but not on disk, it may need a scan (POST /data/api/v1/scan/projects) — or the "
|
|
35
|
+
"data_dir in config is wrong."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
flt = args.filter.strip().lower()
|
|
39
|
+
resources: list[dict[str, Any]] = []
|
|
40
|
+
files: list[dict[str, Any]] = []
|
|
41
|
+
for dirpath, dirnames, filenames in sorted_walk(root):
|
|
42
|
+
rel = Path(dirpath).relative_to(root).as_posix()
|
|
43
|
+
if rel.count("/") == 0 and not rel:
|
|
44
|
+
continue # project root: project.json handled separately
|
|
45
|
+
has_resource = "resource.json" in filenames
|
|
46
|
+
is_resource_folder = has_resource
|
|
47
|
+
if not is_resource_folder and not args.files:
|
|
48
|
+
continue
|
|
49
|
+
entry: dict[str, Any] = {"path": rel, "module": rel.split("/")[0]}
|
|
50
|
+
if is_resource_folder:
|
|
51
|
+
entry["kind"] = "resource"
|
|
52
|
+
payload = detect_payload(filenames)
|
|
53
|
+
entry["payload"] = payload
|
|
54
|
+
entry["folder"] = dirpath
|
|
55
|
+
resources.append(entry)
|
|
56
|
+
else:
|
|
57
|
+
entry["kind"] = "file"
|
|
58
|
+
entry["files"] = sorted(filenames)
|
|
59
|
+
files.append(entry)
|
|
60
|
+
if len(resources) + len(files) >= MAX_ENTRIES:
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
def keep(e: dict[str, Any]) -> bool:
|
|
64
|
+
return not flt or flt in e["path"].lower()
|
|
65
|
+
|
|
66
|
+
resources = [e for e in resources if keep(e)]
|
|
67
|
+
files = [e for e in files if keep(e)]
|
|
68
|
+
by_module: dict[str, int] = {}
|
|
69
|
+
for e in resources:
|
|
70
|
+
by_module[e["module"]] = by_module.get(e["module"], 0) + 1
|
|
71
|
+
|
|
72
|
+
emit(
|
|
73
|
+
{
|
|
74
|
+
"ok": True,
|
|
75
|
+
"project": name,
|
|
76
|
+
"root": str(root),
|
|
77
|
+
"project_json": (root / "project.json").is_file(),
|
|
78
|
+
"resource_count": len(resources),
|
|
79
|
+
"by_module": by_module,
|
|
80
|
+
"resources": [
|
|
81
|
+
{"path": e["path"], "module": e["module"], "payload": e["payload"]} for e in resources
|
|
82
|
+
],
|
|
83
|
+
"file_groups": [{"path": e["path"], "files": e["files"]} for e in files],
|
|
84
|
+
"hint": (
|
|
85
|
+
"Read any resource with ignition_resource_read using path=<resource folder path>. "
|
|
86
|
+
"Perspective views live under com.inductiveautomation.perspective/views/."
|
|
87
|
+
)
|
|
88
|
+
if not args.files
|
|
89
|
+
else "",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sorted_walk(root: Path):
|
|
95
|
+
"""Deterministic walk: dirs sorted by name."""
|
|
96
|
+
stack = [root]
|
|
97
|
+
while stack:
|
|
98
|
+
d = stack.pop()
|
|
99
|
+
try:
|
|
100
|
+
entries = sorted(d.iterdir(), key=lambda p: p.name)
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
dirnames, filenames = [], []
|
|
104
|
+
for p in entries:
|
|
105
|
+
if p.name in ("thumbnail.png",):
|
|
106
|
+
continue
|
|
107
|
+
if p.is_dir():
|
|
108
|
+
dirnames.append(p.name)
|
|
109
|
+
stack.append(p)
|
|
110
|
+
else:
|
|
111
|
+
filenames.append(p.name)
|
|
112
|
+
yield str(d), dirnames, filenames
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def detect_payload(filenames: list[str]) -> str:
|
|
116
|
+
for f in ("view.json", "config.json", "props.json", "style_classes.json"):
|
|
117
|
+
if f in filenames:
|
|
118
|
+
return f
|
|
119
|
+
if any(f.endswith(".py") for f in filenames):
|
|
120
|
+
return ".py"
|
|
121
|
+
if filenames:
|
|
122
|
+
return filenames[0]
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read a resource/file from a project, for ignition_resource_read tool."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from _ignition import load_config, project_dir, resolve_project
|
|
12
|
+
from _json_out import emit, emit_error
|
|
13
|
+
|
|
14
|
+
BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bin", ".idb", ".zip", ".ttf", ".woff", ".woff2"}
|
|
15
|
+
DEFAULT_MAX_CHARS = 40_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser()
|
|
20
|
+
parser.add_argument("--project", default="")
|
|
21
|
+
parser.add_argument("--path", required=True, help="path relative to project root")
|
|
22
|
+
parser.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS)
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
cfg = load_config(require_token=False)
|
|
26
|
+
name = resolve_project(cfg, args.project)
|
|
27
|
+
root = project_dir(cfg, name)
|
|
28
|
+
target = safe_join(root, args.path)
|
|
29
|
+
|
|
30
|
+
if not target.exists():
|
|
31
|
+
emit_error(
|
|
32
|
+
f"Not found: {args.path} (in {root}). List resources with ignition_project_resources. "
|
|
33
|
+
"If you just created the folder, note the gateway may not have scanned it yet."
|
|
34
|
+
)
|
|
35
|
+
if target.is_dir():
|
|
36
|
+
# Directory: show the folder's files (resource.json + payload)
|
|
37
|
+
listing = sorted(p.name for p in target.iterdir())
|
|
38
|
+
emit(
|
|
39
|
+
{
|
|
40
|
+
"ok": True,
|
|
41
|
+
"project": name,
|
|
42
|
+
"path": args.path,
|
|
43
|
+
"type": "directory",
|
|
44
|
+
"files": listing,
|
|
45
|
+
"hint": "Read a specific file by appending it to the path.",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
if target.suffix.lower() in BINARY_EXTS:
|
|
49
|
+
emit_error(
|
|
50
|
+
f"{args.path} is binary ({target.suffix}) — not readable as text. "
|
|
51
|
+
"For images use the gateway web UI; for .bin resources use the Designer."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
raw = target.read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
data: Any = raw
|
|
56
|
+
json_parsed = True
|
|
57
|
+
if target.suffix == ".json":
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(raw)
|
|
60
|
+
except (json.JSONDecodeError, ValueError):
|
|
61
|
+
json_parsed = False
|
|
62
|
+
data = raw
|
|
63
|
+
|
|
64
|
+
truncated = False
|
|
65
|
+
if isinstance(data, str) and len(data) > args.max_chars:
|
|
66
|
+
data = data[: args.max_chars]
|
|
67
|
+
truncated = True
|
|
68
|
+
|
|
69
|
+
out: dict[str, Any] = {
|
|
70
|
+
"ok": True,
|
|
71
|
+
"project": name,
|
|
72
|
+
"path": args.path,
|
|
73
|
+
"abs_path": str(target),
|
|
74
|
+
"size_bytes": target.stat().st_size,
|
|
75
|
+
"truncated": truncated,
|
|
76
|
+
"content": data,
|
|
77
|
+
}
|
|
78
|
+
if target.name == "resource.json" and json_parsed:
|
|
79
|
+
out["hint"] = (
|
|
80
|
+
"resource.json attributes are gateway-managed (signatures/timestamps). "
|
|
81
|
+
"Never hand-edit an existing one — the scan regenerates them."
|
|
82
|
+
)
|
|
83
|
+
emit(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def safe_join(root: Path, rel: str) -> Path:
|
|
87
|
+
"""Join and refuse escapes (.., absolute, drive letters)."""
|
|
88
|
+
rel_clean = rel.replace("\\", "/").strip("/")
|
|
89
|
+
if not rel_clean:
|
|
90
|
+
emit_error("Empty path.")
|
|
91
|
+
if any(part in ("..",) for part in rel_clean.split("/")) or ":" in rel_clean:
|
|
92
|
+
emit_error(f"Illegal path: {rel!r}")
|
|
93
|
+
target = (root / rel_clean).resolve()
|
|
94
|
+
try:
|
|
95
|
+
target.relative_to(root.resolve())
|
|
96
|
+
except ValueError:
|
|
97
|
+
emit_error(f"Path escapes project root: {rel!r}")
|
|
98
|
+
return target
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|