archsight 0.3.3 → 0.4.0

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 (173) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/docs/archimate.md +21 -1
  4. data/docs/configuration.md +1 -0
  5. data/docs/diagram.md +15 -3
  6. data/docs/docker.md +4 -1
  7. data/docs/icons.md +8 -2
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +24 -18
  10. data/docs/modeling.md +637 -34
  11. data/docs/pages.md +44 -13
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +2 -1
  14. data/lib/archsight/analysis/result.rb +8 -2
  15. data/lib/archsight/analysis/sandbox.rb +8 -0
  16. data/lib/archsight/analysis/typst_renderer.rb +246 -0
  17. data/lib/archsight/analysis.rb +1 -0
  18. data/lib/archsight/annotations/annotation.rb +30 -3
  19. data/lib/archsight/annotations/architecture_annotations.rb +47 -0
  20. data/lib/archsight/annotations/link_annotations.rb +15 -0
  21. data/lib/archsight/annotations/resource_reference.rb +12 -0
  22. data/lib/archsight/cli.rb +124 -23
  23. data/lib/archsight/database.rb +40 -0
  24. data/lib/archsight/diagram/cli.rb +50 -2
  25. data/lib/archsight/diagram/formatter.rb +112 -0
  26. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  27. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  28. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  29. data/lib/archsight/diagram/style/theme.rb +7 -2
  30. data/lib/archsight/documentation.rb +4 -4
  31. data/lib/archsight/editor/page_source.rb +13 -1
  32. data/lib/archsight/export/confluence/analysis_output.rb +101 -0
  33. data/lib/archsight/export/confluence/client.rb +13 -0
  34. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  35. data/lib/archsight/export/confluence/exporter.rb +40 -13
  36. data/lib/archsight/export/confluence/labels.rb +25 -0
  37. data/lib/archsight/export/confluence/page_properties.rb +4 -6
  38. data/lib/archsight/export/confluence/storage.rb +38 -2
  39. data/lib/archsight/helpers/embeds.rb +18 -7
  40. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  41. data/lib/archsight/import/executor.rb +139 -16
  42. data/lib/archsight/import/handler.rb +55 -6
  43. data/lib/archsight/import/handler_loader.rb +64 -0
  44. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  45. data/lib/archsight/import/handlers/command.rb +120 -0
  46. data/lib/archsight/import/handlers/github.rb +4 -1
  47. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  48. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  49. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  50. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  51. data/lib/archsight/import/handlers/grapher.rb +40 -1
  52. data/lib/archsight/import/handlers/repository.rb +3 -0
  53. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  54. data/lib/archsight/import/registry.rb +22 -1
  55. data/lib/archsight/import.rb +64 -1
  56. data/lib/archsight/linter/structure.rb +239 -0
  57. data/lib/archsight/linter.rb +121 -14
  58. data/lib/archsight/page_loader.rb +12 -1
  59. data/lib/archsight/query/evaluator.rb +13 -6
  60. data/lib/archsight/query/parser.rb +73 -6
  61. data/lib/archsight/query.rb +1 -1
  62. data/lib/archsight/references.rb +3 -3
  63. data/lib/archsight/renderer.rb +80 -23
  64. data/lib/archsight/resources/analysis.rb +3 -2
  65. data/lib/archsight/resources/application_component.rb +75 -7
  66. data/lib/archsight/resources/application_service.rb +12 -1
  67. data/lib/archsight/resources/base.rb +58 -5
  68. data/lib/archsight/resources/business_actor.rb +5 -0
  69. data/lib/archsight/resources/business_contract.rb +72 -0
  70. data/lib/archsight/resources/business_product.rb +12 -0
  71. data/lib/archsight/resources/business_role.rb +1 -0
  72. data/lib/archsight/resources/implementation_deliverable.rb +1 -0
  73. data/lib/archsight/resources/implementation_plateau.rb +8 -2
  74. data/lib/archsight/resources/implementation_work_package.rb +2 -1
  75. data/lib/archsight/resources/import.rb +37 -11
  76. data/lib/archsight/resources/location.rb +56 -0
  77. data/lib/archsight/resources/motivation_requirement.rb +6 -0
  78. data/lib/archsight/resources/motivation_stakeholder.rb +14 -0
  79. data/lib/archsight/resources/page.rb +4 -7
  80. data/lib/archsight/resources/strategy_capability.rb +9 -0
  81. data/lib/archsight/resources/technology_artifact.rb +11 -4
  82. data/lib/archsight/resources/technology_communication_network.rb +89 -0
  83. data/lib/archsight/resources/technology_device.rb +88 -0
  84. data/lib/archsight/resources/technology_equipment.rb +75 -0
  85. data/lib/archsight/resources/technology_facility.rb +70 -0
  86. data/lib/archsight/resources/technology_node.rb +50 -0
  87. data/lib/archsight/resources/technology_service.rb +33 -0
  88. data/lib/archsight/resources/technology_system_software.rb +28 -0
  89. data/lib/archsight/resources.rb +12 -0
  90. data/lib/archsight/version.rb +1 -1
  91. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  92. data/lib/archsight/web/api/json_helpers.rb +3 -37
  93. data/lib/archsight/web/api/openapi/spec.yaml +10 -8
  94. data/lib/archsight/web/api/page_helpers.rb +1 -8
  95. data/lib/archsight/web/api/routes.rb +7 -2
  96. data/lib/archsight/web/application.rb +38 -2
  97. data/lib/archsight/web/editor/routes.rb +2 -1
  98. data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
  99. data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
  100. data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
  101. data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
  102. data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
  103. data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
  104. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  105. data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
  106. data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
  107. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  108. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  109. data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
  110. data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
  111. data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
  112. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  113. data/lib/archsight/web/public/vue/{SearchResults-BewvsfOc.css → SearchResults-BV6UHriJ.css} +1 -1
  114. data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
  115. data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
  116. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  117. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
  118. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
  119. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
  120. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
  121. data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
  122. data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
  123. data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
  124. data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
  125. data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
  126. data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
  127. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
  128. data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
  129. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
  130. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
  131. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
  132. data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
  133. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
  134. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
  135. data/lib/archsight/web/public/vue/{useGraphviz-DlSnFeCL.js → useGraphviz-4evusK9i.js} +1 -1
  136. data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
  137. data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
  138. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
  139. data/lib/archsight/web/public/vue.html +5 -5
  140. metadata +58 -38
  141. data/lib/archsight/web/public/vue/ApiDocsPage-BgqnQgwa.js +0 -1
  142. data/lib/archsight/web/public/vue/DocPage-CNO71nBH.js +0 -1
  143. data/lib/archsight/web/public/vue/EditorPage-DR0FCNTz.js +0 -35
  144. data/lib/archsight/web/public/vue/ErrorPage-BfYArv6s.js +0 -2
  145. data/lib/archsight/web/public/vue/GraphView-BVTW30Ak.js +0 -1
  146. data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +0 -2
  147. data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +0 -1
  148. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  149. data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +0 -1
  150. data/lib/archsight/web/public/vue/PageView-BaN6TyJB.js +0 -1
  151. data/lib/archsight/web/public/vue/QueryError-D790wH-a.js +0 -1
  152. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  153. data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +0 -2
  154. data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +0 -1
  155. data/lib/archsight/web/public/vue/WikiPage-D2svpk6B.js +0 -13
  156. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  157. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  158. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  159. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  160. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  161. data/lib/archsight/web/public/vue/index-BXXkUK1V.js +0 -3
  162. data/lib/archsight/web/public/vue/index-Cov1SnzY.css +0 -1
  163. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  164. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  165. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  166. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  167. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  168. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  169. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  170. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  171. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  172. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  173. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -41,14 +41,15 @@ module Archsight
41
41
  def candidate_paths(a, b, style, from_shape: "rectangle", to_shape: "rectangle", obstacles: [])
42
42
  # A straight axis-aligned line joins the straight family (and the
43
43
  # automatic choice), listed first so it wins ties against the
44
- # diagonal. An explicit `style "orthogonal"` keeps its bends.
44
+ # diagonal. It is orthogonal too (no bend at all), so an explicit
45
+ # `style "orthogonal"` takes it as well and only never goes diagonal.
45
46
  aligned = StraightPath.aligned_paths(a, b, from_shape: from_shape, to_shape: to_shape)
46
47
  straight = [StraightPath.straight_path(a, b, from_shape: from_shape, to_shape: to_shape)]
47
48
  orthogonal = OrthogonalPath.orthogonal_candidates(a, b, obstacles)
48
49
 
49
50
  case style
50
51
  when "straight" then aligned + straight
51
- when "orthogonal" then orthogonal
52
+ when "orthogonal" then aligned + orthogonal
52
53
  else aligned + straight + orthogonal
53
54
  end
54
55
  end
@@ -71,8 +72,22 @@ module Archsight
71
72
  # Charged relative to the best candidate in the same crossing class
72
73
  # rather than absolutely, so however large it is it never tips the
73
74
  # balance against `CROSSING_PENALTY`: a bridge that clears a box still
74
- # beats a bend-free line through it.
75
+ # beats a bend-free line through it. An extra bend never costs more
76
+ # than `TURN_LENGTH_FACTOR` times the route's own length, though: a
77
+ # short zig-zag still beats a one-corner detour more than three times
78
+ # its length (out of a box's side and all the way along it instead of
79
+ # a short jog off its top).
75
80
  TURN_PENALTY = 300.0
81
+ TURN_LENGTH_FACTOR = 2.0
82
+
83
+ # A bent route's run out of or into a box shorter than this hugs the
84
+ # box's border instead of reading as a connection to it (a turn right
85
+ # past the corner of the box it then enters). Every pixel short of it
86
+ # costs `SHORT_LEG_WEIGHT` pixels of length, so a route a little longer
87
+ # but with a real run in wins (see `OrthogonalPath::EXIT_SHIFT`). A
88
+ # straight line has no turn to hug with and is never charged.
89
+ MIN_END_LEG = BridgePath::BRIDGE_STUB
90
+ SHORT_LEG_WEIGHT = 3.0
76
91
 
77
92
  # Two edges running right along each other for a stretch reads as
78
93
  # clutter too, but far more mildly than either one cutting through a
@@ -130,7 +145,17 @@ module Archsight
130
145
  # Folded into `length` (rather than a separate term) so the native
131
146
  # selection kernels, which only know crossings and length, apply it too.
132
147
  extra_turns = turns[i] - fewest_turns[crossings[i]]
133
- { path: path, crossing: crossings[i], length: lengths[i] + (TURN_PENALTY * extra_turns) + off_center_cost(path) }
148
+ turn_cost = [TURN_PENALTY, lengths[i] * TURN_LENGTH_FACTOR].min * extra_turns
149
+ { path: path, crossing: crossings[i], length: lengths[i] + turn_cost + off_center_cost(path) + short_leg_cost(path) }
150
+ end
151
+ end
152
+
153
+ # See `MIN_END_LEG`.
154
+ def short_leg_cost(path)
155
+ return 0.0 if path.length < 3
156
+
157
+ [path.first(2), path.last(2)].sum do |(p, q)|
158
+ [MIN_END_LEG - Math.hypot(q[0] - p[0], q[1] - p[1]), 0.0].max * SHORT_LEG_WEIGHT
134
159
  end
135
160
  end
136
161
 
@@ -37,7 +37,9 @@ module Archsight
37
37
  # once it's `PORT_ORDER_PROBE` out from the side (see `order_key`);
38
38
  # `far` -- where it finally ends up, its far endpoint -- breaks ties
39
39
  # between lines still running together at that depth.
40
- PortMember = Struct.new(:edge_path, :role, :box, :axis, :original, :approach, :order, :far, keyword_init: true)
40
+ # `slide` is the range a straight line may move within as a whole (see
41
+ # `sliding_range`), nil when it can only tilt.
42
+ PortMember = Struct.new(:edge_path, :role, :box, :axis, :original, :approach, :order, :far, :slide, keyword_init: true)
41
43
 
42
44
  # How many times `refine_line_overlap!` sweeps every edge. Each round
43
45
  # re-scores every edge against every other edge's *current* path, in
@@ -183,6 +185,7 @@ module Archsight
183
185
  add_port_member(groups, ep, :end, ep.to_box, ep.points.last)
184
186
  end
185
187
 
188
+ groups.each_value { |members| members.each { |m| m.slide = sliding_range(m, groups) } }
186
189
  groups.each_value { |members| apply_port_slots(members) if members.length > 1 }
187
190
  ensure
188
191
  @original_crossings = nil
@@ -199,6 +202,38 @@ module Archsight
199
202
  end
200
203
  end
201
204
 
205
+ # Where a straight line (two points, perpendicular to the side) can
206
+ # move as a whole, both ends together, and stay straight: within the
207
+ # span both boxes share on the side's axis, clear of their corners.
208
+ # Only when its far end is alone on its side, so moving it there
209
+ # never collides with that side's own port slots. Nil otherwise.
210
+ def sliding_range(member, groups)
211
+ points = member.edge_path.points
212
+ return nil unless points.length == 2 && EdgeRouter::PathMetrics.close?(member.approach, member.original)
213
+
214
+ far_box = member.role == :start ? member.edge_path.to_box : member.edge_path.from_box
215
+ far_point = member.role == :start ? points.last : points.first
216
+ far_side = far_box&.side_of(far_point)
217
+ return nil unless far_side && groups[[far_box.object_id, far_side]].length == 1
218
+
219
+ span = ->(box) { member.axis == :x ? [box.left, box.right] : [box.top, box.bottom] }
220
+ margin = EdgeRouter::StraightPath::ALIGNED_CORNER_MARGIN
221
+ low = [span.call(member.box).first, span.call(far_box).first].max + margin
222
+ high = [span.call(member.box).last, span.call(far_box).last].min - margin
223
+ low <= high ? (low..high) : nil
224
+ end
225
+
226
+ # `member`'s route with its endpoint moved to `pos`: a straight line
227
+ # that may slide there moves as a whole, anything else only at this end.
228
+ def moved_points(member, pos)
229
+ delta = pos - member.original
230
+ points = member.edge_path.points
231
+ return EdgeRouter::Attachment.shift_attachment(points, member.axis, delta, at: member.role) unless member.slide&.cover?(pos)
232
+
233
+ index = member.axis == :x ? 0 : 1
234
+ points.map { |point| point.dup.tap { |p| p[index] += delta } }
235
+ end
236
+
202
237
  def positioned(node)
203
238
  Layout::PositionedNode.new(node, @boxes[node.id])
204
239
  end
@@ -353,7 +388,7 @@ module Archsight
353
388
  end
354
389
 
355
390
  def slot_path(member, pos)
356
- EdgeRouter::Attachment.shift_attachment(member.edge_path.points, member.axis, pos - member.original, at: member.role)
391
+ moved_points(member, pos)
357
392
  end
358
393
 
359
394
  def port_order(m, n)
@@ -365,8 +400,11 @@ module Archsight
365
400
  # Whether moving `member`'s endpoint keeps its route's shape: it
366
401
  # arrives perpendicular, along a run `EdgeRouter::Attachment.shift_attachment`
367
402
  # moves as a whole -- unlike a straight two-point line, whose far end
368
- # stays put, so it only ever tilts.
403
+ # stays put, so it only ever tilts -- unless it may slide as a whole
404
+ # (`sliding_range`).
369
405
  def flexible?(member)
406
+ return true if member.slide
407
+
370
408
  member.edge_path.points.length > 2 && EdgeRouter::PathMetrics.close?(member.approach, member.original)
371
409
  end
372
410
 
@@ -381,8 +419,12 @@ module Archsight
381
419
  # the side) first tries to stay exactly where it is, anywhere along
382
420
  # the side and not just within its band: moving only its box end
383
421
  # would tilt it, and `place_members` already keeps it clear of every
384
- # endpoint placed before it.
422
+ # endpoint placed before it. One that may slide as a whole
423
+ # (`sliding_range`) is `flexible?` instead and takes its band like the
424
+ # rest, so it makes room for a line that turns in beside it.
385
425
  def band_shifts(member, band_low, band_high, side_low, side_high)
426
+ return shifts_for(member, slide_positions(member, band_low, band_high)) if member.slide
427
+
386
428
  straight = member.approach.clamp(band_low, band_high)
387
429
  center = (band_low + band_high) / 2.0
388
430
  preferred = flexible?(member) ? [center, straight] : [straight, center]
@@ -392,6 +434,20 @@ module Archsight
392
434
  shifts_for(member, positions)
393
435
  end
394
436
 
437
+ # Where a line that may slide (`sliding_range`) tries to go, all of it
438
+ # inside its range so it stays straight: where it is, if that is in its
439
+ # band, else the band's center, then the band's ends -- clamped to the
440
+ # part of the band within the range. A band entirely outside the range
441
+ # leaves it where it is.
442
+ def slide_positions(member, band_low, band_high)
443
+ low = [band_low, member.slide.begin].max
444
+ high = [band_high, member.slide.end].min
445
+ return [member.original] if low > high
446
+
447
+ keep = member.original.between?(low, high) ? [member.original] : []
448
+ [*keep, ((band_low + band_high) / 2.0).clamp(low, high), low, high].uniq
449
+ end
450
+
395
451
  # `[pos, shifted points]` for moving `member`'s endpoint to `pos`, or
396
452
  # nil when that would make its route cross more obstacles than it
397
453
  # already does.
@@ -405,7 +461,7 @@ module Archsight
405
461
  path = member.edge_path
406
462
  moves = positions.map do |pos|
407
463
  delta = pos - member.original
408
- [pos, delta.zero? ? path.points : EdgeRouter::Attachment.shift_attachment(path.points, member.axis, delta, at: member.role), delta.zero?]
464
+ [pos, delta.zero? ? path.points : moved_points(member, pos), delta.zero?]
409
465
  end
410
466
  moved = moves.reject(&:last)
411
467
  return moves.map { |pos, points, _| [pos, points] } if moved.empty?
@@ -4,7 +4,7 @@ module Archsight
4
4
  module Diagram
5
5
  # Every spacing/sizing/font-size value the layout and renderer use, in
6
6
  # one place, so a diagram can pick a denser set for larger graphs:
7
- # `default` > `cozy` > `compact`. Selected per diagram with a top-level
7
+ # `default` > `cozy` > `compact` > `tight`. Selected per diagram with a top-level
8
8
  # `theme "cozy"` statement, or overridden from outside
9
9
  # (`render(theme:)`, `--theme`).
10
10
  #
@@ -113,7 +113,12 @@ module Archsight
113
113
  title_inset_x: 6.0, group_title_baseline: 14.0, boundary_title_baseline: 15.0
114
114
  )
115
115
 
116
- THEMES = [DEFAULT, COZY, COMPACT].to_h { |t| [t.name, t] }.freeze
116
+ # `compact` with component boxes that hug their label: ~6px above and
117
+ # below it instead of ~15px, 4px beside it instead of 8px, for very
118
+ # large graphs. Fonts, containers, gaps and actors stay as `compact`.
119
+ TIGHT = COMPACT.with(name: "tight", node_height: 26.0, node_label_padding: 8.0)
120
+
121
+ THEMES = [DEFAULT, COZY, COMPACT, TIGHT].to_h { |t| [t.name, t] }.freeze
117
122
  end
118
123
  end
119
124
  end
@@ -6,17 +6,17 @@ require_relative "template"
6
6
  module Archsight
7
7
  # Documentation generates markdown documentation for architecture resources
8
8
  class Documentation
9
- # Layer display order (top to bottom)
10
- LAYER_ORDER = %w[motivation strategy business application technology implementation].freeze
9
+ # Layer display order (top to bottom), without the tool's own kinds
10
+ LAYER_ORDER = (Archsight::Resources::LAYERS - ["other"]).freeze
11
11
 
12
12
  # Layer display names
13
13
  LAYER_NAMES = {
14
14
  "motivation" => "Motivation Layer",
15
15
  "strategy" => "Strategy Layer",
16
+ "implementation" => "Implementation & Migration Layer",
16
17
  "business" => "Business Layer",
17
18
  "application" => "Application Layer",
18
- "technology" => "Technology Layer",
19
- "implementation" => "Implementation & Migration Layer"
19
+ "technology" => "Technology Layer"
20
20
  }.freeze
21
21
 
22
22
  # Resource kinds to exclude from the diagram
@@ -12,7 +12,7 @@ module Archsight
12
12
  # the counterpart of Editor.to_yaml for kinds that are stored as YAML.
13
13
  module PageSource
14
14
  # Frontmatter keys the form knows, in the order they are written
15
- KNOWN_KEYS = %w[title tags author owner status toc confluence created updated properties].freeze
15
+ KNOWN_KEYS = %w[title tags author owner status toc created updated properties].freeze
16
16
  TIMESTAMPS = %w[created updated].freeze
17
17
  ISO_TIMESTAMP = /\A\d{4}-\d{2}-\d{2}(?:T[\d:.]+(?:Z|[+-]\d\d:\d\d))?\z/
18
18
 
@@ -33,6 +33,7 @@ module Archsight
33
33
  def render_fields(annotations, existing_source)
34
34
  extras = existing_meta(existing_source)
35
35
  extras.delete_if { |key, _| KNOWN_KEYS.include?(key) }
36
+ migrate_legacy_links(extras)
36
37
  lines = []
37
38
  lines << scalar_line("name", extras.delete("name")) if extras.key?("name")
38
39
  KNOWN_KEYS.each do |key|
@@ -46,6 +47,17 @@ module Archsight
46
47
  "---\n#{lines.join}---\n#{body(annotations["page/content"])}"
47
48
  end
48
49
 
50
+ # The old `confluence:` key becomes `links: { confluence: ... }` when the page is saved; the links mapping is
51
+ # not a form field, so it is kept as it is
52
+ def migrate_legacy_links(extras)
53
+ legacy = extras.delete("confluence")
54
+ return if legacy.nil?
55
+
56
+ links = extras["links"].is_a?(Hash) ? extras["links"] : {}
57
+ links["confluence"] ||= legacy
58
+ extras["links"] = links
59
+ end
60
+
49
61
  # Check that a submitted file still is the page it replaces
50
62
  # @raise [FileWriter::WriteError]
51
63
  def validate!(source, path:, name:)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "kramdown"
5
+ require "kramdown-parser-gfm"
6
+
7
+ module Archsight
8
+ module Export
9
+ module Confluence
10
+ # The result of an analysis (`![[Analysis/Name]]` in a page, run at the export) in storage format: headings,
11
+ # text, tables and lists as Confluence content, messages as info/note/warning panels, code as a code macro.
12
+ # Like a view, it is the data of the moment of the export. The XML holds nothing that changes between two
13
+ # runs on the same data (no time, no duration), so an unchanged result leaves the page unchanged.
14
+ class AnalysisOutput
15
+ # level of a message -> Confluence panel macro
16
+ PANELS = { error: "warning", warning: "note", info: "info" }.freeze
17
+ LINK = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/
18
+ BREAK = %r{<br\s*/?>|\r?\n}i
19
+
20
+ # @param link [Proc] (target, label) -> inline XML of a `[[...]]` link
21
+ # @param code [Proc] (source, language) -> block XML of a code macro
22
+ def initialize(link:, code:)
23
+ @link = link
24
+ @code = code
25
+ end
26
+
27
+ # @return [String] block XML
28
+ def xml(name, result)
29
+ out = %(<p><strong>#{h(name)}</strong> (result of the analysis at the export)</p>\n)
30
+ return out << %(<p><em>The analysis failed: #{h(result.error)}</em></p>\n) unless result.success?
31
+
32
+ result.sections.each { |section| out << section_xml(section).to_s }
33
+ out
34
+ end
35
+
36
+ private
37
+
38
+ def section_xml(section)
39
+ case section[:type]
40
+ when :heading then heading(section)
41
+ when :text then block_markdown(section[:content])
42
+ when :table then table(section)
43
+ when :list then "<ul>#{section[:items].map { |item| "<li>#{inline(item)}</li>" }.join}</ul>\n"
44
+ when :message then panel(section)
45
+ when :code then @code.call(section[:content].to_s, section[:lang].to_s)
46
+ end
47
+ end
48
+
49
+ # level 0 is a section of the analysis: below the page's own headings
50
+ def heading(section)
51
+ level = (section[:level].to_i + 3).clamp(3, 6)
52
+ "<h#{level}>#{h(section[:text])}</h#{level}>\n"
53
+ end
54
+
55
+ def table(section)
56
+ head = "<tr>#{section[:headers].map { |header| "<th>#{h(header)}</th>" }.join}</tr>\n"
57
+ rows = section[:rows].map { |cells| "<tr>#{cells.map { |cell| "<td>#{inline(cell)}</td>" }.join}</tr>\n" }
58
+ "<table><tbody>\n#{head}#{rows.join}</tbody></table>\n"
59
+ end
60
+
61
+ def panel(section)
62
+ macro = PANELS.fetch(section[:level], "info")
63
+ %(<ac:structured-macro ac:name="#{macro}"><ac:rich-text-body><p>#{inline(section[:message])}</p></ac:rich-text-body></ac:structured-macro>\n)
64
+ end
65
+
66
+ # A cell, list item or message: markdown and [[links]] inline, a `<br>` or newline breaks the line, other
67
+ # HTML is text
68
+ def inline(value)
69
+ value.to_s.split(BREAK).map { |line| inline_markdown(line) }.join("<br />")
70
+ end
71
+
72
+ def inline_markdown(text)
73
+ with_links(text) do |marked|
74
+ html = markdown(marked)
75
+ html.match(%r{\A<p>((?:(?!</?p>).)*)</p>\z}m) ? Regexp.last_match(1) : html
76
+ end
77
+ end
78
+
79
+ def block_markdown(text) = "#{with_links(text.to_s) { |marked| markdown(marked) }}\n"
80
+
81
+ # Keeps the [[links]] out of kramdown (it would split the brackets) and puts their XML into its result
82
+ def with_links(text)
83
+ links = []
84
+ marked = text.gsub(LINK) { (links << [Regexp.last_match(1).strip, Regexp.last_match(2)&.strip]) && "ARCHSIGHTAL#{links.size - 1}X" }
85
+ yield(marked).gsub(/ARCHSIGHTAL(\d+)X/) { @link.call(*links[Regexp.last_match(1).to_i]) }
86
+ end
87
+
88
+ # kramdown HTML of the text: raw HTML is shown as text (it would not be well-formed storage XML)
89
+ def markdown(text)
90
+ return "" if text.strip.empty?
91
+
92
+ safe = text.gsub(%r{<(?!(?:https?://|mailto:))}, "&lt;")
93
+ Kramdown::Document.new(safe, input: "GFM", entity_output: :as_char, auto_ids: false, hard_wrap: false).to_html.strip
94
+ .gsub(%r{<(br|hr|img)([^>]*?)\s*/?>}, '<\1\2 />')
95
+ end
96
+
97
+ def h(text) = ERB::Util.html_escape(text)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -97,6 +97,19 @@ module Archsight
97
97
  call("DELETE", "/rest/api/content/#{attachment_id}")
98
98
  end
99
99
 
100
+ # The names of the labels of a content
101
+ def labels(id)
102
+ (get("/rest/api/content/#{id}/label", limit: 500)["results"] || []).map { |label| label["name"] }
103
+ end
104
+
105
+ def add_labels(id, names)
106
+ post("/rest/api/content/#{id}/label", names.map { |name| { prefix: "global", name: name } })
107
+ end
108
+
109
+ def remove_label(id, name)
110
+ call("DELETE", "/rest/api/content/#{id}/label", query: { name: name })
111
+ end
112
+
100
113
  # Let only this user edit the page (best effort: the caller reports a failure).
101
114
  def restrict_update_to(id, username)
102
115
  put("/rest/api/content/#{id}/restriction", [{ operation: "update", restrictions: { user: [{ type: "known", username: username }] } }])
@@ -26,7 +26,7 @@ module Archsight
26
26
 
27
27
  # @return [String, nil] where the page lives in Confluence, nil if it has no Confluence page
28
28
  def self.confluence_url(page)
29
- link = page&.annotations&.fetch("page/confluence", nil).to_s.strip
29
+ link = page&.annotations&.fetch("link/confluence", nil).to_s.strip
30
30
  return nil if link.empty?
31
31
 
32
32
  parsed = PageUrl.parse(link)
@@ -12,6 +12,7 @@ require_relative "credentials"
12
12
  require_relative "page_url"
13
13
  require_relative "storage"
14
14
  require_relative "page_properties"
15
+ require_relative "labels"
15
16
 
16
17
  module Archsight
17
18
  module Export
@@ -21,7 +22,8 @@ module Archsight
21
22
  # A page is only written when Confluence still holds what Archsight wrote last time: the exporter leaves a
22
23
  # marker (a content property with the page version it created) and refuses to overwrite a page that has
23
24
  # no marker or a newer version, unless `force` is set. Every written version says in its message that it
24
- # was generated, and the page is locked for other editors where the server allows it.
25
+ # was generated, and the page is locked for other editors where the server allows it. The page's tags
26
+ # become its labels; the exporter only removes labels it added itself (recorded in the marker).
25
27
  class Exporter
26
28
  PROPERTY = "archsight"
27
29
 
@@ -76,7 +78,7 @@ module Archsight
76
78
  end
77
79
 
78
80
  def link(page)
79
- value = page.annotations["page/confluence"].to_s.strip
81
+ value = page.annotations["link/confluence"].to_s.strip
80
82
  value.empty? ? nil : value
81
83
  end
82
84
 
@@ -138,23 +140,48 @@ module Archsight
138
140
  current = content.dig("version", "number")
139
141
  body_hash = Digest::SHA256.hexdigest(converted.body)
140
142
  files = converted.attachments.transform_values(&:sha)
141
- return Result.new(page: page.name, status: :unchanged, message: "version #{current}") if !blocked && marker["bodyHash"] == body_hash && marker["attachments"] == files
142
- return would_export(page, current, blocked) if @dry_run
143
+ labels = Labels.for(page)
144
+ body_changed = blocked || marker["bodyHash"] != body_hash || marker["attachments"] != files
145
+ labels_changed = (marker&.fetch("labels", nil) || []) != labels
146
+ return Result.new(page: page.name, status: :unchanged, message: "version #{current}") unless body_changed || labels_changed
147
+ return would_export(page, current, blocked, body_changed, labels) if @dry_run
148
+
149
+ version = body_changed ? write_body(page, client, content, converted, marker, blocked, id) : current
150
+ own = marker&.fetch("ownLabels", nil) || []
151
+ own = sync_labels(client, id, labels, own) if labels_changed
152
+ state = { "version" => version, "bodyHash" => body_hash, "attachments" => files, "labels" => labels,
153
+ "ownLabels" => own, "page" => page.name, "exportedAt" => Time.now.utc.iso8601,
154
+ "archsight" => Archsight::VERSION }
155
+ client.save_property(id, PROPERTY, state, property)
156
+ return Result.new(page: page.name, status: :exported, message: "labels updated (version #{version})") unless body_changed
157
+
158
+ remove_stale_attachments(client, id, marker, files)
159
+ Result.new(page: page.name, status: :exported, message: "version #{version}#{lock(client, id)}")
160
+ end
143
161
 
162
+ # Uploads the attachments and writes the new version; returns its number
163
+ def write_body(page, client, content, converted, marker, blocked, id)
164
+ current = content.dig("version", "number")
144
165
  upload(client, id, converted.attachments, marker&.fetch("attachments", nil) || {})
145
166
  updated = client.update_page(id, title: content["title"], space: content.dig("space", "key"), body: converted.body,
146
167
  version: current + 1, message: message(page, blocked, marker, current))
147
- version = updated.dig("version", "number")
148
- state = { "version" => version, "bodyHash" => body_hash, "attachments" => files, "page" => page.name,
149
- "exportedAt" => Time.now.utc.iso8601, "archsight" => Archsight::VERSION }
150
- client.save_property(id, PROPERTY, state, property)
151
- remove_stale_attachments(client, id, marker, files)
152
- Result.new(page: page.name, status: :exported, message: "version #{version}#{lock(client, id)}")
168
+ updated.dig("version", "number")
169
+ end
170
+
171
+ # Gives the page the labels of its tags and removes the labels an earlier export added that are no tag any
172
+ # more; labels somebody else set are left alone. Returns the labels the exporter owns now.
173
+ def sync_labels(client, id, labels, own)
174
+ present = client.labels(id)
175
+ added = labels - present
176
+ client.add_labels(id, added) if added.any?
177
+ (own - labels).each { |name| client.remove_label(id, name) if present.include?(name) }
178
+ ((own & labels) + added).uniq.sort
153
179
  end
154
180
 
155
- def would_export(page, current, blocked)
156
- Result.new(page: page.name, status: :would_export,
157
- message: "would write version #{current + 1}#{" (overwriting: #{blocked})" if blocked}")
181
+ def would_export(page, current, blocked, body_changed, labels)
182
+ message = body_changed ? "would write version #{current + 1}#{" (overwriting: #{blocked})" if blocked}" : "would update the labels"
183
+ message += " with the labels #{labels.join(", ")}" if body_changed && labels.any?
184
+ Result.new(page: page.name, status: :would_export, message: message)
158
185
  end
159
186
 
160
187
  def upload(client, id, attachments, known)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Export
5
+ module Confluence
6
+ # The `page/tags` of a page as Confluence labels. A label is lowercase and cannot contain spaces or any of
7
+ # `!#&()*,.:;<>?@[]^`, so those become a dash: `Team:BSS` is the label `team-bss`.
8
+ module Labels
9
+ FORBIDDEN = /[\s!#&()*,.:;<>?@\[\]^]+/
10
+ MAX_LENGTH = 255
11
+
12
+ # @return [Array<String>] the labels of the page, sorted, without duplicates
13
+ def self.for(page)
14
+ page.annotations["page/tags"].to_s.split(",").filter_map { |tag| label(tag) }.uniq.sort
15
+ end
16
+
17
+ # @return [String, nil] the label for a tag, nil if nothing of it is left
18
+ def self.label(tag)
19
+ name = tag.to_s.downcase.gsub(FORBIDDEN, "-").squeeze("-").delete_prefix("-").delete_suffix("-")[0, MAX_LENGTH]
20
+ name.empty? ? nil : name
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -9,9 +9,9 @@ module Archsight
9
9
  module Export
10
10
  module Confluence
11
11
  # The frontmatter of a page as the Page Properties (`details`) macro of Confluence: a two column table
12
- # with the status, owner, author, teams, tags and every further property. It is the counterpart of what the
12
+ # with the status, owner, author, teams and every further property. It is the counterpart of what the
13
13
  # import lifts out of such a table, and keeps Confluence's property reports working. People are written as
14
- # text (a name), not as user links.
14
+ # text (a name), not as user links. The other tags are no property: they are the page's labels (`Labels`).
15
15
  module PageProperties
16
16
  STATUS_COLOURS = {
17
17
  "wip" => "yellow", "draft" => "yellow", "rfc" => "blue", "review" => "blue", "proposed" => "blue",
@@ -36,13 +36,11 @@ module Archsight
36
36
  # @return [Array<Array(String, String)>] label and the storage XML of the value
37
37
  def rows(page)
38
38
  annotations = page.annotations
39
- tags = annotations["page/tags"].to_s.split(",").map(&:strip).reject(&:empty?)
40
- teams, other = tags.partition { |tag| tag.start_with?(TEAM_PREFIX) }
39
+ teams = annotations["page/tags"].to_s.split(",").map(&:strip).select { |tag| tag.start_with?(TEAM_PREFIX) }
41
40
  rows = [["Document status", status(annotations["page/status"])],
42
41
  ["Document owner", text(person(annotations["page/owner"]))],
43
42
  ["Document author", text(person(annotations["page/author"]))],
44
- ["Teams", text(teams.map { |t| t.delete_prefix(TEAM_PREFIX).strip }.join(", "))],
45
- ["Tags", text(other.join(", "))]]
43
+ ["Teams", text(teams.map { |t| t.delete_prefix(TEAM_PREFIX).strip }.join(", "))]]
46
44
  rows += page.properties.map { |key, value| [key, text(value)] }
47
45
  rows.reject { |_, value| value.empty? }
48
46
  end
@@ -17,6 +17,7 @@ require_relative "drawio"
17
17
  require_relative "page_url"
18
18
  require_relative "rasterizer"
19
19
  require_relative "tables"
20
+ require_relative "analysis_output"
20
21
 
21
22
  module Archsight
22
23
  module Export
@@ -229,9 +230,31 @@ module Archsight
229
230
  end
230
231
 
231
232
  # {macros}, [[Target|label]] and ![[View/Name]] become placeholders before kramdown sees them (it would split the
232
- # brackets), except in code.
233
+ # brackets), except in code; embedded descriptions are written in first.
233
234
  def protect(markdown)
234
- markdown.split(CODE).each_with_index.map { |part, i| i.odd? ? part : protect_text(part) }.join
235
+ outside_code(embed_descriptions(markdown)) { |text| protect_text(text) }
236
+ end
237
+
238
+ def outside_code(markdown, &)
239
+ markdown.split(CODE).each_with_index.map { |part, i| i.odd? ? part : yield(part) }.join
240
+ end
241
+
242
+ # The description of a resource embedded on a line of its own (`![[MotivationGoal/Name]]`) is written into the
243
+ # page: it is text, not live content. Embeds in that description stay notes (they are not expanded again).
244
+ def embed_descriptions(markdown)
245
+ outside_code(markdown) do |text|
246
+ text.gsub(/^[ \t]*!\[\[([^\]|]+)\]\][ \t]*$/) { embedded_description(Regexp.last_match(1).strip) || Regexp.last_match(0) }
247
+ end
248
+ end
249
+
250
+ def embedded_description(reference)
251
+ kind, name = reference.split("/", 2)
252
+ return if name.nil? || Archsight::Helpers::Embeds::LIVE_KINDS.include?(kind) ||
253
+ !Archsight::Helpers::Embeds.embeddable_kind?(kind)
254
+
255
+ instance = @database&.instances_by_kind(kind)&.[](name)
256
+ description = instance&.annotations.to_h["architecture/description"].to_s.strip
257
+ description.empty? ? nil : description
235
258
  end
236
259
 
237
260
  def protect_text(text)
@@ -240,6 +263,7 @@ module Archsight
240
263
  xml && placeholder("LINK", xml, plain: (macro.plain(value) if macro.respond_to?(:plain)))
241
264
  end
242
265
  text = text.gsub(%r{^[ \t]*!\[\[View/([^\]|]+)\]\][ \t]*$}) { view_embed(Regexp.last_match(1).strip) || Regexp.last_match(0) }
266
+ text = text.gsub(%r{^[ \t]*!\[\[Analysis/([^\]|]+)\]\][ \t]*$}) { analysis_embed(Regexp.last_match(1).strip) || Regexp.last_match(0) }
243
267
  text = text.gsub(/!\[\[([^\]|]+)\]\]/) { placeholder("EMBED", embed_note(Regexp.last_match(1).strip)) }
244
268
  text.gsub(Archsight::Helpers::WikiLinks::PATTERN) do
245
269
  placeholder("LINK", wiki_link(Regexp.last_match(1).strip, Regexp.last_match(2)&.strip))
@@ -256,6 +280,18 @@ module Archsight
256
280
  placeholder("TABLE", xml, plain: embed_note("View/#{name}"))
257
281
  end
258
282
 
283
+ # An Analysis on a line of its own is run and its result written into the page (the data of the moment, like a
284
+ # view); an unknown Analysis stays an embed (a note), the linter reports it.
285
+ def analysis_embed(name)
286
+ analysis = @database&.instances_by_kind("Analysis")&.[](name)
287
+ return nil unless analysis
288
+
289
+ require_relative "../../analysis"
290
+ result = Archsight::Analysis::Executor.new(@database).execute(analysis)
291
+ output = AnalysisOutput.new(link: ->(target, label) { wiki_link(target, label) }, code: ->(source, language) { code(source, language) })
292
+ placeholder("TABLE", output.xml(name, result), plain: embed_note("Analysis/#{name}"))
293
+ end
294
+
259
295
  # The XML of a table, or nothing and a problem (the page is not exported) if it cannot be built
260
296
  def table_xml(what)
261
297
  yield