@dforge-core/dforge-mcp 0.2.44 → 0.2.46-next
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 +73 -0
- package/README.md +3 -2
- package/dist/server.js +109 -84
- package/package.json +48 -46
- package/resources/docs/conventions.md +17 -14
- package/resources/docs/dsl-reference.md +15 -5
- package/resources/schemas/entity.schema.json +1 -1
- package/resources/schemas/registers.schema.json +8 -3
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +374 -0
- package/skills/dforge-1c-import/references/environment.md +97 -0
- package/skills/dforge-1c-import/references/loading.md +161 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +110 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
- package/skills/dforge-mcp-author/SKILL.md +8 -2
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
- package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
- package/skills/dforge-mcp-author/references/conventions.md +7 -1
- package/skills/dforge-mcp-author/references/field-types.md +3 -2
- package/skills/dforge-mcp-author/references/formulas.md +5 -1
- package/skills/dforge-mcp-author/references/print-templates.md +5 -2
- package/skills/dforge-mcp-author/references/queries.md +1 -1
- package/skills/dforge-mcp-author/references/registers.md +18 -3
- package/skills/dforge-mcp-author/references/reports.md +4 -1
- package/skills/dforge-mcp-author/references/schema-import.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +1 -1
- package/skills/dforge-mcp-author/references/translations.md +38 -15
- package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
- package/skills/dforge-module-build/SKILL.md +10 -1
- package/skills/dforge-module-ship/SKILL.md +31 -3
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Module dependency graph, and whether it can be installed.
|
|
5
|
+
|
|
6
|
+
dForge installs modules in dependency order, so a cycle between two modules is not a
|
|
7
|
+
style problem - it is a package that cannot be installed at all. A cycle is broken with
|
|
8
|
+
an extension or a bridge module (see MODULE_CONVENTIONS.md), but first it has to be seen.
|
|
9
|
+
|
|
10
|
+
python tools/deps.py
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import print_function
|
|
13
|
+
|
|
14
|
+
import codecs
|
|
15
|
+
import collections
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
import paths
|
|
20
|
+
|
|
21
|
+
ROOT = paths.ROOT
|
|
22
|
+
MODULES = paths.MODULES
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path):
|
|
26
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
27
|
+
return json.load(f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def graph():
|
|
31
|
+
out = collections.OrderedDict()
|
|
32
|
+
for code in sorted(os.listdir(MODULES)):
|
|
33
|
+
manifest = os.path.join(MODULES, code, "manifest.json")
|
|
34
|
+
if os.path.isfile(manifest):
|
|
35
|
+
out[code] = set(load(manifest).get("dependencies", {}))
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def referenced(code):
|
|
40
|
+
"""Modules this one actually points at: `extends`, and every `entity` in a link,
|
|
41
|
+
reference or set. A dependency the manifest forgets to declare still binds the
|
|
42
|
+
install order - masterdata carried a lookup into shipping while declaring nothing,
|
|
43
|
+
and the pair was uninstallable in either order."""
|
|
44
|
+
out = set()
|
|
45
|
+
|
|
46
|
+
def walk(node):
|
|
47
|
+
if isinstance(node, dict):
|
|
48
|
+
for key, value in node.items():
|
|
49
|
+
if key in ("entity", "extends") and isinstance(value, str) and "." in value:
|
|
50
|
+
out.add(value.split(".", 1)[0])
|
|
51
|
+
else:
|
|
52
|
+
walk(value)
|
|
53
|
+
elif isinstance(node, list):
|
|
54
|
+
for item in node:
|
|
55
|
+
walk(item)
|
|
56
|
+
|
|
57
|
+
directory = os.path.join(MODULES, code, "entities")
|
|
58
|
+
if os.path.isdir(directory):
|
|
59
|
+
for name in sorted(os.listdir(directory)):
|
|
60
|
+
walk(load(os.path.join(directory, name)))
|
|
61
|
+
return out - set([code])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cycles(edges):
|
|
65
|
+
"""Every cycle reachable in the graph, as a list of module names."""
|
|
66
|
+
found, path, state = [], [], {}
|
|
67
|
+
|
|
68
|
+
def walk(node):
|
|
69
|
+
state[node] = "open"
|
|
70
|
+
path.append(node)
|
|
71
|
+
for target in sorted(edges.get(node, ())):
|
|
72
|
+
if target not in edges:
|
|
73
|
+
continue
|
|
74
|
+
if state.get(target) == "open":
|
|
75
|
+
found.append(path[path.index(target):] + [target])
|
|
76
|
+
elif state.get(target) != "done":
|
|
77
|
+
walk(target)
|
|
78
|
+
path.pop()
|
|
79
|
+
state[node] = "done"
|
|
80
|
+
|
|
81
|
+
for node in edges:
|
|
82
|
+
if state.get(node) != "done":
|
|
83
|
+
walk(node)
|
|
84
|
+
return found
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def order(edges):
|
|
88
|
+
"""Install order: a module after everything it depends on."""
|
|
89
|
+
done, out = set(), []
|
|
90
|
+
remaining = dict((k, set(v) & set(edges)) for k, v in edges.items())
|
|
91
|
+
while remaining:
|
|
92
|
+
ready = sorted(k for k, v in remaining.items() if v <= done)
|
|
93
|
+
if not ready:
|
|
94
|
+
return out, sorted(remaining)
|
|
95
|
+
for code in ready:
|
|
96
|
+
out.append(code)
|
|
97
|
+
done.add(code)
|
|
98
|
+
del remaining[code]
|
|
99
|
+
return out, []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main():
|
|
103
|
+
edges = graph()
|
|
104
|
+
print("%-12s %s" % ("module", "depends on"))
|
|
105
|
+
print("-" * 60)
|
|
106
|
+
undeclared = collections.OrderedDict()
|
|
107
|
+
for code, deps in edges.items():
|
|
108
|
+
missing = sorted(referenced(code) - deps - set([code]))
|
|
109
|
+
if missing:
|
|
110
|
+
undeclared[code] = missing
|
|
111
|
+
deps.update(missing)
|
|
112
|
+
print("%-12s %s%s" % (code, ", ".join(sorted(deps)) or "-",
|
|
113
|
+
" (undeclared: %s)" % ", ".join(missing) if missing else ""))
|
|
114
|
+
|
|
115
|
+
if undeclared:
|
|
116
|
+
print("\n%d module(s) reference a module they do not declare - add the"
|
|
117
|
+
" dependency, or move the reference to the other side:" % len(undeclared))
|
|
118
|
+
for code, missing in undeclared.items():
|
|
119
|
+
print(" %s -> %s" % (code, ", ".join(missing)))
|
|
120
|
+
|
|
121
|
+
found = cycles(edges)
|
|
122
|
+
if found:
|
|
123
|
+
print("\n%d cycle(s) - these cannot be installed as they stand:" % len(found))
|
|
124
|
+
for cycle in found:
|
|
125
|
+
print(" %s" % " -> ".join(cycle))
|
|
126
|
+
else:
|
|
127
|
+
print("\nno cycles")
|
|
128
|
+
|
|
129
|
+
sequence, stuck = order(edges)
|
|
130
|
+
print("\ninstall order:\n %s" % " -> ".join(sequence))
|
|
131
|
+
if stuck:
|
|
132
|
+
print("stuck: %s" % ", ".join(stuck))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""How much each document type is used: last date, last twelve months, posted.
|
|
4
|
+
|
|
5
|
+
Usage does not narrow what gets ported - a document nobody has posted in years is still
|
|
6
|
+
done - but it orders the work, and it says which types have a history to prove a port on.
|
|
7
|
+
A type with no posted documents has nothing for the oracle to compare against.
|
|
8
|
+
|
|
9
|
+
The twelve months are counted back from the newest document in the infobase, not from
|
|
10
|
+
today: an extraction is a snapshot, and "the last year" should mean the last year the
|
|
11
|
+
client actually worked in it.
|
|
12
|
+
|
|
13
|
+
python tools/1c.py docusage # the table, and out/docusage.json
|
|
14
|
+
|
|
15
|
+
Read-only. The physical table comes from `DBNames` by the object's GUID, never guessed.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import print_function
|
|
18
|
+
|
|
19
|
+
import codecs
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import handlers
|
|
26
|
+
import meta
|
|
27
|
+
import migrate
|
|
28
|
+
import paths
|
|
29
|
+
|
|
30
|
+
TARGET = os.path.join(paths.OUT, "docusage.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def documents():
|
|
34
|
+
"""(name, physical table) for every document in the configuration."""
|
|
35
|
+
with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
|
|
36
|
+
objects = json.load(f)
|
|
37
|
+
index = meta.dbnames_index()
|
|
38
|
+
found = []
|
|
39
|
+
for obj in objects:
|
|
40
|
+
if obj.get("family") != "Document":
|
|
41
|
+
continue
|
|
42
|
+
entry = index.get(obj["guid"])
|
|
43
|
+
if entry and entry["type"] == "Document":
|
|
44
|
+
found.append((obj["name"], entry["physical"]))
|
|
45
|
+
return sorted(found)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def measure(cur, table):
|
|
49
|
+
cur.execute("SELECT COUNT(*), MAX(_Date_Time) FROM dbo.[%s]" % table)
|
|
50
|
+
rows, last = cur.fetchone()
|
|
51
|
+
return rows, last
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
migrate.load_env()
|
|
56
|
+
cur = migrate.mssql_connect().cursor()
|
|
57
|
+
docs = documents()
|
|
58
|
+
newest = None
|
|
59
|
+
raw = []
|
|
60
|
+
for name, table in docs:
|
|
61
|
+
rows, last = measure(cur, table)
|
|
62
|
+
raw.append((name, table, rows, last))
|
|
63
|
+
if last is not None and (newest is None or last > newest):
|
|
64
|
+
newest = last
|
|
65
|
+
cutoff = newest.replace(year=newest.year - 1) if newest else None
|
|
66
|
+
result = []
|
|
67
|
+
for name, table, rows, last in raw:
|
|
68
|
+
recent = posted = 0
|
|
69
|
+
if rows:
|
|
70
|
+
cur.execute("SELECT SUM(CASE WHEN _Date_Time >= ? THEN 1 ELSE 0 END),"
|
|
71
|
+
" SUM(CASE WHEN _Posted = 0x01 THEN 1 ELSE 0 END) FROM dbo.[%s]"
|
|
72
|
+
% table, cutoff)
|
|
73
|
+
recent, posted = [int(v or 0) for v in cur.fetchone()]
|
|
74
|
+
result.append({
|
|
75
|
+
"name": name,
|
|
76
|
+
"table": table,
|
|
77
|
+
"client": name.startswith(handlers.CLIENT),
|
|
78
|
+
"rows": rows,
|
|
79
|
+
"posted": posted,
|
|
80
|
+
"last12m": recent,
|
|
81
|
+
# 1C stores dates shifted by the year offset; the snapshot keeps them as stored.
|
|
82
|
+
"last": last.strftime("%Y-%m-%d") if last else None,
|
|
83
|
+
})
|
|
84
|
+
result.sort(key=lambda r: (-r["last12m"], -r["posted"], r["name"]))
|
|
85
|
+
with codecs.open(TARGET, "w", "utf-8") as f:
|
|
86
|
+
json.dump({"newest": newest.strftime("%Y-%m-%d") if newest else None,
|
|
87
|
+
"cutoff": cutoff.strftime("%Y-%m-%d") if cutoff else None,
|
|
88
|
+
"documents": result}, f, ensure_ascii=False, indent="\t")
|
|
89
|
+
out = codecs.getwriter("utf-8")(sys.stdout.buffer)
|
|
90
|
+
vendor = [r for r in result if not r["client"]]
|
|
91
|
+
out.write("%d document types, %d vendor; newest document %s, last 12 months from %s\n"
|
|
92
|
+
% (len(result), len(vendor), newest, cutoff))
|
|
93
|
+
out.write("vendor with rows %d, posted %d, used in last 12m %d\n\n" % (
|
|
94
|
+
sum(1 for r in vendor if r["rows"]), sum(1 for r in vendor if r["posted"]),
|
|
95
|
+
sum(1 for r in vendor if r["last12m"])))
|
|
96
|
+
for r in result:
|
|
97
|
+
if r["rows"]:
|
|
98
|
+
out.write("%-6s %-52s %9d %9d %8d %s\n" % (
|
|
99
|
+
"client" if r["client"] else "", r["name"], r["rows"], r["posted"],
|
|
100
|
+
r["last12m"], r["last"]))
|
|
101
|
+
out.write("\nwritten %s\n" % TARGET)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Read a 1C:Enterprise 8 infobase hosted on MS SQL Server *without* any 1C software.
|
|
5
|
+
|
|
6
|
+
Everything needed to rebuild the data model lives in two service tables:
|
|
7
|
+
Params -> row FileName='DBNames': GUID -> physical table/column name map
|
|
8
|
+
Config -> the configuration itself, one row (8.2) or several PartNo rows (8.3) per file
|
|
9
|
+
|
|
10
|
+
File bodies are raw-deflate compressed (no zlib header).
|
|
11
|
+
|
|
12
|
+
Credentials come from env DF1C_USER / DF1C_PASS; without them, Windows auth is used.
|
|
13
|
+
Server and database default to DF1C_SERVER / DF1C_DB.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import codecs
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
import zlib
|
|
23
|
+
|
|
24
|
+
import paths
|
|
25
|
+
|
|
26
|
+
OUT = paths.OUT
|
|
27
|
+
|
|
28
|
+
PREFERRED_DRIVERS = (
|
|
29
|
+
"ODBC Driver 18 for SQL Server",
|
|
30
|
+
"ODBC Driver 17 for SQL Server",
|
|
31
|
+
"ODBC Driver 13 for SQL Server",
|
|
32
|
+
"SQL Server Native Client 11.0",
|
|
33
|
+
"SQL Server",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connect(server, db):
|
|
38
|
+
import pyodbc
|
|
39
|
+
available = set(pyodbc.drivers())
|
|
40
|
+
driver = next((d for d in PREFERRED_DRIVERS if d in available), None)
|
|
41
|
+
if driver is None:
|
|
42
|
+
raise RuntimeError("no SQL Server ODBC driver found: %r" % sorted(available))
|
|
43
|
+
parts = ["DRIVER={%s}" % driver, "SERVER=%s" % server, "DATABASE=%s" % db,
|
|
44
|
+
"TrustServerCertificate=yes"]
|
|
45
|
+
user, pwd = os.environ.get("DF1C_USER"), os.environ.get("DF1C_PASS")
|
|
46
|
+
if user:
|
|
47
|
+
parts += ["UID=%s" % user, "PWD=%s" % pwd]
|
|
48
|
+
else:
|
|
49
|
+
parts += ["Trusted_Connection=yes"]
|
|
50
|
+
sys.stderr.write("[i] %s / %s via %s\n" % (server, db, driver))
|
|
51
|
+
return pyodbc.connect(";".join(parts), autocommit=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def inflate(data):
|
|
55
|
+
"""1C stores file bodies as raw deflate; a few small ones are stored plain."""
|
|
56
|
+
if not data:
|
|
57
|
+
return b""
|
|
58
|
+
data = bytes(data)
|
|
59
|
+
for attempt in (lambda: zlib.decompressobj(-15).decompress(data), lambda: zlib.decompress(data)):
|
|
60
|
+
try:
|
|
61
|
+
return attempt()
|
|
62
|
+
except zlib.error:
|
|
63
|
+
continue
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_HAS_PARTNO = {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_partno(cur, table):
|
|
71
|
+
"""8.3 splits large files across rows by PartNo; 8.2 stores one row per file."""
|
|
72
|
+
if table not in _HAS_PARTNO:
|
|
73
|
+
cur.execute("SELECT COUNT(*) FROM sys.columns WHERE object_id = OBJECT_ID(?) AND name = 'PartNo'", table)
|
|
74
|
+
_HAS_PARTNO[table] = cur.fetchone()[0] > 0
|
|
75
|
+
return _HAS_PARTNO[table]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_file(cur, table, name):
|
|
79
|
+
"""Concatenate a file's parts in order, then inflate."""
|
|
80
|
+
order = " ORDER BY PartNo" if has_partno(cur, table) else ""
|
|
81
|
+
cur.execute("SELECT BinaryData FROM %s WHERE FileName = ?%s" % (table, order), name)
|
|
82
|
+
blob = b"".join(bytes(r[0]) for r in cur.fetchall() if r[0] is not None)
|
|
83
|
+
return inflate(blob)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- DBNames
|
|
87
|
+
|
|
88
|
+
DBNAME_ENTRY = re.compile(r'\{([0-9a-fA-F-]{36}),"([^"]*)",(\d+)\}')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_dbnames(buf):
|
|
92
|
+
"""8.2 writes DBNames as UTF-8 text: {ver, {count, {guid,"Kind",N}, ...}}."""
|
|
93
|
+
if buf.startswith(codecs.BOM_UTF8) or buf[:1] == b"{":
|
|
94
|
+
txt = buf.decode("utf-8-sig", "replace")
|
|
95
|
+
entries = [{"guid": g, "type": k, "number": int(n)} for g, k, n in DBNAME_ENTRY.findall(txt)]
|
|
96
|
+
if entries:
|
|
97
|
+
return entries
|
|
98
|
+
raise ValueError("text DBNames matched no entries")
|
|
99
|
+
return parse_dbnames_binary(buf)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_dbnames_binary(buf):
|
|
103
|
+
"""8.3 layout: <u32 count> then count * { <u32 chars> utf16le, <u32 chars> utf16le, <u32 n> }"""
|
|
104
|
+
out, pos = [], 0
|
|
105
|
+
(count,) = struct.unpack_from("<I", buf, pos)
|
|
106
|
+
pos += 4
|
|
107
|
+
if count == 0 or count > 500000:
|
|
108
|
+
raise ValueError("implausible entry count %d" % count)
|
|
109
|
+
for _ in range(count):
|
|
110
|
+
vals = []
|
|
111
|
+
for _ in range(2):
|
|
112
|
+
(ln,) = struct.unpack_from("<I", buf, pos)
|
|
113
|
+
pos += 4
|
|
114
|
+
vals.append(buf[pos:pos + ln * 2].decode("utf-16-le"))
|
|
115
|
+
pos += ln * 2
|
|
116
|
+
(num,) = struct.unpack_from("<I", buf, pos)
|
|
117
|
+
pos += 4
|
|
118
|
+
out.append({"guid": vals[0], "type": vals[1], "number": num})
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def physical_name(entry):
|
|
123
|
+
"""DBNames kind + number -> the SQL object name 1C generated for it."""
|
|
124
|
+
kind, number = entry.get("type"), entry.get("number")
|
|
125
|
+
if not kind or number is None:
|
|
126
|
+
return None
|
|
127
|
+
return "_%s%d" % (kind, number)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- commands
|
|
131
|
+
|
|
132
|
+
def cmd_databases(cur, args):
|
|
133
|
+
"""Which databases does this login actually see? Separates a bad password from missing rights."""
|
|
134
|
+
cur.execute("SELECT name, state_desc, HAS_DBACCESS(name) FROM sys.databases ORDER BY name")
|
|
135
|
+
for name, state, access in cur.fetchall():
|
|
136
|
+
print("%-44s %-10s access=%s" % (name, state, access))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_probe(cur, args):
|
|
140
|
+
cur.execute("SELECT @@VERSION")
|
|
141
|
+
print(cur.fetchone()[0].splitlines()[0])
|
|
142
|
+
for table in ("Config", "ConfigSave", "Params", "DBSchema", "_YearOffset"):
|
|
143
|
+
try:
|
|
144
|
+
cur.execute("SELECT COUNT(*) FROM [%s]" % table)
|
|
145
|
+
print(" %-12s rows=%s" % (table, cur.fetchone()[0]))
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
print(" %-12s -- %s" % (table, str(exc).split("]")[-1].strip()[:70]))
|
|
148
|
+
cur.execute("""SELECT LEFT(name, PATINDEX('%[0-9]%', name + '0') - 1) AS kind, COUNT(*)
|
|
149
|
+
FROM sys.tables WHERE name LIKE '[_]%'
|
|
150
|
+
GROUP BY LEFT(name, PATINDEX('%[0-9]%', name + '0') - 1)
|
|
151
|
+
ORDER BY 2 DESC""")
|
|
152
|
+
print("\n physical table families:")
|
|
153
|
+
for kind, n in cur.fetchall():
|
|
154
|
+
print(" %-24s %d" % (kind, n))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_schema(cur, args):
|
|
158
|
+
cur.execute("""SELECT t.name, c.name, ty.name, c.max_length, c.precision, c.scale, c.is_nullable
|
|
159
|
+
FROM sys.tables t
|
|
160
|
+
JOIN sys.columns c ON c.object_id = t.object_id
|
|
161
|
+
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
162
|
+
ORDER BY t.name, c.column_id""")
|
|
163
|
+
tables = {}
|
|
164
|
+
for tn, cn, ty, ml, pr, sc, nullable in cur.fetchall():
|
|
165
|
+
tables.setdefault(tn, []).append({
|
|
166
|
+
"column": cn, "type": ty, "len": ml,
|
|
167
|
+
"precision": pr, "scale": sc, "nullable": bool(nullable),
|
|
168
|
+
})
|
|
169
|
+
cur.execute("""SELECT t.name, SUM(p.rows) FROM sys.tables t
|
|
170
|
+
JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
|
|
171
|
+
GROUP BY t.name""")
|
|
172
|
+
rows = dict(cur.fetchall())
|
|
173
|
+
write_json("schema.json", {"tables": [
|
|
174
|
+
{"name": name, "rows": rows.get(name), "columns": cols}
|
|
175
|
+
for name, cols in sorted(tables.items())
|
|
176
|
+
]})
|
|
177
|
+
print("tables=%d" % len(tables))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_dbnames(cur, args):
|
|
181
|
+
buf = read_file(cur, "Params", "DBNames")
|
|
182
|
+
save("dbnames.bin", buf)
|
|
183
|
+
entries = parse_dbnames(buf)
|
|
184
|
+
for entry in entries:
|
|
185
|
+
entry["physical"] = physical_name(entry)
|
|
186
|
+
write_json("dbnames.json", {"count": len(entries), "entries": entries})
|
|
187
|
+
kinds = {}
|
|
188
|
+
for entry in entries:
|
|
189
|
+
kinds[entry["type"]] = kinds.get(entry["type"], 0) + 1
|
|
190
|
+
print("entries=%d distinct kinds=%d" % (len(entries), len(kinds)))
|
|
191
|
+
for kind, n in sorted(kinds.items(), key=lambda kv: -kv[1]):
|
|
192
|
+
print(" %-28s %d" % (kind, n))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_extract(cur, args):
|
|
196
|
+
target = os.path.join(OUT, "config")
|
|
197
|
+
if not os.path.isdir(target):
|
|
198
|
+
os.makedirs(target)
|
|
199
|
+
cur.execute("SELECT DISTINCT FileName FROM Config")
|
|
200
|
+
names = [r[0] for r in cur.fetchall()]
|
|
201
|
+
text = binary = 0
|
|
202
|
+
for i, name in enumerate(names):
|
|
203
|
+
buf = read_file(cur, "Config", name)
|
|
204
|
+
safe = re.sub(r"[^0-9A-Za-z._-]", "_", name)[:120]
|
|
205
|
+
if buf.startswith(codecs.BOM_UTF8) or b"{" in buf[:64]:
|
|
206
|
+
suffix, text = ".txt", text + 1
|
|
207
|
+
else:
|
|
208
|
+
suffix, binary = ".bin", binary + 1
|
|
209
|
+
with open(os.path.join(target, safe + suffix), "wb") as f:
|
|
210
|
+
f.write(buf)
|
|
211
|
+
if i and i % 2000 == 0:
|
|
212
|
+
sys.stderr.write("[i] %d/%d\n" % (i, len(names)))
|
|
213
|
+
print("files=%d text=%d binary=%d -> %s" % (len(names), text, binary, target))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def save(name, data):
|
|
217
|
+
if not os.path.isdir(OUT):
|
|
218
|
+
os.makedirs(OUT)
|
|
219
|
+
with open(os.path.join(OUT, name), "wb") as f:
|
|
220
|
+
f.write(data)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_json(name, doc):
|
|
224
|
+
if not os.path.isdir(OUT):
|
|
225
|
+
os.makedirs(OUT)
|
|
226
|
+
with codecs.open(os.path.join(OUT, name), "w", "utf-8") as f:
|
|
227
|
+
json.dump(doc, f, ensure_ascii=False, indent="\t")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
COMMANDS = {
|
|
231
|
+
"databases": cmd_databases,
|
|
232
|
+
"probe": cmd_probe,
|
|
233
|
+
"schema": cmd_schema,
|
|
234
|
+
"dbnames": cmd_dbnames,
|
|
235
|
+
"extract": cmd_extract,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main():
|
|
240
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
241
|
+
ap.add_argument("command", choices=sorted(COMMANDS))
|
|
242
|
+
ap.add_argument("--server", default=os.environ.get("DF1C_SERVER")
|
|
243
|
+
or paths.required("DF1C_SERVER", "which MS SQL server holds it"))
|
|
244
|
+
ap.add_argument("--db", default=os.environ.get("DF1C_DB")
|
|
245
|
+
or paths.required("DF1C_DB", "which MS SQL infobase to read"))
|
|
246
|
+
args = ap.parse_args()
|
|
247
|
+
COMMANDS[args.command](connect(args.server, args.db).cursor(), args)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Dump one metadata object's parse tree so its attribute layout can be read off."""
|
|
4
|
+
from __future__ import print_function
|
|
5
|
+
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import meta
|
|
12
|
+
|
|
13
|
+
OUT = meta.OUT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find(name):
|
|
17
|
+
with codecs.open(os.path.join(OUT, "objects.json"), "r", "utf-8") as f:
|
|
18
|
+
for row in json.load(f):
|
|
19
|
+
if row.get("name") == name:
|
|
20
|
+
return row["guid"]
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def shape(node, depth, max_depth, path):
|
|
25
|
+
pad = " " * depth
|
|
26
|
+
if isinstance(node, list):
|
|
27
|
+
strings = [x for x in node if isinstance(x, str) and x and not meta.GUID.match(x)
|
|
28
|
+
and not x.isdigit()]
|
|
29
|
+
hint = (" <- " + ", ".join(repr(s)[:34] for s in strings[:4])) if strings else ""
|
|
30
|
+
print("%s%s list[%d]%s" % (pad, path, len(node), hint))
|
|
31
|
+
if depth >= max_depth:
|
|
32
|
+
return
|
|
33
|
+
for i, item in enumerate(node):
|
|
34
|
+
if isinstance(item, list):
|
|
35
|
+
shape(item, depth + 1, max_depth, "[%d]" % i)
|
|
36
|
+
else:
|
|
37
|
+
if node:
|
|
38
|
+
print("%s%s = %s" % (pad, path, repr(node)[:70]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
name = sys.argv[1]
|
|
43
|
+
depth = int(sys.argv[2]) if len(sys.argv) > 2 else 4
|
|
44
|
+
guid = find(name)
|
|
45
|
+
print("%s -> %s\n" % (name, guid))
|
|
46
|
+
node = meta.parse(meta.load(guid))
|
|
47
|
+
label = "root"
|
|
48
|
+
if len(sys.argv) > 3: # drill into e.g. 6.2
|
|
49
|
+
for part in sys.argv[3].split("."):
|
|
50
|
+
node = node[int(part)]
|
|
51
|
+
label = "root." + sys.argv[3]
|
|
52
|
+
if "--json" in sys.argv:
|
|
53
|
+
print(json.dumps(node, ensure_ascii=False, indent=1))
|
|
54
|
+
else:
|
|
55
|
+
shape(node, 0, depth, label)
|