@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,201 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Pull the external reports and processings an infobase keeps in a catalog out to files.
|
|
5
|
+
|
|
6
|
+
УТП-based configurations store a user's `.erf` / `.epf` in `Справочник.ВнешниеОбработки`,
|
|
7
|
+
attribute `ХранилищеВнешнейОбработки` - a ValueStorage. The value is not compressed:
|
|
8
|
+
|
|
9
|
+
01 01 | u32 header length | u32 0 | the header, UTF-8: {"#",<ДвоичныеДанные guid>,{1,{0}}}
|
|
10
|
+
u32 0 | u32 data length | u32 0 | the file itself
|
|
11
|
+
|
|
12
|
+
and the file is the same paged container `unpack.py` reads for the configuration, its
|
|
13
|
+
entries nested a level deeper:
|
|
14
|
+
|
|
15
|
+
root {2,<object guid>,}
|
|
16
|
+
<object guid> the object's metadata - its name, forms, templates
|
|
17
|
+
<guid>.0 a container: `info` + `text` is the object module,
|
|
18
|
+
`form` + `module` a form
|
|
19
|
+
<form or template> metadata naming it; a template's body is its `.0`, not a container
|
|
20
|
+
<guid>.1 help, base64 HTML - skipped
|
|
21
|
+
|
|
22
|
+
python tools/1c.py external # every row, to out/external/<code>/
|
|
23
|
+
python tools/1c.py external --kind Отчет # one kind of processing only
|
|
24
|
+
python tools/1c.py external --catalog <Name> # another catalog of the same shape
|
|
25
|
+
|
|
26
|
+
Each row gets `info.json` (code, name, kind, the object's name, its forms and templates)
|
|
27
|
+
and the texts: `ObjectModule.bsl`, `Form.<name>.bsl`, `Form.<name>.form`, `Template.<name>.*`.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import print_function
|
|
30
|
+
|
|
31
|
+
import codecs
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import struct
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
import attrs
|
|
40
|
+
import meta
|
|
41
|
+
import migrate
|
|
42
|
+
import paths
|
|
43
|
+
import unpack
|
|
44
|
+
|
|
45
|
+
CONTAINER = b"\xff\xff\xff\x7f"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stored_file(blob):
|
|
49
|
+
"""The file a ValueStorage holding ДвоичныеДанные carries, or None."""
|
|
50
|
+
blob = bytes(blob or b"")
|
|
51
|
+
if len(blob) < 10 or blob[:2] != b"\x01\x01":
|
|
52
|
+
return None
|
|
53
|
+
(head,) = struct.unpack_from("<I", blob, 2)
|
|
54
|
+
at = 10 + head
|
|
55
|
+
if len(blob) < at + 12:
|
|
56
|
+
return None
|
|
57
|
+
(size,) = struct.unpack_from("<I", blob, at + 4)
|
|
58
|
+
return blob[at + 12:at + 12 + size]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
IDENTITY = re.compile(r'\{\d+,\s*\d+,\s*[0-9a-f-]{36}\},\s*"((?:[^"]|"")*)",\s*'
|
|
62
|
+
r'(?:\{\d+,\s*"[a-z]{2}",\s*"((?:[^"]|"")*)")?')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def name_of(text):
|
|
66
|
+
"""(name, synonym) off the first identity block - `{1,0,<guid>},"Name",{1,"ru","..."}`.
|
|
67
|
+
Its guid is the object's own, not the one `root` names."""
|
|
68
|
+
m = IDENTITY.search(text or "")
|
|
69
|
+
if not m:
|
|
70
|
+
return None, None
|
|
71
|
+
return m.group(1).replace('""', '"'), (m.group(2) or "").replace('""', '"') or None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read(file_bytes):
|
|
75
|
+
"""{'object': name, 'synonym', 'module': text, 'forms': {name: (form, module)},
|
|
76
|
+
'templates': {name: bytes}} for one external report or processing."""
|
|
77
|
+
top = dict(unpack.entries(file_bytes))
|
|
78
|
+
texts = dict((k, unpack.as_text(v)) for k, v in top.items() if v[:4] != CONTAINER)
|
|
79
|
+
root = re.search(r"\{2,\s*([0-9a-f-]{36})", texts.get("root") or "")
|
|
80
|
+
object_guid = root.group(1) if root else None
|
|
81
|
+
name, synonym = name_of(texts.get(object_guid))
|
|
82
|
+
out = {"object": name, "synonym": synonym,
|
|
83
|
+
"module": None, "forms": {}, "templates": {}}
|
|
84
|
+
for key, body in sorted(top.items()):
|
|
85
|
+
if not key.endswith(".0"):
|
|
86
|
+
continue
|
|
87
|
+
guid = key[:-2]
|
|
88
|
+
if body[:4] == CONTAINER:
|
|
89
|
+
inner = dict(unpack.entries(body))
|
|
90
|
+
if "form" in inner:
|
|
91
|
+
form = name_of(texts.get(guid))[0] or guid
|
|
92
|
+
out["forms"][form] = (unpack.as_text(inner["form"]), unpack.as_text(inner.get("module") or b""))
|
|
93
|
+
elif "text" in inner:
|
|
94
|
+
out["module"] = unpack.as_text(inner["text"])
|
|
95
|
+
elif guid in texts:
|
|
96
|
+
out["templates"][name_of(texts[guid])[0] or guid] = body
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def template_files(name, body):
|
|
101
|
+
"""[(file name, bytes)]. A composition schema is a short binary header and then several XML
|
|
102
|
+
documents back to back - the schema, its settings, ... - each its own file."""
|
|
103
|
+
starts = [m.start() for m in re.finditer(b"(?:\\xef\\xbb\\xbf)?<\\?xml", body)]
|
|
104
|
+
if starts:
|
|
105
|
+
parts = [body[a:b] for a, b in zip(starts, starts[1:] + [len(body)])]
|
|
106
|
+
return [("Template.%s%s.xml" % (name, "" if i == 0 else ".%d" % i), part)
|
|
107
|
+
for i, part in enumerate(parts)]
|
|
108
|
+
text = unpack.as_text(body)
|
|
109
|
+
if text:
|
|
110
|
+
return [("Template.%s.txt" % name, text.encode("utf-8"))]
|
|
111
|
+
return [("Template.%s.bin" % name, body)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def safe(name):
|
|
115
|
+
return re.sub(r'[\\/:*?"<>|]', "_", name or "")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write(folder, row, parsed):
|
|
119
|
+
if os.path.isdir(folder):
|
|
120
|
+
shutil.rmtree(folder)
|
|
121
|
+
os.makedirs(folder)
|
|
122
|
+
files = []
|
|
123
|
+
|
|
124
|
+
def put(file_name, data):
|
|
125
|
+
with open(os.path.join(folder, file_name), "wb") as f:
|
|
126
|
+
f.write(data if isinstance(data, bytes) else data.encode("utf-8"))
|
|
127
|
+
files.append(file_name)
|
|
128
|
+
|
|
129
|
+
if parsed["module"]:
|
|
130
|
+
put("ObjectModule.bsl", parsed["module"])
|
|
131
|
+
for form, (layout, module) in sorted(parsed["forms"].items()):
|
|
132
|
+
if layout:
|
|
133
|
+
put("Form.%s.form" % safe(form), layout)
|
|
134
|
+
if module:
|
|
135
|
+
put("Form.%s.bsl" % safe(form), module)
|
|
136
|
+
for template, body in sorted(parsed["templates"].items()):
|
|
137
|
+
for file_name, data in template_files(safe(template), body):
|
|
138
|
+
put(file_name, data)
|
|
139
|
+
info = dict(row, object=parsed["object"], synonym=parsed["synonym"],
|
|
140
|
+
forms=sorted(parsed["forms"]), templates=sorted(parsed["templates"]), files=files)
|
|
141
|
+
with codecs.open(os.path.join(folder, "info.json"), "w", "utf-8") as f:
|
|
142
|
+
json.dump(info, f, ensure_ascii=False, indent="\t")
|
|
143
|
+
return info
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def physical(objects, index, name):
|
|
147
|
+
"""(table, {attribute: column}) for a catalog, resolved through DBNames."""
|
|
148
|
+
guid, fields = attrs.fields_of(name, objects, attrs.load_json("typemap.json"))
|
|
149
|
+
if not guid or guid not in index:
|
|
150
|
+
raise SystemExit("no catalog %s in this configuration" % name)
|
|
151
|
+
columns = {}
|
|
152
|
+
for field in fields or []:
|
|
153
|
+
entry = index.get(field.get("guid"))
|
|
154
|
+
if entry and not field.get("section"):
|
|
155
|
+
columns[field["name"]] = entry["physical"]
|
|
156
|
+
return index[guid]["physical"], columns
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
args = sys.argv[1:]
|
|
161
|
+
catalog = args[args.index("--catalog") + 1] if "--catalog" in args else u"ВнешниеОбработки"
|
|
162
|
+
only = args[args.index("--kind") + 1] if "--kind" in args else None
|
|
163
|
+
|
|
164
|
+
objects = attrs.load_json("objects.json")
|
|
165
|
+
index = meta.dbnames_index()
|
|
166
|
+
table, columns = physical(objects, index, catalog)
|
|
167
|
+
storage, kind = columns[u"ХранилищеВнешнейОбработки"], columns[u"ВидОбработки"]
|
|
168
|
+
|
|
169
|
+
enum_guid = next(o["guid"] for o in objects
|
|
170
|
+
if o.get("family") == "Enum" and o.get("name") == u"ВидыДополнительныхВнешнихОбработок")
|
|
171
|
+
enum_table = index[enum_guid]["physical"]
|
|
172
|
+
kinds = attrs.enum_values(meta.parse(meta.load(enum_guid)), u"ВидыДополнительныхВнешнихОбработок")
|
|
173
|
+
|
|
174
|
+
cur = migrate.mssql_connect().cursor()
|
|
175
|
+
cur.execute("SELECT t._Code, t._Description, t._Marked, e._EnumOrder, t.%s FROM %s t "
|
|
176
|
+
"LEFT JOIN %s e ON e._IDRRef = t.%sRRef WHERE t._Folder = 0x01"
|
|
177
|
+
% (storage, table, enum_table, kind))
|
|
178
|
+
root = os.path.join(paths.OUT, "external")
|
|
179
|
+
index_rows, skipped = [], []
|
|
180
|
+
for code, name, marked, order, blob in cur.fetchall():
|
|
181
|
+
kind_name = kinds[int(order)] if order is not None and int(order) < len(kinds) else None
|
|
182
|
+
if only and kind_name != only:
|
|
183
|
+
continue
|
|
184
|
+
row = {"code": code.strip(), "name": name.strip(), "kind": kind_name, "marked": marked == b"\x01"}
|
|
185
|
+
data = stored_file(blob)
|
|
186
|
+
if not data or data[:4] != CONTAINER:
|
|
187
|
+
skipped.append(row)
|
|
188
|
+
continue
|
|
189
|
+
info = write(os.path.join(root, row["code"]), row, read(data))
|
|
190
|
+
index_rows.append(dict((k, info[k]) for k in ("code", "name", "kind", "marked", "object", "files")))
|
|
191
|
+
|
|
192
|
+
index_rows.sort(key=lambda r: r["code"])
|
|
193
|
+
with codecs.open(os.path.join(root, "index.json"), "w", "utf-8") as f:
|
|
194
|
+
json.dump(index_rows, f, ensure_ascii=False, indent="\t")
|
|
195
|
+
print("%d written to %s, %d without a file" % (len(index_rows), root, len(skipped)))
|
|
196
|
+
for row in skipped:
|
|
197
|
+
print(" no file: %s %s" % (row["code"], row["name"]))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Extract every 1C module text in the configuration to files, once.
|
|
5
|
+
|
|
6
|
+
Unpacking on demand costs a container parse per question; this walks every container in
|
|
7
|
+
out/config and writes the module sources to out/code, so from then on the whole business
|
|
8
|
+
logic of the infobase is greppable with ordinary tools.
|
|
9
|
+
|
|
10
|
+
It iterates the container FILES rather than the metadata tree on purpose: form modules
|
|
11
|
+
are their own Config entries and are not reachable from the object list, so a tree walk
|
|
12
|
+
would silently miss them.
|
|
13
|
+
|
|
14
|
+
Layout:
|
|
15
|
+
|
|
16
|
+
out/code/<Family>/<ObjectName>/<container>.<entry>.bsl
|
|
17
|
+
out/code/_unowned/<guid>/... container whose owner is not a top-level object
|
|
18
|
+
out/code/index.json one record per file, for attribution and sizing
|
|
19
|
+
|
|
20
|
+
python tools/extract_code.py # everything
|
|
21
|
+
python tools/extract_code.py --stats # re-print the summary from index.json
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import print_function
|
|
24
|
+
|
|
25
|
+
import codecs
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
import attrs
|
|
32
|
+
import meta
|
|
33
|
+
import unpack
|
|
34
|
+
|
|
35
|
+
OUT = meta.OUT
|
|
36
|
+
CONFIG = meta.CONFIG
|
|
37
|
+
CODE = os.path.join(OUT, "code")
|
|
38
|
+
|
|
39
|
+
GUID_PREFIX = re.compile(r"^([0-9a-fA-F-]{36})")
|
|
40
|
+
# a module, as opposed to a form layout or a picture, reads as 1C source
|
|
41
|
+
CODE_MARKERS = ("Процедура", "Функция", "Перем ", "Procedure", "Function")
|
|
42
|
+
SAFE = re.compile(r"[^0-9A-Za-zА-Яа-яЁёІіЇїЄєҐґ._-]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe(name):
|
|
46
|
+
return SAFE.sub("_", name)[:80] or "unnamed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# DBNames names only the classes that own a table. Two more are named here because the
|
|
50
|
+
# evidence is conclusive: 631b75a0's members are ABCАнализПокупателей /
|
|
51
|
+
# АнализДвиженияДенежныхСредств, and 07ee8426's containers hold `form` + `module` rather
|
|
52
|
+
# than the `info` + `text` every module-bearing object has.
|
|
53
|
+
#
|
|
54
|
+
# The rest keep their class GUID as a directory name on purpose. Two of them carry a lot
|
|
55
|
+
# of code - bf845118 (187 members) and 0fe48980 (128) - and both look like common modules
|
|
56
|
+
# from their container shape, which cannot both be true. Guessing would put wrong labels
|
|
57
|
+
# on 500k lines, so out/code/classes.md records the evidence instead and anyone who knows
|
|
58
|
+
# 1C can name them in a minute. Attribution to the owning OBJECT is exact either way.
|
|
59
|
+
CLASS_LABELS = {
|
|
60
|
+
"631b75a0-29e2-11d6-a3c7-0050bae0a776": "Report",
|
|
61
|
+
"07ee8426-87f1-11d5-b99c-0050bae0a95d": "Form",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def owner_index():
|
|
66
|
+
"""guid -> (label, object name) for every metadata object in the root.
|
|
67
|
+
|
|
68
|
+
objects_all.json is used rather than objects.json: the latter only covers classes
|
|
69
|
+
that own a physical table, which leaves out common modules, reports and event
|
|
70
|
+
subscriptions - most of the configuration's code.
|
|
71
|
+
"""
|
|
72
|
+
index = {}
|
|
73
|
+
for obj in attrs.load_json("objects_all.json"):
|
|
74
|
+
if not obj.get("name"):
|
|
75
|
+
continue
|
|
76
|
+
label = (obj.get("family")
|
|
77
|
+
or CLASS_LABELS.get(obj["classGuid"])
|
|
78
|
+
or "class_" + obj["classGuid"][:8])
|
|
79
|
+
index[obj["guid"]] = (label, obj["name"])
|
|
80
|
+
return index
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_code(text):
|
|
84
|
+
return text is not None and any(marker in text for marker in CODE_MARKERS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def nested_owner_index(unknown, owners):
|
|
88
|
+
"""Attribute nested containers - form modules, mostly - to the object they belong to.
|
|
89
|
+
|
|
90
|
+
A form is its own Config entry with its own GUID and is not in the root object list,
|
|
91
|
+
but that GUID is written into its owner's definition, so one pass over the root
|
|
92
|
+
objects' files resolves them.
|
|
93
|
+
"""
|
|
94
|
+
unknown = set(unknown)
|
|
95
|
+
resolved = {}
|
|
96
|
+
for guid, (label, name) in owners.items():
|
|
97
|
+
text = meta.load(guid)
|
|
98
|
+
if text is None:
|
|
99
|
+
continue
|
|
100
|
+
for found in set(re.findall(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
101
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", text)):
|
|
102
|
+
if found in unknown and found not in resolved:
|
|
103
|
+
resolved[found] = (label, name)
|
|
104
|
+
return resolved
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main():
|
|
108
|
+
if "--stats" in sys.argv:
|
|
109
|
+
report(attrs.load_json(os.path.join("code", "index.json")))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
owners = owner_index()
|
|
113
|
+
files = sorted(f for f in os.listdir(CONFIG) if f.endswith(".bin"))
|
|
114
|
+
|
|
115
|
+
unknown = set()
|
|
116
|
+
for filename in files:
|
|
117
|
+
match = GUID_PREFIX.match(filename)
|
|
118
|
+
if match and match.group(1) not in owners:
|
|
119
|
+
unknown.add(match.group(1))
|
|
120
|
+
sys.stderr.write("[i] resolving %d nested containers against %d objects\n"
|
|
121
|
+
% (len(unknown), len(owners)))
|
|
122
|
+
nested = nested_owner_index(unknown, owners)
|
|
123
|
+
sys.stderr.write("[i] %d of them attributed\n" % len(nested))
|
|
124
|
+
|
|
125
|
+
records = []
|
|
126
|
+
containers = skipped = 0
|
|
127
|
+
written = set()
|
|
128
|
+
|
|
129
|
+
for i, filename in enumerate(files):
|
|
130
|
+
path = os.path.join(CONFIG, filename)
|
|
131
|
+
with open(path, "rb") as f:
|
|
132
|
+
blob = f.read()
|
|
133
|
+
if blob[:4] != b"\xff\xff\xff\x7f":
|
|
134
|
+
skipped += 1
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
items = unpack.entries(blob)
|
|
138
|
+
except Exception as exc: # a malformed container is data, not a crash
|
|
139
|
+
records.append({"file": filename, "error": str(exc)[:120]})
|
|
140
|
+
continue
|
|
141
|
+
containers += 1
|
|
142
|
+
|
|
143
|
+
base = filename[:-len(".bin")]
|
|
144
|
+
match = GUID_PREFIX.match(base)
|
|
145
|
+
guid = match.group(1) if match else ""
|
|
146
|
+
family, name = owners.get(guid) or nested.get(guid) or ("_unowned", guid or base)
|
|
147
|
+
target = os.path.join(CODE, safe(family), safe(name))
|
|
148
|
+
|
|
149
|
+
for entry_name, content in items:
|
|
150
|
+
text = unpack.as_text(content)
|
|
151
|
+
if not is_code(text):
|
|
152
|
+
continue
|
|
153
|
+
if not os.path.isdir(target):
|
|
154
|
+
os.makedirs(target)
|
|
155
|
+
# The container's own identity has to reach the filename. Naming the file
|
|
156
|
+
# after the suffix alone - the `0` of `<guid>.0` - gave every container of
|
|
157
|
+
# an object the same name, so an object owning several (a document and its
|
|
158
|
+
# forms, a report and its variants) kept only whichever was written last.
|
|
159
|
+
# That silently lost 825,327 lines of 2.36 M, and the index went on
|
|
160
|
+
# describing files that no longer held what it said.
|
|
161
|
+
stem = "%s.%s" % (guid[:8] or "x", base.split(".", 1)[-1])
|
|
162
|
+
out_name = "%s.%s.bsl" % (safe(stem), safe(entry_name))
|
|
163
|
+
if os.path.join(target, out_name) in written:
|
|
164
|
+
# Eight hex digits are not a promise. Spend the whole GUID rather
|
|
165
|
+
# than lose a file to the same bug in a smaller form.
|
|
166
|
+
out_name = "%s.%s.bsl" % (safe(base), safe(entry_name))
|
|
167
|
+
written.add(os.path.join(target, out_name))
|
|
168
|
+
with codecs.open(os.path.join(target, out_name), "w", "utf-8") as f:
|
|
169
|
+
f.write(text)
|
|
170
|
+
records.append({
|
|
171
|
+
"family": family, "object": name, "guid": match.group(1) if match else None,
|
|
172
|
+
"container": base,
|
|
173
|
+
"entry": entry_name, "path": os.path.relpath(
|
|
174
|
+
os.path.join(target, out_name), OUT).replace("\\", "/"),
|
|
175
|
+
"lines": sum(1 for line in text.splitlines()
|
|
176
|
+
if line.strip() and not line.strip().startswith("//")),
|
|
177
|
+
})
|
|
178
|
+
if i and i % 500 == 0:
|
|
179
|
+
sys.stderr.write("[i] %d/%d files, %d modules\n" % (i, len(files), len(records)))
|
|
180
|
+
|
|
181
|
+
with codecs.open(os.path.join(CODE, "index.json"), "w", "utf-8") as f:
|
|
182
|
+
json.dump(records, f, ensure_ascii=False, indent="\t")
|
|
183
|
+
write_classes(records)
|
|
184
|
+
print("containers=%d non-container files skipped=%d" % (containers, skipped))
|
|
185
|
+
report(records)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_classes(records):
|
|
189
|
+
"""What each metadata class turned out to hold, so the unlabelled ones can be named."""
|
|
190
|
+
import collections
|
|
191
|
+
objects = attrs.load_json("objects_all.json")
|
|
192
|
+
by_guid = {o["guid"]: o for o in objects}
|
|
193
|
+
|
|
194
|
+
# join on the owner GUID, never on the name: names repeat across classes
|
|
195
|
+
# (ABCКлассификацияПокупателей is a Document, an InfoRg and a Report)
|
|
196
|
+
stats = collections.defaultdict(lambda: {"modules": 0, "lines": 0, "objects": set()})
|
|
197
|
+
for r in records:
|
|
198
|
+
if "lines" not in r:
|
|
199
|
+
continue
|
|
200
|
+
owner = by_guid.get(r.get("guid"))
|
|
201
|
+
key = owner["classGuid"] if owner else "(not a root object - forms, etc.)"
|
|
202
|
+
stats[key]["modules"] += 1
|
|
203
|
+
stats[key]["lines"] += r["lines"]
|
|
204
|
+
stats[key]["objects"].add(r.get("guid"))
|
|
205
|
+
|
|
206
|
+
members = collections.defaultdict(list)
|
|
207
|
+
for o in objects:
|
|
208
|
+
if o.get("name"):
|
|
209
|
+
members[o["classGuid"]].append(o["name"])
|
|
210
|
+
|
|
211
|
+
lines = ["# Metadata classes, by what they actually contain", "",
|
|
212
|
+
"Generated by `tools/extract_code.py`. Classes without a DBNames family own",
|
|
213
|
+
"no table, so they have to be identified from their contents.", "",
|
|
214
|
+
"| class GUID | label | members | with code | modules | lines | sample members |",
|
|
215
|
+
"|---|---|---:|---:|---:|---:|---|"]
|
|
216
|
+
for class_guid, names in sorted(members.items(), key=lambda kv: -len(kv[1])):
|
|
217
|
+
s = stats.get(class_guid, {"modules": 0, "lines": 0, "objects": set()})
|
|
218
|
+
label = CLASS_LABELS.get(class_guid, "")
|
|
219
|
+
lines.append("| `%s` | %s | %d | %d | %d | %d | %s |"
|
|
220
|
+
% (class_guid, label, len(names), len(s["objects"]),
|
|
221
|
+
s["modules"], s["lines"], ", ".join(names[:4])))
|
|
222
|
+
unowned = stats.get("(not a root object - forms, etc.)")
|
|
223
|
+
if unowned:
|
|
224
|
+
lines += ["", "Modules whose container is not a root object (nested forms and the"
|
|
225
|
+
" like): %d modules, %d lines."
|
|
226
|
+
% (unowned["modules"], unowned["lines"])]
|
|
227
|
+
with codecs.open(os.path.join(CODE, "classes.md"), "w", "utf-8") as f:
|
|
228
|
+
f.write("\n".join(lines) + "\n")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def report(records):
|
|
232
|
+
import collections
|
|
233
|
+
by_family = collections.Counter()
|
|
234
|
+
lines_by_family = collections.Counter()
|
|
235
|
+
for r in records:
|
|
236
|
+
if "lines" not in r:
|
|
237
|
+
continue
|
|
238
|
+
by_family[r["family"]] += 1
|
|
239
|
+
lines_by_family[r["family"]] += r["lines"]
|
|
240
|
+
print("\n%-16s %8s %10s" % ("family", "modules", "lines"))
|
|
241
|
+
print("-" * 36)
|
|
242
|
+
for family, count in by_family.most_common():
|
|
243
|
+
print("%-16s %8d %10d" % (family, count, lines_by_family[family]))
|
|
244
|
+
print("-" * 36)
|
|
245
|
+
print("%-16s %8d %10d" % ("total", sum(by_family.values()),
|
|
246
|
+
sum(lines_by_family.values())))
|
|
247
|
+
print("\n-> %s" % CODE)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Read the 1C forms out of the configuration dump.
|
|
5
|
+
|
|
6
|
+
The port carries every attribute a catalogue declares, because that is what the metadata
|
|
7
|
+
says - and the metadata says nothing about how they are grouped. `Номенклатура` is a flat
|
|
8
|
+
list of 81 attributes, so the card the port generates is a flat list of 81 fields.
|
|
9
|
+
|
|
10
|
+
The grouping is in the forms. An ordinary 1C form is not text: it sits in `<guid>.0.bin`
|
|
11
|
+
in 1C's own container format - a 16-byte header, then a chain of blocks whose 31-byte
|
|
12
|
+
ASCII headers give (data size, page size, next page). The first chain is a directory of
|
|
13
|
+
(attributes address, data address) pairs; each attributes block carries an inner file's
|
|
14
|
+
name and each data block its body. A form container holds two of them, `form` and
|
|
15
|
+
`module` - the control tree and the form's code.
|
|
16
|
+
|
|
17
|
+
Inside `form`, a panel's pages carry their captions:
|
|
18
|
+
|
|
19
|
+
{4, {1,1, {"ru","Цены номенклатуры"}}, ..., -1,1,1,"ЦеныНоменклатуры",1, ...
|
|
20
|
+
|
|
21
|
+
which is where a card's tabs should come from.
|
|
22
|
+
|
|
23
|
+
python tools/forms.py # coverage, and writes out/formtabs.json
|
|
24
|
+
python tools/forms.py <1C object> # that object's forms, and one form's tabs
|
|
25
|
+
|
|
26
|
+
What is NOT here is which page a given field sits on. A page's controls are not nested
|
|
27
|
+
under it: the page list is one node of the panel and the controls are another, tied by
|
|
28
|
+
an id mapping whose layout is not documented and did not yield to three readings of it.
|
|
29
|
+
The configuration is on ordinary forms throughout - 123 of the 182 ported objects have
|
|
30
|
+
only those, and not one has a managed form with anything in it - so there is no clean
|
|
31
|
+
tree to fall back on. The tabs below are therefore a description of the 1C card, not yet
|
|
32
|
+
a placement for ours.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import print_function
|
|
35
|
+
|
|
36
|
+
import codecs
|
|
37
|
+
import collections
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import struct
|
|
42
|
+
import sys
|
|
43
|
+
import zlib
|
|
44
|
+
|
|
45
|
+
import attrs
|
|
46
|
+
import paths
|
|
47
|
+
import meta
|
|
48
|
+
|
|
49
|
+
CONFIG = os.path.join(attrs.OUT, "config")
|
|
50
|
+
GUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
51
|
+
|
|
52
|
+
# `{4, {1,1, {"ru","<caption>"}}, ...},-1,1,1,"<name>",1,` - one page of a panel.
|
|
53
|
+
PAGE = re.compile(
|
|
54
|
+
r'\{1,1,\s*\{"ru","([^"\n]{2,60})"\}[\s\S]{0,400}?,-1,1,1,"([A-Za-zЀ-ӿ_][^"\n]{0,60})",1,',
|
|
55
|
+
re.S)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------- container
|
|
59
|
+
|
|
60
|
+
def read_block(buf, at):
|
|
61
|
+
"""One block chain of a 1C container, concatenated."""
|
|
62
|
+
header = buf[at:at + 31]
|
|
63
|
+
if len(header) < 31 or header[:2] != b"\r\n":
|
|
64
|
+
return None
|
|
65
|
+
left = int(header[2:10], 16)
|
|
66
|
+
page, size, following = at, int(header[11:19], 16), int(header[20:28], 16)
|
|
67
|
+
out = b""
|
|
68
|
+
while True:
|
|
69
|
+
chunk = buf[page + 31:page + 31 + size]
|
|
70
|
+
out += chunk[:left]
|
|
71
|
+
left -= min(len(chunk), left)
|
|
72
|
+
if left <= 0 or following == 0x7fffffff:
|
|
73
|
+
break
|
|
74
|
+
header = buf[following:following + 31]
|
|
75
|
+
size, page, following = (int(header[11:19], 16), following,
|
|
76
|
+
int(header[20:28], 16))
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def container_files(path):
|
|
81
|
+
"""The inner files of a 1C container, by name."""
|
|
82
|
+
with open(path, "rb") as f:
|
|
83
|
+
buf = f.read()
|
|
84
|
+
directory = read_block(buf, 16)
|
|
85
|
+
if not directory:
|
|
86
|
+
return {}
|
|
87
|
+
out = {}
|
|
88
|
+
for i in range(0, len(directory) - 11, 12):
|
|
89
|
+
attr_at, data_at, _ = struct.unpack_from("<III", directory, i)
|
|
90
|
+
header = read_block(buf, attr_at)
|
|
91
|
+
if not header or len(header) <= 20:
|
|
92
|
+
continue
|
|
93
|
+
name = header[20:].decode("utf-16-le", "replace").rstrip(u"\x00")
|
|
94
|
+
body = read_block(buf, data_at) or b""
|
|
95
|
+
try:
|
|
96
|
+
body = zlib.decompressobj(-15).decompress(body)
|
|
97
|
+
except zlib.error:
|
|
98
|
+
pass
|
|
99
|
+
out[name] = body
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def form_body(guid, part="form"):
|
|
104
|
+
"""A form's control tree as text, whether it was stored packed or plain."""
|
|
105
|
+
packed = os.path.join(CONFIG, guid + ".0.bin")
|
|
106
|
+
if os.path.isfile(packed):
|
|
107
|
+
body = container_files(packed).get(part)
|
|
108
|
+
return body.decode("utf-8-sig", "replace") if body else None
|
|
109
|
+
plain = os.path.join(CONFIG, guid + ".0.txt")
|
|
110
|
+
if part == "form" and os.path.isfile(plain):
|
|
111
|
+
with codecs.open(plain, "r", "utf-8-sig", "replace") as f:
|
|
112
|
+
return f.read()
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------- forms
|
|
117
|
+
|
|
118
|
+
_ON_DISK = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def on_disk():
|
|
122
|
+
global _ON_DISK
|
|
123
|
+
if _ON_DISK is None:
|
|
124
|
+
_ON_DISK = set(name.split(".")[0] for name in os.listdir(CONFIG))
|
|
125
|
+
return _ON_DISK
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def forms_of(object_guid):
|
|
129
|
+
"""[(1C form name, guid)] for one metadata object, in declaration order."""
|
|
130
|
+
text = meta.load(object_guid) or ""
|
|
131
|
+
found = []
|
|
132
|
+
for guid in dict.fromkeys(re.findall(GUID, text)):
|
|
133
|
+
if guid == object_guid or guid not in on_disk():
|
|
134
|
+
continue
|
|
135
|
+
header = meta.load(guid) or ""
|
|
136
|
+
name = re.search(u'"(Форма[^"]*)"', header)
|
|
137
|
+
if name:
|
|
138
|
+
found.append((name.group(1), guid))
|
|
139
|
+
return found
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def tabs(text):
|
|
143
|
+
"""A panel's pages, in order: [(caption, control name)].
|
|
144
|
+
|
|
145
|
+
1C names a page it was never given a caption for `Страница1`, and a form with one
|
|
146
|
+
such page has no tabs worth porting - that is the whole card.
|
|
147
|
+
"""
|
|
148
|
+
found = []
|
|
149
|
+
for caption, name in PAGE.findall(text or ""):
|
|
150
|
+
if caption.startswith(u"Страница"):
|
|
151
|
+
continue
|
|
152
|
+
found.append((caption, name))
|
|
153
|
+
return found
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def element_form(object_guid):
|
|
157
|
+
"""The form a record opens in, preferring the client's own over the stock one."""
|
|
158
|
+
found = forms_of(object_guid)
|
|
159
|
+
for wanted in (u"ФормаЭлементаМСБ",
|
|
160
|
+
u"ФормаЭлемента",
|
|
161
|
+
u"ФормаДокумента"):
|
|
162
|
+
for name, guid in found:
|
|
163
|
+
if name == wanted:
|
|
164
|
+
return name, guid
|
|
165
|
+
return (found[0] if found else (None, None))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------- reporting
|
|
169
|
+
|
|
170
|
+
def model():
|
|
171
|
+
path = os.path.join(attrs.OUT, "objects_all.json")
|
|
172
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
173
|
+
doc = json.load(f)
|
|
174
|
+
return doc["objects"] if isinstance(doc, dict) else doc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main():
|
|
178
|
+
rows = model()
|
|
179
|
+
if len(sys.argv) > 1:
|
|
180
|
+
wanted = sys.argv[1].decode("utf-8") if str is bytes else sys.argv[1]
|
|
181
|
+
row = next((r for r in rows if r.get("name") == wanted), None)
|
|
182
|
+
if not row:
|
|
183
|
+
print("no such object: %s" % wanted)
|
|
184
|
+
return 1
|
|
185
|
+
for name, guid in forms_of(row["guid"]):
|
|
186
|
+
text = form_body(guid)
|
|
187
|
+
found = tabs(text)
|
|
188
|
+
print("%-28s %8d chars %d tab(s)"
|
|
189
|
+
% (name, len(text or ""), len(found)))
|
|
190
|
+
for caption, control in found:
|
|
191
|
+
print(" %-42s %s" % (caption, control))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
# The objects the port actually carries, which is the column map's own list - a
|
|
195
|
+
# tabular section has no form of its own, so only the owners are asked about.
|
|
196
|
+
with codecs.open(paths.derived("columnmap.json"), "r", "utf-8") as f:
|
|
197
|
+
mapping = json.load(f)
|
|
198
|
+
wanted = set()
|
|
199
|
+
for entities in mapping.values():
|
|
200
|
+
for found in entities.values():
|
|
201
|
+
name = (found.get("object") or "").strip()
|
|
202
|
+
if name and " / " not in name:
|
|
203
|
+
wanted.add(name)
|
|
204
|
+
|
|
205
|
+
tally = collections.Counter()
|
|
206
|
+
total_tabs = 0
|
|
207
|
+
found_tabs = collections.OrderedDict()
|
|
208
|
+
for row in rows:
|
|
209
|
+
if row.get("name") not in wanted:
|
|
210
|
+
continue
|
|
211
|
+
name, guid = element_form(row["guid"])
|
|
212
|
+
if not guid:
|
|
213
|
+
tally["no form"] += 1
|
|
214
|
+
continue
|
|
215
|
+
found = tabs(form_body(guid))
|
|
216
|
+
total_tabs += len(found)
|
|
217
|
+
tally["tabs" if found else "one page"] += 1
|
|
218
|
+
if found:
|
|
219
|
+
found_tabs[row["name"]] = {
|
|
220
|
+
"form": name,
|
|
221
|
+
"tabs": [{"caption": caption, "name": control}
|
|
222
|
+
for caption, control in found],
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
path = os.path.join(attrs.OUT, "formtabs.json")
|
|
226
|
+
with codecs.open(path, "w", "utf-8") as f:
|
|
227
|
+
f.write(json.dumps(found_tabs, ensure_ascii=False, indent="\t"))
|
|
228
|
+
f.write("\n")
|
|
229
|
+
|
|
230
|
+
print("%-12s %s" % ("ported objects", len(wanted)))
|
|
231
|
+
for key, count in tally.most_common():
|
|
232
|
+
print("%-12s %d" % (key, count))
|
|
233
|
+
print("%-12s %d" % ("tabs found", total_tabs))
|
|
234
|
+
print("\n-> %s" % path)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
sys.exit(main())
|