@dforge-core/dforge-mcp 0.2.45 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/package.json +48 -46
  4. package/scripts/install-skills.mjs +1 -0
  5. package/skills/dforge-1c-import/SKILL.md +374 -0
  6. package/skills/dforge-1c-import/references/environment.md +97 -0
  7. package/skills/dforge-1c-import/references/loading.md +161 -0
  8. package/skills/dforge-1c-import/references/measurements.md +210 -0
  9. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  10. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  11. package/skills/dforge-1c-import/scripts/README.md +110 -0
  12. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  13. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  14. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  15. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  16. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  17. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  18. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  19. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  20. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  21. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  22. package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
  23. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  24. package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
  25. package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
  26. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  27. package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
  28. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  29. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  30. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  31. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  33. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  34. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  35. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  36. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  37. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  38. package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
  39. package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
  40. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  41. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  42. package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
  43. package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
  44. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  45. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  46. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  47. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  48. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  49. package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
  50. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  51. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  52. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  53. package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
  54. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  55. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  56. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  57. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  58. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  59. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  60. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  61. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  62. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  63. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  64. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  65. package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
  66. package/skills/dforge-mcp-author/SKILL.md +5 -0
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ u"""Read the configuration's print templates (макеты, MOXCEL spreadsheets) into HTML and JSON.
4
+
5
+ An object lists its templates as the group `{3daea016-69b7-4ed4-9453-127911372fe6, n, guid...}`
6
+ in its metadata. A template's own `<guid>.txt` names it, and its body is `<guid>.0.txt`. A
7
+ spreadsheet body is `MOXCEL` and a few binary bytes, then a brace list:
8
+
9
+ 8, 1, 9, {languages}, {0}, {column sets}, 0, {0,0} x6,
10
+ 1, 2, <rows>, then per row: <row>, <format>, <cells>, and per cell <column>, {<cell>}
11
+ {<columns>, 0, <guid>, <count>, then per column <column>, <format>}
12
+ <rows again>, 0 x8,
13
+ {<merges>, {left, top, right, bottom, 0|2} ...}
14
+ {0}, {0},
15
+ {<areas>, name, {1, {kind, left, top, right, bottom, guid}, 0} ...}
16
+ ... {<formats>, {mask, values...} ...}, then {<fonts>, {7, ..., height, ..., weight, ...,
17
+ face, ...}}, the cell value formats, the colors
18
+
19
+ A cell is `{0, format}` when it is empty, `{16, format, {1, 1, {lang, text}}}` when it holds
20
+ text or a parameter - lang "" is a parameter, the text its name - and `{20, format, {"U"},
21
+ {1, 1, {lang, text}}}` a template whose `[Name]` are parameters. An area is rows (kind 1, left
22
+ and right -1) or columns (kind 2), inclusive. A cell's format is 1-based, 0 for none; a format's font is 0-based.
23
+
24
+ A format is a bit mask and its values in the order of the bits, low first. What each bit means
25
+ was read off the client's templates: font index (1); the left, top, right and bottom border, a
26
+ line index (2, 4, 8, 16); height (64) and width (128) in 1C's units. The other bits - alignment
27
+ and text placement among them - are carried by number and not reproduced, nor are colors and
28
+ line styles: the HTML is a layout to author a print form from, not a facsimile.
29
+
30
+ python tools/1c.py mxl list # every template: object, name, kind, size
31
+ python tools/1c.py mxl list --client # the integrator's objects only (мсб*, рз*)
32
+ python tools/1c.py mxl dump <object> [<name>] # to out/templates/<object>/<name>.html, .json
33
+ """
34
+ from __future__ import print_function
35
+
36
+ import codecs
37
+ import html
38
+ import io
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+
44
+ import meta
45
+ import binding
46
+ import paths
47
+
48
+ TEMPLATES = "3daea016-69b7-4ed4-9453-127911372fe6"
49
+ CLIENT = tuple(binding.client_prefixes())
50
+ OUT = os.path.join(paths.OUT, "templates")
51
+
52
+ # a format's bits, in the order their values follow the mask; the unnamed ones are kept by number
53
+ NAMED = {1: "font", 2: "left", 4: "top", 8: "right", 16: "bottom", 64: "height", 128: "width"}
54
+ BITS = [(1 << k, NAMED.get(1 << k, "b%d" % (1 << k))) for k in range(32)]
55
+
56
+
57
+ def objects():
58
+ with codecs.open(os.path.join(paths.OUT, "objects_all.json"), "r", "utf-8") as f:
59
+ return [o for o in json.load(f) if o.get("name")]
60
+
61
+
62
+ def templates_of(guid):
63
+ """[(template guid, name, synonym)] an object lists."""
64
+ text = meta.load(guid)
65
+ if text is None:
66
+ return []
67
+ found = []
68
+ for group in meta.collect_guid_groups(meta.parse(text)):
69
+ if group["classGuid"] != TEMPLATES:
70
+ continue
71
+ for member in group["members"]:
72
+ head = meta.load(member)
73
+ if head is None:
74
+ continue
75
+ name, syn = meta.object_header(head)
76
+ found.append((member, name, syn.get("ru") or syn.get("uk") or ""))
77
+ return found
78
+
79
+
80
+ def body(guid):
81
+ """The template's body as bytes, or None."""
82
+ for suffix in (".0.txt", ".0.bin", ".0"):
83
+ path = os.path.join(meta.CONFIG, guid + suffix)
84
+ if os.path.isfile(path):
85
+ with open(path, "rb") as f:
86
+ return f.read()
87
+ return None
88
+
89
+
90
+ def kind(data):
91
+ if data is None:
92
+ return "missing"
93
+ if data.startswith(b"MOXCEL"):
94
+ return "mxl"
95
+ return "other"
96
+
97
+
98
+ def inventory(client_only=False):
99
+ """[(object family, object name, template guid, template name, synonym, kind, size)]."""
100
+ seen = set()
101
+ out = []
102
+ for o in objects():
103
+ if o["guid"] in seen or (client_only and not (o["name"] or "").startswith(CLIENT)):
104
+ continue
105
+ seen.add(o["guid"])
106
+ for guid, name, syn in templates_of(o["guid"]):
107
+ data = body(guid)
108
+ out.append((o.get("family") or "", o["name"], guid, name, syn, kind(data), len(data or b"")))
109
+ return out
110
+
111
+
112
+ # --------------------------------------------------------------------------- the spreadsheet
113
+
114
+ def tree(data):
115
+ at = data.find(b"{")
116
+ return meta.parse(data[at:].decode("utf-8", "replace"))
117
+
118
+
119
+ def _int(value, default=0):
120
+ try:
121
+ return int(value)
122
+ except (TypeError, ValueError):
123
+ return default
124
+
125
+
126
+ def _format(entry):
127
+ """{property: value} of a format entry {mask, values...}."""
128
+ mask = _int(entry[0])
129
+ values = entry[1:]
130
+ out = {}
131
+ k = 0
132
+ for bit, name in BITS:
133
+ if mask & bit:
134
+ out[name] = values[k] if k < len(values) else None
135
+ k += 1
136
+ return out
137
+
138
+
139
+ def _font(entry):
140
+ # {7, 0, ?, height, 0, 0, 0, weight, italic, underline, strike, charset, ..., face, ...}
141
+ at = lambda k: entry[k] if k < len(entry) else None
142
+ return {"size": _int(at(3)) / 10.0, "bold": _int(at(7)) >= 600,
143
+ "italic": _int(at(8)) != 0, "underline": _int(at(9)) != 0,
144
+ "face": next((x for x in entry if isinstance(x, str) and x and not x.lstrip("-").isdigit()), "")}
145
+
146
+
147
+ def _text(cell):
148
+ """(kind, text) of a cell: 'empty', 'text', 'param' or 'template'."""
149
+ tag = _int(cell[0])
150
+ strings = [n for n in meta.walk(cell) if len(n) == 2 and all(isinstance(x, str) for x in n)
151
+ and n[0] in ("", "ru", "uk", "en")]
152
+ value = strings[-1][1] if strings else ""
153
+ lang = strings[-1][0] if strings else None
154
+ if tag == 0 or not strings:
155
+ return "empty", ""
156
+ if tag == 20 or (lang != "" and re.search(r"\[[^\]]+\]", value)):
157
+ # a text cell naming [Parameter] is filled as a template: the code sets that parameter
158
+ # (the transfer's "Оператор: [ОтпускРазрешил]" is a text cell, tag 16)
159
+ return "template", value
160
+ return ("param" if lang == "" else "text"), value
161
+
162
+
163
+ def read(data):
164
+ """The spreadsheet as {columns: {col: format}, rows: [{row, format, cells: {col: (kind,
165
+ text, format)}}], merges, areas, formats, fonts}."""
166
+ r = tree(data)
167
+ i = 15
168
+ rows = []
169
+ for _ in range(_int(r[i])):
170
+ i += 1
171
+ row, fmt, n = _int(r[i]), _int(r[i + 1]), _int(r[i + 2])
172
+ i += 2
173
+ cells = {}
174
+ for _c in range(n):
175
+ col, cell = _int(r[i + 1]), r[i + 2]
176
+ kind_, text = _text(cell)
177
+ cells[col] = (kind_, text, _int(cell[1]))
178
+ i += 2
179
+ rows.append({"row": row, "format": fmt, "cells": cells})
180
+ i += 1
181
+ columns = {}
182
+ col_list = r[i]
183
+ for k in range(4, len(col_list) - 1, 2):
184
+ columns[_int(col_list[k])] = _int(col_list[k + 1])
185
+ lists = [x for x in r[i + 1:] if isinstance(x, list)]
186
+ merges, areas, formats, fonts = [], {}, [], []
187
+ for x in lists:
188
+ if not merges and x and _int(x[0]) == len(x) - 1 and all(isinstance(y, list) and len(y) == 5 for y in x[1:]) and len(x) > 1:
189
+ merges = [tuple(_int(v) for v in y[:4]) for y in x[1:]]
190
+ elif not areas and len(x) > 2 and isinstance(x[1], str) and isinstance(x[2], list) and _int(x[0]) * 2 == len(x) - 1:
191
+ for k in range(1, len(x), 2):
192
+ spec = x[k + 1][1]
193
+ areas[x[k]] = {"kind": "rows" if _int(spec[0]) == 1 else "columns" if _int(spec[0]) == 2 else "cells",
194
+ "left": _int(spec[1]), "top": _int(spec[2]), "right": _int(spec[3]), "bottom": _int(spec[4])}
195
+ # the formats: the count, then that many lists, each a mask and its values
196
+ for k, x in enumerate(r):
197
+ if isinstance(x, str) and x.isdigit() and k + 1 < len(r) and int(x) > 0 and k > i:
198
+ run = r[k + 1:k + 1 + int(x)]
199
+ if len(run) == int(x) and all(isinstance(y, list) and y and str(y[0]).isdigit() for y in run) \
200
+ and all(len(y) == len([b for b, _ in BITS if _int(y[0]) & b]) + 1 for y in run):
201
+ formats = [_format(y) for y in run]
202
+ after = r[k + 1 + int(x)]
203
+ if isinstance(after, str) and after.isdigit():
204
+ fonts = [_font(y) for y in r[k + 2 + int(x):k + 2 + int(x) + int(after)]]
205
+ break
206
+ return {"columns": columns, "rows": rows, "merges": merges, "areas": areas,
207
+ "formats": formats, "fonts": fonts}
208
+
209
+
210
+ def parameters(sheet, area=None):
211
+ """The parameter names an area (or the whole sheet) fills, in reading order."""
212
+ names = []
213
+ for row in _rows(sheet, area):
214
+ for col in sorted(row["cells"]):
215
+ kind_, text, _ = row["cells"][col]
216
+ found = [text] if kind_ == "param" else re.findall(r"\[([^\]]+)\]", text) if kind_ == "template" else []
217
+ for name in found:
218
+ if name not in names:
219
+ names.append(name)
220
+ return names
221
+
222
+
223
+ def _rows(sheet, area):
224
+ if area is None:
225
+ return sheet["rows"]
226
+ a = sheet["areas"][area]
227
+ if a["kind"] != "rows":
228
+ return sheet["rows"]
229
+ return [row for row in sheet["rows"] if a["top"] <= row["row"] <= a["bottom"]]
230
+
231
+
232
+ # --------------------------------------------------------------------------- HTML
233
+
234
+ def _fmt(sheet, index):
235
+ return sheet["formats"][index - 1] if 0 < index <= len(sheet["formats"]) else {}
236
+
237
+
238
+ def _width(sheet, col):
239
+ return _int(_fmt(sheet, sheet["columns"].get(col, 0)).get("width"), 0)
240
+
241
+
242
+ def _style(sheet, fmt):
243
+ css = []
244
+ font = _int(fmt.get("font"), -1)
245
+ if 0 <= font < len(sheet["fonts"]) and "font" in fmt:
246
+ f = sheet["fonts"][font]
247
+ if f["bold"]:
248
+ css.append("font-weight:bold")
249
+ if f["italic"]:
250
+ css.append("font-style:italic")
251
+ if f["underline"]:
252
+ css.append("text-decoration:underline")
253
+ if f["size"]:
254
+ css.append("font-size:%gpt" % f["size"])
255
+ for side in ("left", "top", "right", "bottom"):
256
+ if side in fmt:
257
+ css.append("border-%s:1px solid #000" % side)
258
+ return ";".join(css)
259
+
260
+
261
+ def area_html(sheet, area=None, value=None):
262
+ """An area as an HTML table. `value(kind, text)` renders a cell's content - by default the
263
+ text as is and a parameter as `{{ name }}`."""
264
+ value = value or _liquid
265
+ rows = _rows(sheet, area)
266
+ if not rows:
267
+ return ""
268
+ top, bottom = rows[0]["row"], rows[-1]["row"]
269
+ width = max([max(r["cells"]) for r in rows if r["cells"]] + [max(sheet["columns"] or [0])]) + 1
270
+ spans, covered = {}, set()
271
+ for left, t, right, b in sheet["merges"]:
272
+ if right < 0 or b < 0:
273
+ continue
274
+ if t < top or b > bottom:
275
+ continue
276
+ spans[(t, left)] = (b - t + 1, right - left + 1)
277
+ for rr in range(t, b + 1):
278
+ for cc in range(left, right + 1):
279
+ if (rr, cc) != (t, left):
280
+ covered.add((rr, cc))
281
+ widths = [max(1, _width(sheet, col)) for col in range(width)]
282
+ out = ['<table class="mxl">', "<colgroup>"]
283
+ for w in widths:
284
+ out.append('<col style="width:%.2f%%">' % (100.0 * w / sum(widths)))
285
+ out.append("</colgroup>")
286
+ by_row = dict((r["row"], r) for r in rows)
287
+ for rn in range(top, bottom + 1):
288
+ row = by_row.get(rn, {"cells": {}, "format": 0})
289
+ height = _int(_fmt(sheet, row["format"]).get("height"), 0)
290
+ out.append('<tr%s>' % (' style="height:%dpx"' % (height * 4 / 3) if height else ""))
291
+ for col in range(width):
292
+ if (rn, col) in covered:
293
+ continue
294
+ kind_, text, fmt = row["cells"].get(col, ("empty", "", 0))
295
+ attrs = ""
296
+ if (rn, col) in spans:
297
+ rs, cs = spans[(rn, col)]
298
+ attrs += (' rowspan="%d"' % rs if rs > 1 else "") + (' colspan="%d"' % cs if cs > 1 else "")
299
+ style = _style(sheet, _fmt(sheet, fmt))
300
+ if style:
301
+ attrs += ' style="%s"' % style
302
+ out.append("<td%s>%s</td>" % (attrs, value(kind_, text)))
303
+ out.append("</tr>")
304
+ out.append("</table>")
305
+ return "\n".join(out)
306
+
307
+
308
+ def _liquid(kind_, text):
309
+ if kind_ == "param":
310
+ return "{{ %s }}" % text
311
+ if kind_ == "template":
312
+ return re.sub(r"\[([^\]]+)\]", lambda m: "{{ %s }}" % m.group(1), html.escape(text))
313
+ return html.escape(text).replace("\n", "<br>")
314
+
315
+
316
+ def dump(obj_name, template_name=None):
317
+ written = []
318
+ for o in objects():
319
+ if o["name"] != obj_name:
320
+ continue
321
+ for guid, name, syn in templates_of(o["guid"]):
322
+ if template_name and name != template_name:
323
+ continue
324
+ data = body(guid)
325
+ if kind(data) != "mxl":
326
+ continue
327
+ sheet = read(data)
328
+ folder = os.path.join(OUT, obj_name)
329
+ if not os.path.isdir(folder):
330
+ os.makedirs(folder)
331
+ parts = ["<!-- %s.%s: %s -->" % (obj_name, name, syn)]
332
+ for area in sorted(sheet["areas"], key=lambda a: sheet["areas"][a]["top"]):
333
+ if sheet["areas"][area]["kind"] != "rows":
334
+ continue
335
+ parts.append("<h3>%s</h3>" % html.escape(area))
336
+ parts.append(area_html(sheet, area))
337
+ if not any(a["kind"] == "rows" for a in sheet["areas"].values()):
338
+ parts.append(area_html(sheet))
339
+ with io.open(os.path.join(folder, name + ".html"), "w", encoding="utf-8") as f:
340
+ f.write(u"\n".join(parts) + u"\n")
341
+ info = {"object": obj_name, "template": name, "synonym": syn,
342
+ "areas": dict((a, dict(v, parameters=parameters(sheet, a))) for a, v in sheet["areas"].items()),
343
+ "parameters": parameters(sheet)}
344
+ with io.open(os.path.join(folder, name + ".json"), "w", encoding="utf-8") as f:
345
+ f.write(json.dumps(info, ensure_ascii=False, indent="\t") + u"\n")
346
+ written.append(os.path.join(folder, name))
347
+ return written
348
+
349
+
350
+ def main():
351
+ args = sys.argv[1:]
352
+ command = args[0] if args else "list"
353
+ if command == "list":
354
+ rows = inventory("--client" in args)
355
+ for family, obj, guid, name, syn, kind_, size in rows:
356
+ print("%-10s %-45s %-50s %-6s %7d" % (family, obj, name, kind_, size))
357
+ print("%d template(s), %d spreadsheet(s)" % (len(rows), sum(1 for r in rows if r[5] == "mxl")))
358
+ return 0
359
+ if command == "dump" and len(args) >= 2:
360
+ for path in dump(args[1], args[2] if len(args) > 2 else None):
361
+ print(path)
362
+ return 0
363
+ print(__doc__)
364
+ return 2
365
+
366
+
367
+ if __name__ == "__main__":
368
+ sys.exit(main())
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 1C names -> dForge codes.
5
+
6
+ `мсбРасходЭнергоресурсов` has to become `utilities_issue`, `КоличествоМест` has to become
7
+ `places_qty`, and 2,620 of these cannot be typed by hand. The rule is: strip the
8
+ integrator's prefix, split the CamelCase compound into words, translate each word through
9
+ `terms.TERMS`, drop the prepositions, and join with underscores.
10
+
11
+ Two things keep the result usable rather than merely mechanical:
12
+
13
+ * unknown words are transliterated, not dropped, and every one is recorded so
14
+ `python tools/naming.py` can print what the dictionary is still missing;
15
+ * codes are shortened and de-duplicated per scope, because Postgres identifiers are
16
+ capped at 63 characters and 1C names run long.
17
+
18
+ python tools/naming.py # coverage report over the whole scope
19
+ python tools/naming.py <1C name> ... # what these particular names become
20
+ """
21
+ from __future__ import print_function
22
+
23
+ import collections
24
+ import re
25
+ import sys
26
+
27
+ import terms
28
+ import terms_uk
29
+
30
+ import binding
31
+ import paths
32
+
33
+ # The integrator's and the embedded toolkit's prefixes are tags, not words.
34
+ _TAGS = binding.client_prefixes() + binding.toolkit_prefixes()
35
+ PREFIX = re.compile(u"^(%s)" % u"|".join(re.escape(p) for p in _TAGS) if _TAGS else u"(?!)")
36
+
37
+ # Keeps acronym runs together (ГТД, НН, АТТ) instead of splitting them letter by letter.
38
+ WORD = re.compile(u"[А-ЯЁA-Z]+(?![а-яёa-z])|[А-ЯЁA-Z][а-яёa-z]*|[а-яёa-z]+|\\d+")
39
+
40
+ TRANSLIT = {
41
+ u"а": "a", u"б": "b", u"в": "v", u"г": "g", u"д": "d", u"е": "e", u"ё": "e",
42
+ u"ж": "zh", u"з": "z", u"и": "i", u"й": "y", u"к": "k", u"л": "l", u"м": "m",
43
+ u"н": "n", u"о": "o", u"п": "p", u"р": "r", u"с": "s", u"т": "t", u"у": "u",
44
+ u"ф": "f", u"х": "h", u"ц": "ts", u"ч": "ch", u"ш": "sh", u"щ": "sch",
45
+ u"ъ": "", u"ы": "y", u"ь": "", u"э": "e", u"ю": "yu", u"я": "ya",
46
+ u"і": "i", u"ї": "i", u"є": "ie", u"ґ": "g",
47
+ }
48
+
49
+ # Words the dictionary had no entry for, collected so the gap is visible rather than
50
+ # silently transliterated away.
51
+ UNKNOWN = collections.Counter()
52
+
53
+ MAX_LEN = 55 # Postgres caps identifiers at 63; leave room for the "_id" suffix and more
54
+
55
+ # A column named after a Postgres reserved word passes every JSON schema and then fails
56
+ # the module import with a raw `syntax error at or near "order"`, because not every
57
+ # statement dForge generates quotes its identifiers. These are the collisions the port
58
+ # actually hits; the names are the ones already installed, so regenerating does not move
59
+ # a column that exists in the tenant. Anything new falls back to a `_col` suffix, which
60
+ # `validate.check_reserved` will catch if it ever stops being enough.
61
+ RESERVED_RENAME = {
62
+ "order": "order_id",
63
+ "user": "user_id",
64
+ "natural": "natural_qty",
65
+ }
66
+
67
+ RESERVED_WORDS = set("""
68
+ all analyse analyze and any array as asc asymmetric authorization binary both case cast
69
+ check collate collation column concurrently constraint create cross default deferrable
70
+ desc distinct do else end except false fetch for foreign freeze from full grant group
71
+ having ilike in initially inner intersect into is isnull join lateral leading left like
72
+ limit natural not notnull null offset on only or order outer overlaps placing primary
73
+ references returning right select similar some symmetric table tablesample then to
74
+ trailing true union unique user using variadic verbose when where window with
75
+ """.split())
76
+
77
+
78
+ def translit(word):
79
+ return "".join(TRANSLIT.get(ch, ch if ch.isalnum() else "") for ch in word.lower())
80
+
81
+
82
+ def words(name):
83
+ """`мсбРасходЭнергоресурсов` -> ['Расход', 'Энергоресурсов']"""
84
+ return WORD.findall(PREFIX.sub("", name or ""))
85
+
86
+
87
+ def is_dead(name):
88
+ """1C keeps retired attributes under an `Удалить...` name. They are not ported."""
89
+ head = words(name)
90
+ return bool(head) and head[0].lower().startswith(terms.DEAD_PREFIXES)
91
+
92
+
93
+ def code(name, max_len=MAX_LEN, avoid_reserved=True):
94
+ """1C name -> a snake_case dForge code.
95
+
96
+ `avoid_reserved` is on for anything that becomes a Postgres identifier and off for
97
+ anything that becomes a value - a dropdown option code is stored in the column, not
98
+ used as its name, so renaming it would change the data rather than protect it.
99
+ """
100
+ out = []
101
+ for word in words(name):
102
+ low = word.lower()
103
+ if low in terms.STOP:
104
+ continue
105
+ english = terms.TERMS.get(low)
106
+ if english is None:
107
+ UNKNOWN[low] += 1
108
+ english = translit(low)
109
+ if not english:
110
+ continue
111
+ # `положение` -> `position` twice in a row reads worse than once
112
+ if out and out[-1] == english:
113
+ continue
114
+ out.append(english)
115
+ result = "_".join(out).strip("_")
116
+ result = re.sub(r"_{2,}", "_", result)
117
+ if not result:
118
+ result = translit(name) or "field"
119
+ if result[0].isdigit():
120
+ result = "n" + result
121
+ while len(result) > max_len and "_" in result:
122
+ result = result.rsplit("_", 1)[0]
123
+ result = result[:max_len]
124
+ if avoid_reserved and result in RESERVED_WORDS:
125
+ result = RESERVED_RENAME.get(result, result + "_col")
126
+ return result
127
+
128
+
129
+ def label(name):
130
+ """1C name or synonym -> an English label. `Количество мест` -> `Qty places`."""
131
+ out = []
132
+ for word in words(name):
133
+ low = word.lower()
134
+ if low in terms.STOP:
135
+ continue
136
+ english = terms.TERMS.get(low)
137
+ if english is None:
138
+ UNKNOWN[low] += 1
139
+ english = translit(low)
140
+ if english and (not out or out[-1] != english):
141
+ out.append(english.replace("_", " "))
142
+ text = " ".join(out).strip()
143
+ if not text:
144
+ text = translit(name)
145
+ return text[0].upper() + text[1:] if text else "Field"
146
+
147
+
148
+ MISSING_UK = collections.Counter()
149
+
150
+
151
+ def label_uk(name):
152
+ """1C name or synonym -> a Ukrainian label.
153
+
154
+ Word for word, keeping the order and the case endings the Russian already carries -
155
+ `Расход материалов` is `Витрата матеріалів`. Prepositions are translated rather than
156
+ dropped, because a label is read as a phrase. A word the dictionary does not have is
157
+ left in Russian and counted, so the gap shows up rather than passing as translated.
158
+ """
159
+ source = [w for w in words(name) if w.lower() not in terms_uk.DROP]
160
+ out, index = [], 0
161
+ while index < len(source):
162
+ phrase = terms_uk.PHRASES.get(
163
+ tuple(w.lower() for w in source[index:index + 2]))
164
+ if phrase is not None:
165
+ out.append(phrase)
166
+ index += 2
167
+ continue
168
+ word = source[index]
169
+ low = word.lower()
170
+ translated = terms_uk.PREPOSITIONS.get(low) or terms_uk.UK.get(low)
171
+ if translated is None:
172
+ MISSING_UK[low] += 1
173
+ translated = word
174
+ if translated and (not out or out[-1] != translated):
175
+ out.append(translated)
176
+ index += 1
177
+ text = " ".join(out).strip()
178
+ if not text:
179
+ return None
180
+ return text[0].upper() + text[1:]
181
+
182
+
183
+ def unique(name, taken, max_len=MAX_LEN, avoid_reserved=True):
184
+ """`code()` with a numeric suffix if that code is already spoken for in this scope."""
185
+ base = code(name, max_len, avoid_reserved)
186
+ if base not in taken:
187
+ taken.add(base)
188
+ return base
189
+ for n in range(2, 100):
190
+ candidate = "%s_%d" % (base[:max_len - 3], n)
191
+ if candidate not in taken:
192
+ taken.add(candidate)
193
+ return candidate
194
+ raise ValueError("cannot make %r unique" % base)
195
+
196
+
197
+ def report():
198
+ """How well the dictionary covers the porting scope."""
199
+ import codecs
200
+ import json
201
+ import os
202
+ import attrs
203
+
204
+ root = paths.ROOT
205
+ with codecs.open(os.path.join(root, "out", "report.json"), "r", "utf-8") as f:
206
+ rows = json.load(f)
207
+ objects = attrs.load_json("objects.json")
208
+ types = attrs.load_json("typemap.json")
209
+
210
+ scope = [r for r in rows
211
+ if (r["name"] or "").startswith(binding.client_prefixes())
212
+ and ((r["rows"] or 0) or r["childRows"])]
213
+
214
+ names, fields = 0, 0
215
+ for row in scope:
216
+ code(row["name"])
217
+ label_uk(row.get("synonym") or row["name"])
218
+ names += 1
219
+ _, extracted = attrs.fields_of(row["name"], objects, types)
220
+ for field in (extracted or []):
221
+ if is_dead(field["name"]):
222
+ continue
223
+ code(field["name"])
224
+ label_uk(field.get("synonym") or field["name"])
225
+ fields += 1
226
+
227
+ print("named %d objects and %d fields" % (names, fields))
228
+
229
+ if UNKNOWN:
230
+ print("\nEnglish: %d words fell back to transliteration (%d occurrences)"
231
+ % (len(UNKNOWN), sum(UNKNOWN.values())))
232
+ for word, n in UNKNOWN.most_common(40):
233
+ print(" %-32s %3d -> %s" % (word, n, translit(word)))
234
+ else:
235
+ print("English: every word was in terms.py")
236
+
237
+ if MISSING_UK:
238
+ print("\nUkrainian: %d words left in Russian (%d occurrences)"
239
+ % (len(MISSING_UK), sum(MISSING_UK.values())))
240
+ for word, n in MISSING_UK.most_common(40):
241
+ print(" %-32s %3d" % (word, n))
242
+ else:
243
+ print("Ukrainian: every word was in terms_uk.py")
244
+
245
+
246
+ if __name__ == "__main__":
247
+ if len(sys.argv) > 1:
248
+ for argument in sys.argv[1:]:
249
+ print("%-52s %s" % (argument, code(argument)))
250
+ else:
251
+ report()