mapcan 0.23.1__tar.gz → 0.23.2__tar.gz

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 (158) hide show
  1. {mapcan-0.23.1 → mapcan-0.23.2}/PKG-INFO +1 -1
  2. {mapcan-0.23.1 → mapcan-0.23.2}/docs/format.md +3 -2
  3. {mapcan-0.23.1 → mapcan-0.23.2}/docs/web.md +5 -3
  4. {mapcan-0.23.1 → mapcan-0.23.2}/examples/minimal.yaml +1 -2
  5. {mapcan-0.23.1 → mapcan-0.23.2}/pyproject.toml +1 -1
  6. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/__init__.py +1 -1
  7. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/launcher.py +1 -1
  8. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/locale/ru.py +3 -1
  9. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/lsp/analysis.py +26 -6
  10. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/lsp/edits.py +14 -0
  11. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/validate.py +21 -3
  12. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/app.py +14 -6
  13. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/session.py +63 -22
  14. mapcan-0.23.2/src/mapcan/web/static/assets/index-B4ld-hyP.js +35 -0
  15. mapcan-0.23.2/src/mapcan/web/static/assets/index-qvZIhvUc.css +1 -0
  16. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/static/index.html +2 -2
  17. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_device_file.py +2 -1
  18. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_eds.py +1 -1
  19. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_fmt.py +1 -1
  20. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_include.py +28 -2
  21. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_lsp.py +14 -1
  22. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_lsp_edits.py +27 -4
  23. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_validate.py +33 -0
  24. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_web.py +9 -1
  25. mapcan-0.23.1/src/mapcan/web/static/assets/index-LGXs_rv-.js +0 -31
  26. mapcan-0.23.1/src/mapcan/web/static/assets/index-aNlRJrWm.css +0 -1
  27. {mapcan-0.23.1 → mapcan-0.23.2}/.gitignore +0 -0
  28. {mapcan-0.23.1 → mapcan-0.23.2}/LICENSE +0 -0
  29. {mapcan-0.23.1 → mapcan-0.23.2}/NOTICE +0 -0
  30. {mapcan-0.23.1 → mapcan-0.23.2}/README.md +0 -0
  31. {mapcan-0.23.1 → mapcan-0.23.2}/THIRD_PARTY_NOTICES.md +0 -0
  32. {mapcan-0.23.1 → mapcan-0.23.2}/docs/ci-example.md +0 -0
  33. {mapcan-0.23.1 → mapcan-0.23.2}/docs/cli.md +0 -0
  34. {mapcan-0.23.1 → mapcan-0.23.2}/docs/format-changelog.md +0 -0
  35. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/demo-dark.gif +0 -0
  36. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/demo-light.gif +0 -0
  37. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/document-pages-dark.png +0 -0
  38. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/document-pages-light.png +0 -0
  39. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-demo-dark.gif +0 -0
  40. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-demo-light.gif +0 -0
  41. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-hover-dark.png +0 -0
  42. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-hover-light.png +0 -0
  43. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-map-dark.png +0 -0
  44. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-map-light.png +0 -0
  45. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-problems-dark.png +0 -0
  46. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-problems-light.png +0 -0
  47. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-quickfix-dark.gif +0 -0
  48. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/vscode-quickfix-light.gif +0 -0
  49. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/web-frame-dark.png +0 -0
  50. {mapcan-0.23.1 → mapcan-0.23.2}/docs/images/web-frame-light.png +0 -0
  51. {mapcan-0.23.1 → mapcan-0.23.2}/docs/logo-dark.svg +0 -0
  52. {mapcan-0.23.1 → mapcan-0.23.2}/docs/logo-light.svg +0 -0
  53. {mapcan-0.23.1 → mapcan-0.23.2}/docs/templates.md +0 -0
  54. {mapcan-0.23.1 → mapcan-0.23.2}/docs/zephyr.md +0 -0
  55. {mapcan-0.23.1 → mapcan-0.23.2}/examples/canopen-node.yaml +0 -0
  56. {mapcan-0.23.1 → mapcan-0.23.2}/examples/j1939.yaml +0 -0
  57. {mapcan-0.23.1 → mapcan-0.23.2}/examples/tractor/powertrain.yaml +0 -0
  58. {mapcan-0.23.1 → mapcan-0.23.2}/examples/tractor/tractor.yaml +0 -0
  59. {mapcan-0.23.1 → mapcan-0.23.2}/examples/vehicle-can.yaml +0 -0
  60. {mapcan-0.23.1 → mapcan-0.23.2}/examples/zephyr/CMakeLists.txt +0 -0
  61. {mapcan-0.23.1 → mapcan-0.23.2}/examples/zephyr/prj.conf +0 -0
  62. {mapcan-0.23.1 → mapcan-0.23.2}/examples/zephyr/src/main.c +0 -0
  63. {mapcan-0.23.1 → mapcan-0.23.2}/schema/mapcan-part.schema.json +0 -0
  64. {mapcan-0.23.1 → mapcan-0.23.2}/schema/mapcan.schema.json +0 -0
  65. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/bits.py +0 -0
  66. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/canopen.py +0 -0
  67. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/cli.py +0 -0
  68. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/codegen/__init__.py +0 -0
  69. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/codegen/c.py +0 -0
  70. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/codegen/common.py +0 -0
  71. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/codegen/py.py +0 -0
  72. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/codegen/zephyr.py +0 -0
  73. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/diagnostics.py +0 -0
  74. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/diff.py +0 -0
  75. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/docmodel.py +0 -0
  76. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/__init__.py +0 -0
  77. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/dbc.py +0 -0
  78. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/eds.py +0 -0
  79. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/pdf.py +0 -0
  80. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/text.py +0 -0
  81. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/export/xlsx.py +0 -0
  82. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/extras.py +0 -0
  83. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fmt.py +0 -0
  84. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/OFL-PTSerif.txt +0 -0
  85. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/OFL.txt +0 -0
  86. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Bold.ttf +0 -0
  87. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-BoldItalic.ttf +0 -0
  88. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Italic.ttf +0 -0
  89. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Sans-Web-Regular.ttf +0 -0
  90. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Bold.ttf +0 -0
  91. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-BoldItalic.ttf +0 -0
  92. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Italic.ttf +0 -0
  93. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/fonts/PT_Serif-Web-Regular.ttf +0 -0
  94. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/i18n.py +0 -0
  95. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/importers/__init__.py +0 -0
  96. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/importers/dbc.py +0 -0
  97. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/layout.py +0 -0
  98. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/loader.py +0 -0
  99. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/locale/__init__.py +0 -0
  100. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/lsp/__init__.py +0 -0
  101. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/lsp/exports.py +0 -0
  102. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/lsp/server.py +0 -0
  103. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/model.py +0 -0
  104. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/protocols.py +0 -0
  105. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/resolve.py +0 -0
  106. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/schema.py +0 -0
  107. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/document.html.j2 +0 -0
  108. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/document.md.j2 +0 -0
  109. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-19.typ +0 -0
  110. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-2.105.typ +0 -0
  111. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/pdf/gost-7.32.typ +0 -0
  112. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/pdf/lib.typ +0 -0
  113. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/pdf/mapcan.typ +0 -0
  114. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/templates/xlsx_style.yaml +0 -0
  115. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/units.py +0 -0
  116. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/updates.py +0 -0
  117. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/upgrade.py +0 -0
  118. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/userconfig.py +0 -0
  119. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/__init__.py +0 -0
  120. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/devicefile.py +0 -0
  121. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/examples.py +0 -0
  122. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/newmap.py +0 -0
  123. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/static/OFL-Sora.txt +0 -0
  124. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/static/favicon.svg +0 -0
  125. {mapcan-0.23.1 → mapcan-0.23.2}/src/mapcan/web/yamlops.py +0 -0
  126. {mapcan-0.23.1 → mapcan-0.23.2}/tests/__init__.py +0 -0
  127. {mapcan-0.23.1 → mapcan-0.23.2}/tests/conftest.py +0 -0
  128. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/invalid_map.yaml +0 -0
  129. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/legacy.yaml +0 -0
  130. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/logo.png +0 -0
  131. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/logo.svg +0 -0
  132. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/reference.yaml +0 -0
  133. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/zephyr_stub/zephyr/drivers/can.h +0 -0
  134. {mapcan-0.23.1 → mapcan-0.23.2}/tests/data/zephyr_stub/zephyr/kernel.h +0 -0
  135. {mapcan-0.23.1 → mapcan-0.23.2}/tests/golden/reference.md +0 -0
  136. {mapcan-0.23.1 → mapcan-0.23.2}/tests/golden/reference_bus1.h +0 -0
  137. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_auth.py +0 -0
  138. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_cli.py +0 -0
  139. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_codegen.py +0 -0
  140. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_diff.py +0 -0
  141. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_direction.py +0 -0
  142. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_exchange.py +0 -0
  143. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_export.py +0 -0
  144. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_extras.py +0 -0
  145. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_i18n.py +0 -0
  146. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_import.py +0 -0
  147. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_j1939.py +0 -0
  148. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_launcher.py +0 -0
  149. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_loader_fast.py +0 -0
  150. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_model.py +0 -0
  151. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_new_signal.py +0 -0
  152. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_source.py +0 -0
  153. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_templates.py +0 -0
  154. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_titles.py +0 -0
  155. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_units.py +0 -0
  156. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_upgrade.py +0 -0
  157. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_workspace.py +0 -0
  158. {mapcan-0.23.1 → mapcan-0.23.2}/tests/test_yamlops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mapcan
3
- Version: 0.23.1
3
+ Version: 0.23.2
4
4
  Summary: A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code
5
5
  Project-URL: Homepage, https://github.com/ordu1453/mapcan
6
6
  Project-URL: Documentation, https://github.com/ordu1453/mapcan/blob/main/docs/format.md
@@ -180,7 +180,7 @@ CANopen ID computation:
180
180
  | `terminator` | `string`: the byte that ends a shorter text and fills the rest of the field: a number (`0x00`) or one character (`"*"`, as in J1939 text parameters). Without it the text fills the field and unused bytes are 0x00 |
181
181
  | `start_bit` + `bit_length` | Canonical position |
182
182
  | `byte` [+ `bit`] + `byte_length` \| `bit_length` | Shorthand position (see below) |
183
- | `byte_order` | `little_endian` (default) or `big_endian` |
183
+ | `byte_order` | `little_endian` (default) or `big_endian`. CANopen and J1939 carry little-endian data, so the editors offer the field only on other buses (or when it is set), and validation warns about a big-endian signal there (`byte-order-protocol`) |
184
184
  | `representation` | How the raw value is read as a number: `unsigned` (default), `twos_complement` (the usual signed integer), `ones_complement` or `sign_magnitude`. Generated C and Python code packs and unpacks the value in this representation; the last two have a symmetric range (−127…127 for 8 bits). DBC stores two's complement only, so the export warns about the other two. Formerly `signed` + `signed_code` |
185
185
  | `values` | Value table: `{0: NO_DATA, 1: {name: OK, description: "normal"}}` |
186
186
  | `value_format` | `dec` \| `hex`: how values are shown in documents |
@@ -699,6 +699,7 @@ turned off with `--disable CODE` or `lint.disable`.
699
699
  | `dlc-missing` | error | Frame has no `length` (only CANopen service frames may omit it) |
700
700
  | `canopen-dlc` | warning | Length of a CANopen service frame does not match CiA 301 |
701
701
  | `frame-type-protocol` | warning | `canopen` set on a frame of a non-CANopen bus |
702
+ | `byte-order-protocol` | warning | A big-endian signal spanning bytes on a CANopen or J1939 bus: both carry little-endian data (CiA 301, SAE J1939-71) |
702
703
  | `direction-deprecated` | warning | `direction` is set: it follows from the CANopen function and `sender`; a frame from outside the map goes to the frames of its bus. `mapcan migrate` does both |
703
704
  | `direction-mismatch` | warning | Direction conflicts with the frame's CANopen function |
704
705
 
@@ -712,7 +713,7 @@ turned off with `--disable CODE` or `lint.disable`.
712
713
  | `j1939-field-protocol` | warning | pgn/priority/destination/address/spn on a non-J1939 bus |
713
714
  | `j1939-spn-duplicate` | warning | The same SPN on several signals of the bus |
714
715
  | `j1939-tp` | info | A PGN longer than 8 bytes is sent with the J1939-21 transport protocol |
715
- | `canopen-od` | warning | Dictionary object on a signal outside a device's PDO, outside 0x2000..0x9FFF, or big-endian |
716
+ | `canopen-od` | warning | Dictionary object on a signal outside a device's PDO, or outside 0x2000..0x9FFF |
716
717
  | `od-duplicate` | warning | The same dictionary object on several signals of a device |
717
718
 
718
719
  ### Sender and receivers
@@ -49,6 +49,8 @@ offers to save them before switching.
49
49
 
50
50
  The start screen also lists the bundled examples: pick one, choose where to save a copy, and it
51
51
  opens. The mapcan version is shown in the top bar next to the title and on the start screen.
52
+ **File → Report an issue** (and the link on the start screen) opens a new GitHub issue with the
53
+ versions of mapcan, the server's system and the browser filled in; the map itself is not sent.
52
54
 
53
55
  ### Security
54
56
 
@@ -117,7 +119,7 @@ The profile records who made which changes:
117
119
  | **Receives** | On a device page: the bus frames the device receives (its own RX frames and other devices' frames that list it as a receiver), with their senders. Below it, **Transmits frames of other devices** lists the frames it sends on behalf of other devices (`sender`). Both are computed from the frames; click a row to jump to the frame. On a frame page, **Receivers** are toggled on and off; renaming a device updates them and `sender`. ✕ in a row of another device's frame removes the device from its receivers. On an RX frame, **Frame of another device** picks a frame that is already described at its sender: the device is added to that frame's receivers and its own copy is removed (Ctrl+Z undoes it). The `rx-duplicate` message on such a frame has a button that does the same. |
118
120
  | **Values / Flags** | Enum and bit field tables: value (decimal or `0x…`), name, description. **+ Flag** adds a row without leaving the signal page. |
119
121
  | **Validation** (bottom) | The same rules as `mapcan validate`. Click a message to jump to the node (or, on the YAML tab, to the line). While the YAML tab holds text that is not applied yet (for example, with a syntax error), the panel lists the messages for that text and marks them **YAML text not applied**. |
120
- | **YAML** | Edit the whole file as text. The text is validated as you type: lines with problems are marked in the gutter (hover a line number to read the message), and the first syntax error is shown in the YAML toolbar; click it to jump to its line. Once the text parses, it is applied automatically after a short pause, so the tree and the editor follow your typing; a whole run of typing is one undo step. Switching to the editor applies what is left (it stays on the YAML tab if there is a syntax error). Renaming a device in the text also updates `sender` and `receivers` of the bus frames, as renaming in the form does. Tab and Shift+Tab indent and outdent the selected lines, Enter keeps the indentation (one level deeper after `key:`), Backspace after spaces goes back to the previous tab stop (2 spaces); press Esc first to move focus with Tab. Ctrl+Enter applies the edit at once. While you type a key, a list offers the fields allowed at that place that are not set yet (required ones first), with the field description from the format schema; after `key:` it offers the values of enumerations (`encoding`, `protocol`, `byte_order`, `true`/`false`…). The same works inside a one-line `{...}` mapping (after `{` and after `, `), and a new `- ` list item also offers `{ }` to write the item on one line. Arrows choose, Enter or Tab inserts (a list or object field also opens its first level), Esc closes, Ctrl+Space shows the list on an empty line. |
122
+ | **YAML** | Edit the whole file as text. The text is validated as you type: lines with problems are marked in the gutter (hover a line number to read the message), and the first syntax error is shown in the YAML toolbar; click it to jump to its line. Once the text parses, it is applied automatically after a short pause, so the tree and the editor follow your typing; a whole run of typing is one undo step. Switching to the editor applies what is left (it stays on the YAML tab if there is a syntax error). Renaming a device in the text also updates `sender` and `receivers` of the bus frames, as renaming in the form does. Tab and Shift+Tab indent and outdent the selected lines, Enter keeps the indentation (one level deeper after `key:`), Backspace after spaces goes back to the previous tab stop (2 spaces); press Esc first to move focus with Tab. Ctrl+Enter applies the edit at once. While you type a key, a list offers the fields allowed at that place that are not set yet (required ones first), with the field description from the format schema; after `key:` it offers the values of enumerations (`encoding`, `protocol`, `byte_order`, `true`/`false`…). The same works inside a one-line `{...}` mapping (after `{` and after `, `), and a new `- ` list item also offers `{ }` to write the item on one line. Arrows choose, Enter or Tab inserts (a list or object field also opens its first level), Esc closes, Ctrl+Space shows the list on an empty line. A map with included files (`include`) shows one file at a time: a node selected in the tree opens the file it is written in (a device of `devices/node.yaml`, a frame where its sender describes it), with the node's lines highlighted, and the list in the YAML toolbar switches between the map and its files (• marks a file with unsaved edits). The text of an included file is edited and applied the same way and goes to that file; **Save** writes it. On the editor tab, **Open the YAML of …** in the note above a node of an included file opens it. |
121
123
  | **Export** | Documents (PDF, HTML, Markdown, Excel) and development formats (DBC, C, Python), generated from the current state, including unsaved changes. Clicking a document opens a preview; for PDF, the template is chosen there: `mapcan` or ESKD (see [templates.md](templates.md)). ↓ on the right downloads the file directly. The document language (English or Russian; the map's language by default) is chosen in the menu and in the preview. The ESKD template is always in Russian. |
122
124
  | **Layout and document details** | In the document properties: logo (file upload), designation, letter, organization, city, title block signatures, approval blocks. |
123
125
  | **Git → Compare versions…** | Diffs the current map against a git revision (HEAD, a tag or a commit) or an uploaded file: changes to frames, signals, values and flags. **Add to history** writes the changelog into `document.history` and bumps the version. |
@@ -179,8 +181,8 @@ available at `http://127.0.0.1:8765/api/docs`.
179
181
  | GET | `/api/state` | Text, data, computed layout and diagnostics |
180
182
  | POST | `/api/ops` | `{"ops": [...]}`: apply operations atomically |
181
183
  | POST | `/api/device_file` | `{"bus": ["buses", 0], "device": {...}, "file": "devices/node.yaml"}`: a new device in a new file, added to `include` |
182
- | POST | `/api/text` | Replace the YAML text |
183
- | POST | `/api/check` | `{"text": ...}`: validate text without applying it; returns `{diagnostics}` |
184
+ | POST | `/api/text` | `{"text": ..., "file": null}`: replace the YAML text of the map, or of an included file (`file`, relative to the map) |
185
+ | POST | `/api/check` | `{"text": ..., "file": null}`: validate text without applying it; returns `{diagnostics}` (a message about an included file has `part`: its file, line and column) |
184
186
  | POST | `/api/undo`, `/api/redo` | Edit history |
185
187
  | POST | `/api/save` | `{"force": false}`; `409` means the file changed on disk |
186
188
  | POST | `/api/reload` | Reload the file from disk |
@@ -66,12 +66,11 @@ buses:
66
66
  offset: -20
67
67
  unit: °C
68
68
 
69
- - name: speed # big-endian: byte 5 is the high byte, byte 6 the low byte
69
+ - name: speed # two bytes, little-endian (CANopen): byte 5 is the low byte
70
70
  description: Speed
71
71
  encoding: scaled
72
72
  byte: 5
73
73
  byte_length: 2
74
- byte_order: big_endian
75
74
  unit: rpm
76
75
 
77
76
  - name: counter # arbitrary position: bits 56..59
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mapcan"
3
- version = "0.23.1"
3
+ version = "0.23.2"
4
4
  description = "A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code"
5
5
  # readme: built from README.md with absolute links for PyPI (see fancy-pypi-readme below)
6
6
  dynamic = ["readme"]
@@ -1,6 +1,6 @@
1
1
  """mapcan: a YAML signal map as the single source of truth."""
2
2
 
3
- __version__ = "0.23.1"
3
+ __version__ = "0.23.2"
4
4
 
5
5
  from .loader import load, loads # noqa: E402
6
6
  from .model import MapCan # noqa: E402
@@ -252,7 +252,7 @@ def run(path: Path | None = None, *, window: bool = True, readonly: bool = False
252
252
 
253
253
  app = create_app(session, cwd=cwd.resolve(), readonly=readonly, check_updates=check_updates,
254
254
  instance_token=token if single_instance else None, on_activate=on_activate,
255
- allowed_hosts=LOCAL_HOSTS)
255
+ allowed_hosts=LOCAL_HOSTS, mode="app")
256
256
  server = ServerThread(app, host, port)
257
257
  try:
258
258
  server.start()
@@ -234,6 +234,8 @@ RU: dict[str, str] = {
234
234
  "direction кадра {name!r} противоречит его функции CANopen или отправителю: удалите его и укажите в sender "
235
235
  "устройство, которое передаёт кадр",
236
236
  "CANopen frame type on a non-CANopen bus": "Тип кадра CANopen на шине не CANopen",
237
+ "Big-endian signal on a CANopen or J1939 bus (they carry little-endian data)":
238
+ "Сигнал big-endian на шине CANopen или J1939 (они передают данные little-endian)",
237
239
  "sdo description on a frame that is not sdo_tx/sdo_rx": "Описание sdo у кадра не типа sdo_tx/sdo_rx",
238
240
  "Duplicate SDO exchange (command, index, subindex)": "Повтор SDO-обмена (команда, индекс, субиндекс)",
239
241
  "Name does not match the style rule (lint)": "Имя не соответствует правилу стиля (lint)",
@@ -480,7 +482,7 @@ RU: dict[str, str] = {
480
482
  "{name!r}: subindex without index": "{name!r}: subindex без index",
481
483
  "{name!r}: index {index:#06x} is outside the manufacturer and profile areas (0x2000..0x9FFF)":
482
484
  "{name!r}: индекс {index:#06x} вне областей производителя и профилей (0x2000..0x9FFF)",
483
- "{name!r}: CANopen PDOs carry little-endian data": "{name!r}: PDO CANopen передаёт данные little-endian",
485
+ "{name!r}: {protocol} carries little-endian data": "{name!r}: {protocol} передаёт данные little-endian",
484
486
  "object {index:04X}:{sub:02X} is already mapped by {other}": "объект {index:04X}:{sub:02X} уже у сигнала {other}",
485
487
  "CANopen: index of the dictionary object mapped into the PDO.":
486
488
  "CANopen: индекс объекта словаря, отображённого в PDO.",
@@ -446,7 +446,8 @@ def device_label(d: dict, lang: str) -> str:
446
446
 
447
447
 
448
448
  def map_tree(a: Analysis) -> list[dict]:
449
- """Nodes: kind, label, ident, description, dir (frames), from (received frames), range, selection, children.
449
+ """Nodes: kind, label, ident, description, dir (frames), from (received frames), to (frames the device sends,
450
+ described at their receiver), range, selection, children.
450
451
 
451
452
  Labels are as in the web editor's tree: titles in the document language, the identifier (``ident``)
452
453
  only if there is no title. Built from the raw YAML; computed IDs and bits come from the model if the map parses.
@@ -484,7 +485,7 @@ def map_tree(a: Analysis) -> list[dict]:
484
485
  def model(path: Path_):
485
486
  return _find(a.map, path) if a.map is not None else None
486
487
 
487
- def frame_node(f: dict, path: Path_) -> dict | None:
488
+ def frame_node(f: dict, path: Path_, devices: list) -> dict | None:
488
489
  loc = rng(path)
489
490
  if loc is None:
490
491
  return None
@@ -494,7 +495,12 @@ def map_tree(a: Analysis) -> list[dict]:
494
495
  sub = f"PGN {_hexw(f['pgn'], 4)}"
495
496
  elif isinstance(rf, M.Frame) and rf.can_id is not None:
496
497
  sub = _hex(rf.can_id, rf.extended)
497
- node = {"kind": "frame", "label": display_name(f, lang), "ident": name_of(f), "description": sub,
498
+ label = display_name(f, lang)
499
+ # A frame described at its receiver (sender: another device): the label names the sender, as a link does
500
+ if isinstance(rf, M.Frame) and rf.device is not None and rf.sender_name not in (None, rf.device.name):
501
+ sender = next((x for x in devices if x.get("name") == rf.sender_name), None)
502
+ label = f"{device_label(sender, lang) if sender else rf.sender_name} · {label}"
503
+ node = {"kind": "frame", "label": label, "ident": name_of(f), "description": sub,
498
504
  "dir": rf.direction if isinstance(rf, M.Frame) and rf.device is not None else None,
499
505
  "children": [], **loc}
500
506
  for si, s in enumerate(_items(f.get("signals"))):
@@ -533,10 +539,10 @@ def map_tree(a: Analysis) -> list[dict]:
533
539
  bnode = {"kind": "bus", "label": display_name(bus, lang), "ident": name_of(bus),
534
540
  "description": bus_load_text(mbus) if is_bus else "",
535
541
  "load_note": bus_load_note(mbus) if is_bus else "", "children": [], **bl}
542
+ devices = _items(bus.get("devices"))
536
543
  for fi, f in enumerate(_items(bus.get("frames"))):
537
- if (fn := frame_node(f, bp + ("frames", fi))) is not None:
544
+ if (fn := frame_node(f, bp + ("frames", fi), devices)) is not None:
538
545
  bnode["children"].append(fn)
539
- devices = _items(bus.get("devices"))
540
546
  for di, d in enumerate(devices):
541
547
  dp = bp + ("devices", di)
542
548
  if (dl := rng(dp)) is None:
@@ -546,7 +552,7 @@ def map_tree(a: Analysis) -> list[dict]:
546
552
  dnode = {"kind": "device", "label": display_name(d, lang), "ident": name_of(d), "description": sub,
547
553
  "children": [], **dl}
548
554
  for fi, f in enumerate(_items(d.get("frames"))):
549
- if (fn := frame_node(f, dp + ("frames", fi))) is not None:
555
+ if (fn := frame_node(f, dp + ("frames", fi), devices)) is not None:
550
556
  dnode["children"].append(fn)
551
557
  # Frames of other devices that the device receives: links after its own; the frame's title may
552
558
  # not tell whose it is, so the link's label names the sender
@@ -563,6 +569,20 @@ def map_tree(a: Analysis) -> list[dict]:
563
569
  "ident": rf.name, "dir": "rx", "from": sender_label or rf.sender_name or "",
564
570
  "description": _hex(rf.can_id, rf.extended) if rf.can_id is not None else "",
565
571
  "children": [], **loc})
572
+ # Frames the device sends that are described at their receiver (sender: this device): links too, the
573
+ # label names the device they are described at
574
+ for rf in mbus.all_frames() if isinstance(mbus, M.Bus) else []:
575
+ if (rf.device is None or rf.device.name == d.get("name") or rf.sender_name != d.get("name")
576
+ or (loc := rng(rf._path)) is None):
577
+ continue
578
+ owner = next((x for x in devices if x.get("name") == rf.device.name), None)
579
+ owner_label = device_label(owner, lang) if owner else rf.device.name
580
+ frame = raw_at_path(raw, rf._path)
581
+ title = display_name(frame if isinstance(frame, dict) else {"name": rf.name}, lang)
582
+ dnode["children"].append({
583
+ "kind": "frame", "label": f"{owner_label} · {title}", "ident": rf.name, "dir": "tx",
584
+ "to": owner_label, "description": _hex(rf.can_id, rf.extended) if rf.can_id is not None else "",
585
+ "children": [], **loc})
566
586
  bnode["children"].append(dnode)
567
587
  out.append(bnode)
568
588
  return out
@@ -1245,6 +1245,8 @@ def set_field(a: Analysis, path: Path_, value) -> list[Edit]:
1245
1245
  return [minimal_edit(a.text, apply_ops(a, [op]))]
1246
1246
  if key == "canopen" and node_kind(path[:-1]) == "frame":
1247
1247
  return _set_frame_type(a, path, value)
1248
+ if key == "sender" and node_kind(path[:-1]) == "frame":
1249
+ return _set_sender(a, path, value)
1248
1250
  if not isinstance(raw_at(a, path[:-1]), dict):
1249
1251
  # A field of a nested mapping that does not exist yet (value.name of an SDO): the mapping is added with it
1250
1252
  parent = path[:-1]
@@ -1307,6 +1309,18 @@ def _set_frame_type(a: Analysis, path: Path_, value) -> list[Edit]:
1307
1309
  return edits
1308
1310
 
1309
1311
 
1312
+ def _set_sender(a: Analysis, path: Path_, value) -> list[Edit]:
1313
+ """The sender of a frame; a device named as the sender does not receive the frame, so it leaves the receivers."""
1314
+ receivers = raw_at(a, path[:-1] + ("receivers",))
1315
+ if not value or not isinstance(receivers, list) or value not in receivers:
1316
+ return _set_plain(a, path, value)
1317
+ rest = [r for r in receivers if r != value]
1318
+ ops = [{"op": "set", "path": list(path), "value": value},
1319
+ {"op": "set", "path": list(path[:-1] + ("receivers",)), "value": rest} if rest
1320
+ else {"op": "remove", "path": list(path[:-1] + ("receivers",))}]
1321
+ return [minimal_edit(a.text, apply_ops(a, ops))]
1322
+
1323
+
1310
1324
  def _set_plain(a: Analysis, path: Path_, value) -> list[Edit]:
1311
1325
  """A scalar field without special rules: a new value, or ``None`` or "" to remove the key."""
1312
1326
  current = raw_at(a, path)
@@ -107,6 +107,8 @@ RULES = {
107
107
  "direction-mismatch": ("warning", N_("Direction conflicts with the CANopen frame type")),
108
108
  "direction-deprecated": ("warning", N_("direction is set: it follows from the CANopen function and the sender")),
109
109
  "frame-type-protocol": ("warning", N_("CANopen frame type on a non-CANopen bus")),
110
+ "byte-order-protocol": ("warning", N_("Big-endian signal on a CANopen or J1939 bus (they carry little-endian "
111
+ "data)")),
110
112
  "sdo-frame-type": ("warning", N_("sdo description on a frame that is not sdo_tx/sdo_rx")),
111
113
  "sdo-duplicate": ("error", N_("Duplicate SDO exchange (command, index, subindex)")),
112
114
  "name-style": ("warning", N_("Name does not match the style rule (lint)")),
@@ -355,6 +357,7 @@ def _check_bus(ctx: _Ctx, bus) -> None:
355
357
  ctx.emit("can-id-range", _("CAN ID {can_id:#x} > 0x7FF", can_id=frame.can_id), p + ("id",))
356
358
  _check_spn(ctx, bus)
357
359
  _check_od(ctx, bus)
360
+ _check_byte_order(ctx, bus)
358
361
  _check_bus_load(ctx, bus)
359
362
 
360
363
 
@@ -374,9 +377,6 @@ def _check_od(ctx: _Ctx, bus) -> None:
374
377
  elif not 0x2000 <= s.index <= 0x9FFF:
375
378
  ctx.emit("canopen-od", _("{name!r}: index {index:#06x} is outside the manufacturer and profile areas "
376
379
  "(0x2000..0x9FFF)", name=s.name, index=s.index), p)
377
- elif s.byte_order == "big_endian":
378
- ctx.emit("canopen-od", _("{name!r}: CANopen PDOs carry little-endian data", name=s.name),
379
- s._path + ("byte_order",))
380
380
  for dev in bus.devices:
381
381
  seen: dict[tuple[int, int], str] = {}
382
382
  for frame in dev.frames:
@@ -391,6 +391,24 @@ def _check_od(ctx: _Ctx, bus) -> None:
391
391
  seen[key] = f"{frame.name}.{s.name}"
392
392
 
393
393
 
394
+ def _check_byte_order(ctx: _Ctx, bus) -> None:
395
+ """CANopen (CiA 301) and J1939 (SAE J1939-71) carry little-endian data: a big-endian number spanning bytes is
396
+ a mistake there (within one byte the order changes nothing)."""
397
+ if bus.protocol not in ("canopen", "j1939"):
398
+ return
399
+ for frame in bus.all_frames():
400
+ for s in frame.signals:
401
+ if s.byte_order != "big_endian" or s.encoding in ("bytes", "string"):
402
+ continue
403
+ if len({b // 8 for b in B.occupied_bits(s.start_bit, s.bit_length, True)}) > 1:
404
+ ctx.emit("byte-order-protocol", _("{name!r}: {protocol} carries little-endian data",
405
+ name=s.name, protocol=_PROTOCOL_NAMES[bus.protocol]),
406
+ s._path + ("byte_order",))
407
+
408
+
409
+ _PROTOCOL_NAMES = {"canopen": "CANopen", "j1939": "J1939"}
410
+
411
+
394
412
  def _check_spn(ctx: _Ctx, bus) -> None:
395
413
  seen: dict[int, str] = {}
396
414
  for frame in bus.all_frames():
@@ -9,6 +9,7 @@ import hmac
9
9
  import html
10
10
  import io
11
11
  import os
12
+ import platform
12
13
  import re
13
14
  import shutil
14
15
  import string
@@ -66,6 +67,7 @@ class DeviceFileBody(BaseModel):
66
67
  class TextBody(BaseModel):
67
68
  text: str
68
69
  live: bool = False # applied while typing (see EditorSession.set_text)
70
+ file: str | None = None # the text of an included file (relative to the map); None: of the map
69
71
 
70
72
 
71
73
  class SaveBody(BaseModel):
@@ -253,10 +255,11 @@ class TokenAuth:
253
255
  class Workspace:
254
256
  """The open map (there may be none), the workspace folder, the user settings."""
255
257
 
256
- def __init__(self, session: EditorSession | None, cwd: Path, readonly: bool):
258
+ def __init__(self, session: EditorSession | None, cwd: Path, readonly: bool, mode: str = "serve"):
257
259
  self.session = session
258
260
  self.cwd = cwd
259
261
  self.readonly = readonly
262
+ self.mode = mode
260
263
  self.generation = 1 if session else 0
261
264
  self.config = UserConfig.load()
262
265
  if session:
@@ -305,6 +308,9 @@ class Workspace:
305
308
  "open": s is not None,
306
309
  "file": str(s.path) if s else None,
307
310
  "session_id": self.generation,
311
+ # For a bug report: how mapcan runs (the app window or mapcan serve) and on what
312
+ "mode": self.mode,
313
+ "system": f"{platform.system()} {platform.release()}, Python {platform.python_version()}",
308
314
  }
309
315
 
310
316
 
@@ -526,7 +532,8 @@ def create_app(session: EditorSession | None = None, poll_interval: float = 1.0,
526
532
  cwd: Path | None = None, readonly: bool | None = None, *,
527
533
  check_updates: bool = False, instance_token: str | None = None,
528
534
  on_activate: Callable[[], None] | None = None,
529
- allowed_hosts: Collection[str] | None = None, auth_token: str | None = None) -> FastAPI:
535
+ allowed_hosts: Collection[str] | None = None, auth_token: str | None = None,
536
+ mode: str = "serve") -> FastAPI:
530
537
  """The editor application.
531
538
 
532
539
  ``allowed_hosts`` are the names allowed in the ``Host`` header (for a server on localhost),
@@ -534,13 +541,14 @@ def create_app(session: EditorSession | None = None, poll_interval: float = 1.0,
534
541
  ``auth_token``: every request needs this token (see :class:`TokenAuth`), for a server on the network;
535
542
  ``check_updates``: answer /api/update with information from GitHub Releases;
536
543
  ``instance_token`` allows /api/instance/activate (mapcan run again
537
- passes the file to the running instance), ``on_activate`` brings the window to the front.
544
+ passes the file to the running instance), ``on_activate`` brings the window to the front;
545
+ ``mode``: "app" (the app window) or "serve" (for a bug report).
538
546
  """
539
547
  hub = Hub()
540
548
  lock = asyncio.Lock()
541
549
  root = cwd or (session.path.parent if session else Path.cwd())
542
550
  wsp = Workspace(session, root.resolve(),
543
- readonly if readonly is not None else bool(session and session.readonly))
551
+ readonly if readonly is not None else bool(session and session.readonly), mode)
544
552
 
545
553
  @contextlib.asynccontextmanager
546
554
  async def lifespan(app: FastAPI):
@@ -775,13 +783,13 @@ def create_app(session: EditorSession | None = None, poll_interval: float = 1.0,
775
783
  @app.post("/api/text")
776
784
  async def post_text(body: TextBody) -> dict:
777
785
  async with lock:
778
- await run_in_threadpool(wsp.require().set_text, body.text, body.live)
786
+ await run_in_threadpool(wsp.require().set_text, body.text, body.live, body.file)
779
787
  return await changed()
780
788
 
781
789
  @app.post("/api/check")
782
790
  async def post_check(body: TextBody) -> dict:
783
791
  s = wsp.require()
784
- return {"diagnostics": await run_in_threadpool(s.check_text, body.text)}
792
+ return {"diagnostics": await run_in_threadpool(s.check_text, body.text, body.file)}
785
793
 
786
794
  @app.post("/api/undo")
787
795
  async def post_undo() -> dict:
@@ -107,7 +107,8 @@ class EditorSession:
107
107
  self.version = 0
108
108
  self.undo_stack: list[Snapshot] = []
109
109
  self.redo_stack: list[Snapshot] = []
110
- self._live_base: str | None = None # text before a series of edits in the YAML tab while typing
110
+ # The file (None: the map) and its text before a series of edits in the YAML tab while typing
111
+ self._live_base: tuple[str | None, str] | None = None
111
112
  self._result: LoadResult | None = None
112
113
  self._report = None
113
114
  self._last_check: tuple | None = None # (text and included files, result, report) of check_text
@@ -131,11 +132,22 @@ class EditorSession:
131
132
  return None
132
133
  return part.text
133
134
 
134
- def _load(self, text: str) -> LoadResult:
135
- return loads(text, str(self.path.name), self.path.parent, read=self._read_part)
135
+ def _load(self, text: str, override: tuple[str, str] | None = None) -> LoadResult:
136
+ """Load the map; ``override``: an included file (its key in parts) read with another text."""
137
+ def read(path: str) -> str | None:
138
+ return override[1] if override is not None and path == override[0] else self._read_part(path)
139
+ return loads(text, str(self.path.name), self.path.parent, read=read)
136
140
 
137
- def _parts_key(self) -> tuple:
138
- return tuple((p, part.text) for p, part in sorted(self.parts.items()))
141
+ def _parts_key(self, override: tuple[str, str] | None = None) -> tuple:
142
+ return tuple((p, override[1] if override is not None and p == override[0] else part.text)
143
+ for p, part in sorted(self.parts.items()))
144
+
145
+ def part_key(self, file: str) -> str:
146
+ """An included file by its path relative to the map (as the state names it): its key in parts."""
147
+ for k, part in self.parts.items():
148
+ if part.text is not None and self._rel(part.path) == file:
149
+ return k
150
+ raise OpError(_("the included file {file} cannot be read", file=file))
139
151
 
140
152
  def _checked(self, text: str) -> tuple[LoadResult, Report]:
141
153
  """The load and validation of a text: those of the live validation of the same text (and the same
@@ -227,14 +239,29 @@ class EditorSession:
227
239
  self._result, self._report = self._checked(text)
228
240
  self.version += 1
229
241
 
230
- def check_text(self, text: str) -> list[dict]:
231
- """Validate a text without applying it (live validation in the YAML tab)."""
242
+ def check_text(self, text: str, file: str | None = None) -> list[dict]:
243
+ """Validate a text without applying it (live validation in the YAML tab). ``file``: the text is that of
244
+ an included file (relative to the map), with the map's current text."""
232
245
  text = text.replace("\r\n", "\n")
233
- key = (text, self._parts_key())
234
- result = self._load(text)
246
+ with self.lock:
247
+ override = (self.part_key(file), text) if file is not None else None
248
+ main = self.text if override is not None else text
249
+ key = (main, self._parts_key(override))
250
+ result = self._load(main, override)
235
251
  report = check(result)
236
252
  self._last_check = (key, result, report)
237
- return [asdict(result.source.in_main_file(d)) for d in report.sorted()]
253
+ return self._diag_dicts(result, report)
254
+
255
+ def _diag_dicts(self, result: LoadResult, report: Report) -> list[dict]:
256
+ """Messages for the editor, on the include line in the map; one about an included file also has its place
257
+ in that file (``part``: the file relative to the map, line, column) for the YAML tab of that file."""
258
+ out = []
259
+ for d in report.sorted():
260
+ item = asdict(result.source.in_main_file(d))
261
+ if d.file is not None and d.file != result.source.file:
262
+ item["part"] = {"file": self._rel(d.file), "line": d.line, "column": d.column}
263
+ out.append(item)
264
+ return out
238
265
 
239
266
  def recheck(self) -> None:
240
267
  """Rebuild the validation messages (after the interface language changed)."""
@@ -378,8 +405,8 @@ class EditorSession:
378
405
  return text
379
406
  return merge_text(text, canon_old, dump(y, doc))
380
407
 
381
- def set_text(self, text: str, live: bool = False) -> bool:
382
- """Take the text of the YAML tab.
408
+ def set_text(self, text: str, live: bool = False, file: str | None = None) -> bool:
409
+ """Take the text of the YAML tab: of the map, or of the included file ``file`` (relative to the map).
383
410
 
384
411
  `live`: an intermediate application while typing; such edits in a row merge into one undo
385
412
  step until a normal application or another edit comes. Device renames are counted
@@ -387,21 +414,28 @@ class EditorSession:
387
414
  """
388
415
  with self.lock:
389
416
  self._check_writable()
390
- base = self._live_base
417
+ key = self.part_key(file) if file is not None else None
418
+ current = self.text if key is None else self.parts[key].text
419
+ live_base = self._live_base
391
420
  self._live_base = None
392
- text = self._carry_renames(self.text if base is None else base, text.replace("\r\n", "\n"))
421
+ # A series goes on only in the same file
422
+ base = live_base[1] if live_base is not None and live_base[0] == key else None
423
+ text = self._carry_renames(current if base is None else base, text.replace("\r\n", "\n"))
393
424
  if base is None:
394
- changed = self._commit(text)
425
+ changed = self._commit(text) if key is None else self._commit(self.text, {key: text})
395
426
  if live and changed:
396
- self._live_base = self.undo_stack[-1][0]
427
+ self._live_base = (key, current)
397
428
  return changed
398
429
  if text == base: # the series brought the text back: no undo step needed
399
430
  self.undo_stack.pop()
400
431
  elif live:
401
- self._live_base = base
402
- if text == self.text:
432
+ self._live_base = (key, base)
433
+ if text == current:
403
434
  return False
404
435
  self.redo_stack.clear()
436
+ if key is not None:
437
+ self.parts[key].text = text
438
+ text = self.text
405
439
  self._set_text(text)
406
440
  return True
407
441
 
@@ -432,10 +466,17 @@ class EditorSession:
432
466
  and the nodes they added, with the file's path relative to the map."""
433
467
  merged = self._result.merged if self._result else None
434
468
  if not isinstance(merged, dict) or not isinstance(merged.get("buses"), list):
435
- return {"included_buses": None, "included": []}
436
- roots = self._result.source.included_roots()
469
+ return {"included_buses": None, "included": [], "parts": {}}
470
+ src = self._result.source
471
+ roots = src.included_roots()
472
+ files = dict.fromkeys(str(Path(f).resolve()) for _i, f in src.includes)
437
473
  return {"included_buses": merged["buses"],
438
- "included": [{"path": list(p), "file": self._rel(f)} for p, f in sorted(roots, key=lambda r: r[0])]}
474
+ # local: the node's path in its file (the second device of a bus may be the first of the file)
475
+ "included": [{"path": list(p), "file": self._rel(f), "local": list(src.origin(p)[1])}
476
+ for p, f in sorted(roots, key=lambda r: r[0])],
477
+ # Texts of the included files (relative to the map), for the YAML tab
478
+ "parts": {self._rel(self.parts[k].path): self.parts[k].text for k in files
479
+ if k in self.parts and self.parts[k].text is not None}}
439
480
 
440
481
  def _rel(self, f: str | Path) -> str:
441
482
  try:
@@ -467,7 +508,7 @@ class EditorSession:
467
508
  "data": upgrade_tree(plain(self.doc)) if self.doc is not None else None,
468
509
  "resolved": resolved_view(self.map) if self.map else None,
469
510
  **self._included(),
470
- "diagnostics": [asdict(self._result.source.in_main_file(d)) for d in self._report.sorted()],
511
+ "diagnostics": self._diag_dicts(self._result, self._report),
471
512
  "palette": PALETTE,
472
513
  "units": UNITS,
473
514
  }