@dforge-core/dforge-mcp 0.1.0-rc.9 → 0.1.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/CHANGELOG.md +132 -0
- package/README.md +84 -27
- package/dist/server.js +2957 -616
- package/docs/creating-modules.md +11 -4
- package/package.json +11 -6
- package/resources/docs/conventions.md +22 -16
- package/resources/docs/dsl-reference.md +767 -0
- package/resources/schemas/entity.schema.json +8 -1
- package/resources/schemas/print-templates.schema.json +82 -0
- package/resources/schemas/reports.schema.json +4 -0
- package/resources/schemas/triggers.schema.json +59 -0
- package/skills/dforge-mcp-author/SKILL.md +269 -69
- package/skills/dforge-mcp-author/examples/matrix-budget/README.md +43 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_category.json +24 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_line.json +56 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/manifest.json +30 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/security/roles.json +9 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/seed-data/01-categories.json +8 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/ui/data_views.json +42 -0
- package/skills/dforge-mcp-author/examples/simple-todo/README.md +38 -0
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +83 -0
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_list.json +43 -0
- package/skills/dforge-mcp-author/examples/simple-todo/logic/actions/mark_done.dsl +6 -0
- package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +32 -0
- package/skills/dforge-mcp-author/examples/simple-todo/security/roles.json +10 -0
- package/skills/dforge-mcp-author/examples/simple-todo/seed-data/01-lists.json +17 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/actions.json +11 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/data_views.json +35 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/menus.json +28 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +397 -0
- package/skills/dforge-mcp-author/references/column-types.md +168 -0
- package/skills/dforge-mcp-author/references/conventions.md +177 -0
- package/skills/dforge-mcp-author/references/data-migration.md +270 -0
- package/skills/dforge-mcp-author/references/data-views.md +243 -0
- package/skills/dforge-mcp-author/references/excel-import.md +61 -0
- package/skills/dforge-mcp-author/references/field-types.md +144 -0
- package/skills/dforge-mcp-author/references/filters.md +326 -0
- package/skills/dforge-mcp-author/references/flags.md +73 -0
- package/skills/dforge-mcp-author/references/formulas.md +206 -0
- package/skills/dforge-mcp-author/references/jobs.md +149 -0
- package/skills/dforge-mcp-author/references/manifest.md +123 -0
- package/skills/dforge-mcp-author/references/menus.md +164 -0
- package/skills/dforge-mcp-author/references/number-sequences.md +117 -0
- package/skills/dforge-mcp-author/references/print-templates.md +159 -0
- package/skills/dforge-mcp-author/references/queries.md +312 -0
- package/skills/dforge-mcp-author/references/reports.md +398 -0
- package/skills/dforge-mcp-author/references/schema-import.md +331 -0
- package/skills/dforge-mcp-author/references/security.md +244 -0
- package/skills/dforge-mcp-author/references/settings.md +120 -0
- package/skills/dforge-mcp-author/references/traits.md +153 -0
- package/skills/dforge-mcp-author/references/translations.md +158 -0
- package/skills/dforge-mcp-author/references/validation-checklist.md +183 -0
- package/skills/dforge-mcp-author/scripts/xlsx_to_model.py +198 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract sheets/tables from an .xlsx into a JSON "model" for dForge import.
|
|
3
|
+
|
|
4
|
+
Pure standard library (zipfile + xml.etree) — NO `pip install` needed, runs on
|
|
5
|
+
any Python 3. An .xlsx is a zip of XML; this reads each worksheet grid (a capped
|
|
6
|
+
sample of rows) and resolves cell strings from the shared-string table, then
|
|
7
|
+
prints, per sheet, the header row + a sample of data rows. The AI then turns that
|
|
8
|
+
model into a dForge table-spec and calls `dforge_module_import`.
|
|
9
|
+
|
|
10
|
+
Memory-bounded by design: it streams each worksheet (clearing elements) and only
|
|
11
|
+
materialises the FIRST `max_data_rows` rows; it then loads ONLY the shared
|
|
12
|
+
strings those sampled cells actually reference — never the whole string table —
|
|
13
|
+
so there is no cap that could silently drop values.
|
|
14
|
+
|
|
15
|
+
Usage: python3 xlsx_to_model.py <file.xlsx> [max_data_rows]
|
|
16
|
+
Output: JSON on stdout — {"sheets":[{"name","headers":[...],"rows":[[...],...]}]}
|
|
17
|
+
(on failure: {"error": "..."} with a non-zero exit)
|
|
18
|
+
|
|
19
|
+
Caveat: date cells are stored as serial numbers in xlsx; they come through as
|
|
20
|
+
numbers here. Recognise date columns by header name when building the spec.
|
|
21
|
+
"""
|
|
22
|
+
import sys
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import zipfile
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
|
|
28
|
+
REL_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def localname(tag):
|
|
32
|
+
return tag.rsplit("}", 1)[-1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def col_index(ref):
|
|
36
|
+
"""'B7' -> 1 (0-based column index)."""
|
|
37
|
+
m = re.match(r"([A-Z]+)", ref or "")
|
|
38
|
+
if not m:
|
|
39
|
+
return 0
|
|
40
|
+
n = 0
|
|
41
|
+
for ch in m.group(1):
|
|
42
|
+
n = n * 26 + (ord(ch) - 64)
|
|
43
|
+
return n - 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sheet_paths(z):
|
|
47
|
+
wb = ET.fromstring(z.read("xl/workbook.xml"))
|
|
48
|
+
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
|
|
49
|
+
rid_target = {r.get("Id"): r.get("Target") for r in rels}
|
|
50
|
+
sheets = []
|
|
51
|
+
for sh in wb.iter():
|
|
52
|
+
if localname(sh.tag) != "sheet":
|
|
53
|
+
continue
|
|
54
|
+
rid = sh.get(REL_NS) or sh.get("id")
|
|
55
|
+
target = (rid_target.get(rid) or "").lstrip("/")
|
|
56
|
+
if target and not target.startswith("xl/"):
|
|
57
|
+
target = "xl/" + target
|
|
58
|
+
sheets.append((sh.get("name"), target))
|
|
59
|
+
return sheets
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def raw_cell(c):
|
|
63
|
+
"""Raw cell payload: the <v> text, or the concatenated <is> inline string."""
|
|
64
|
+
for ch in c:
|
|
65
|
+
name = localname(ch.tag)
|
|
66
|
+
if name == "v":
|
|
67
|
+
return ch.text
|
|
68
|
+
if name == "is":
|
|
69
|
+
return "".join(x.text or "" for x in ch.iter() if localname(x.tag) == "t")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_sheet_raw(z, path, max_rows):
|
|
74
|
+
"""Stream a worksheet into raw rows of (cell_type, raw_value) tuples, capped
|
|
75
|
+
at max_rows+1 non-empty rows. Strings are NOT resolved yet (pass 2 does that)."""
|
|
76
|
+
rows = []
|
|
77
|
+
cells, maxc = {}, -1
|
|
78
|
+
with z.open(path) as f:
|
|
79
|
+
for event, el in ET.iterparse(f, events=("end",)):
|
|
80
|
+
name = localname(el.tag)
|
|
81
|
+
if name == "c":
|
|
82
|
+
i = col_index(el.get("r"))
|
|
83
|
+
cells[i] = (el.get("t"), raw_cell(el))
|
|
84
|
+
if i > maxc:
|
|
85
|
+
maxc = i
|
|
86
|
+
el.clear()
|
|
87
|
+
elif name == "row":
|
|
88
|
+
row = [cells.get(i) for i in range(maxc + 1)]
|
|
89
|
+
# A row counts only if a cell carries an actual VALUE — styled /
|
|
90
|
+
# bordered but empty cells still emit a <c> node (no <v>), and
|
|
91
|
+
# must not consume a sample slot before the real data is reached.
|
|
92
|
+
if any(c is not None and c[1] not in (None, "") for c in row):
|
|
93
|
+
rows.append(row)
|
|
94
|
+
cells, maxc = {}, -1
|
|
95
|
+
el.clear()
|
|
96
|
+
if len(rows) >= max_rows + 1:
|
|
97
|
+
break
|
|
98
|
+
return rows
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def needed_string_indices(sheets_raw):
|
|
102
|
+
need = set()
|
|
103
|
+
for rows in sheets_raw:
|
|
104
|
+
for row in rows:
|
|
105
|
+
for cell in row:
|
|
106
|
+
if cell and cell[0] == "s" and cell[1] is not None:
|
|
107
|
+
try:
|
|
108
|
+
need.add(int(cell[1]))
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
return need
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_shared_subset(z, needed):
|
|
115
|
+
"""Stream the shared-string table, keeping ONLY the indices we need (bounded
|
|
116
|
+
by the sampled cells — never the whole table). Stops once the highest needed
|
|
117
|
+
index is read."""
|
|
118
|
+
result = {}
|
|
119
|
+
if not needed:
|
|
120
|
+
return result
|
|
121
|
+
max_needed = max(needed)
|
|
122
|
+
try:
|
|
123
|
+
handle = z.open("xl/sharedStrings.xml")
|
|
124
|
+
except KeyError:
|
|
125
|
+
return result
|
|
126
|
+
idx = -1
|
|
127
|
+
parts = []
|
|
128
|
+
with handle as f:
|
|
129
|
+
for event, el in ET.iterparse(f, events=("start", "end")):
|
|
130
|
+
name = localname(el.tag)
|
|
131
|
+
if event == "start":
|
|
132
|
+
if name == "si":
|
|
133
|
+
idx += 1
|
|
134
|
+
parts = []
|
|
135
|
+
elif name == "t":
|
|
136
|
+
parts.append(el.text or "")
|
|
137
|
+
elif name == "si":
|
|
138
|
+
if idx in needed:
|
|
139
|
+
result[idx] = "".join(parts)
|
|
140
|
+
el.clear()
|
|
141
|
+
if idx >= max_needed:
|
|
142
|
+
break
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve(cell, shared):
|
|
147
|
+
if cell is None:
|
|
148
|
+
return None
|
|
149
|
+
t, raw = cell
|
|
150
|
+
if raw is None:
|
|
151
|
+
return None
|
|
152
|
+
if t == "s":
|
|
153
|
+
try:
|
|
154
|
+
return shared.get(int(raw))
|
|
155
|
+
except ValueError:
|
|
156
|
+
return raw
|
|
157
|
+
if t in ("inlineStr", "str"):
|
|
158
|
+
return raw
|
|
159
|
+
try:
|
|
160
|
+
f = float(raw)
|
|
161
|
+
return int(f) if f.is_integer() else f
|
|
162
|
+
except ValueError:
|
|
163
|
+
return raw
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main():
|
|
167
|
+
if len(sys.argv) < 2:
|
|
168
|
+
print(json.dumps({"error": "usage: xlsx_to_model.py <file.xlsx> [max_data_rows]"}))
|
|
169
|
+
sys.exit(2)
|
|
170
|
+
max_rows = int(sys.argv[2]) if len(sys.argv) > 2 else 15
|
|
171
|
+
try:
|
|
172
|
+
with zipfile.ZipFile(sys.argv[1]) as z:
|
|
173
|
+
raw = []
|
|
174
|
+
for name, path in sheet_paths(z):
|
|
175
|
+
if not path:
|
|
176
|
+
raw.append((name, []))
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
raw.append((name, parse_sheet_raw(z, path, max_rows)))
|
|
180
|
+
except KeyError:
|
|
181
|
+
raw.append((name, []))
|
|
182
|
+
shared = load_shared_subset(z, needed_string_indices([r for _, r in raw]))
|
|
183
|
+
sheets = []
|
|
184
|
+
for name, raw_rows in raw:
|
|
185
|
+
rows = [[resolve(c, shared) for c in row] for row in raw_rows]
|
|
186
|
+
rows = [r for r in rows if any(v is not None and v != "" for v in r)]
|
|
187
|
+
if not rows:
|
|
188
|
+
continue
|
|
189
|
+
headers = [str(h) if h not in (None, "") else "col%d" % (i + 1) for i, h in enumerate(rows[0])]
|
|
190
|
+
sheets.append({"name": name, "headers": headers, "rows": rows[1:max_rows + 1]})
|
|
191
|
+
print(json.dumps({"sheets": sheets}, default=str))
|
|
192
|
+
except Exception as exc: # noqa: BLE001 — report any failure as JSON
|
|
193
|
+
print(json.dumps({"error": "%s: %s" % (type(exc).__name__, exc)}))
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|