Merge lp:~tapaal-contributor/tapaal/cpn-gui-TikZ-support into lp:~tapaal-contributor/tapaal/cpn-gui-dev

Proposed by Kristian Morsing Pedersen
Status: Merged
Approved by: Jiri Srba
Approved revision: 1579
Merged at revision: 1622
Proposed branch: lp:~tapaal-contributor/tapaal/cpn-gui-TikZ-support
Merge into: lp:~tapaal-contributor/tapaal/cpn-gui-dev
Diff against target: 491 lines (+206/-139)
1 file modified
src/net/tapaal/export/TikZExporter.java (+206/-139)
To merge this branch: bzr merge lp:~tapaal-contributor/tapaal/cpn-gui-TikZ-support
Reviewer Review Type Date Requested Status
Jiri Srba Approve
Kenneth Yrke Jørgensen code Pending
Review via email: mp+416464@code.launchpad.net

This proposal supersedes a proposal from 2022-03-06.

Commit message

Added TikZ support for colored nets.
Added a framed box to the Tikz output for global variables/constants/color types.
Removed subscripiting from transition- and place names
Changed \mathrm to \mathit
Added more adjustment options for transitions, places and arcs
Merged with cpn-gui-dev

Description of the change

The TikZ export now shows all of the new features associated with colored nets.
Also added a framed box that contains all the global variables, constants and/or color types for a given net.
Subscripting from transition- and place names have been removed, so it looks more like the GUI.
\mathrm has also been replaces with \mathit.

To post a comment you must log in.
Revision history for this message
Kenneth Yrke Jørgensen (yrke) : Posted in a previous version of this proposal
review: Approve (code)
Revision history for this message
Jiri Srba (srba) wrote : Posted in a previous version of this proposal

It would be nice if instead of (in referendum colored timed):

1′v[1, 2]Voters1 → [1, 4]

one would print:

1′v
[1, 2]
Voters1 → [1, 4]

(on three sepratate lines).

review: Needs Fixing
Revision history for this message
Jiri Srba (srba) wrote : Posted in a previous version of this proposal

Please, set the default position for arc lables to pos=0.5

review: Needs Fixing
Revision history for this message
Jiri Srba (srba) wrote : Posted in a previous version of this proposal

At the end of the labels, there is a redundant \\ that makes the spacing look ugly. E.g. in

\node[place, label={[align=left,label distance=0cm]270:$\mathrm{voting} ... $\mathit{4}$ \\}] at (390,-345) (voting) {};

The label should finish with just ... $\mathit{4}$ }] without the extra \\

review: Needs Fixing
Revision history for this message
Jiri Srba (srba) wrote : Posted in a previous version of this proposal

Also, when exporting age of tokens in the initial marking, please write 0.0 instead of 0,0

review: Needs Fixing
Revision history for this message
Jiri Srba (srba) wrote :

In the tikz export of weights, please use 10\times instead of 10x.

Also, nets that are not colored seem to be exported without the relative label positions. Could all nets be exported to Tikz using the same relative positions as for CPNs?

review: Needs Fixing
1579. By Kristian Morsing Pedersen <email address hidden>

Changed x -> \times in inhibitor arcs

Revision history for this message
Jiri Srba (srba) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/net/tapaal/export/TikZExporter.java'
2--- src/net/tapaal/export/TikZExporter.java 2022-02-21 19:46:59 +0000
3+++ src/net/tapaal/export/TikZExporter.java 2022-03-09 14:15:46 +0000
4@@ -3,16 +3,17 @@
5 import java.io.FileWriter;
6 import java.io.IOException;
7 import java.io.PrintWriter;
8-import java.util.List;
9+import java.util.*;
10
11+import dk.aau.cs.model.CPN.ColorType;
12+import dk.aau.cs.model.CPN.Variable;
13+import dk.aau.cs.model.tapn.Constant;
14 import dk.aau.cs.model.tapn.TimedToken;
15
16+import net.tapaal.gui.petrinet.Context;
17 import pipe.gui.petrinet.dataLayer.DataLayer;
18 import pipe.gui.TAPAALGUI;
19-import pipe.gui.petrinet.graphicElements.Arc;
20-import pipe.gui.petrinet.graphicElements.ArcPathPoint;
21-import pipe.gui.petrinet.graphicElements.Place;
22-import pipe.gui.petrinet.graphicElements.Transition;
23+import pipe.gui.petrinet.graphicElements.*;
24 import pipe.gui.petrinet.graphicElements.tapn.TimedInhibitorArcComponent;
25 import pipe.gui.petrinet.graphicElements.tapn.TimedInputArcComponent;
26 import pipe.gui.petrinet.graphicElements.tapn.TimedPlaceComponent;
27@@ -45,7 +46,7 @@
28 if (option == TikZOutputOption.FULL_LATEX) {
29 out.println("\\documentclass[a4paper]{article}");
30 out.println("\\usepackage{tikz}");
31- out.println("\\usetikzlibrary{petri,arrows}");
32+ out.println("\\usetikzlibrary{petri,arrows,positioning}");
33 out.println("\\usepackage{amstext}");
34 out.println();
35 out.println("\\begin{document}");
36@@ -62,6 +63,8 @@
37 out.print(exportTransitions(net.getTransitions()));
38 out.print(exportArcs(net.getArcs()));
39
40+ out.println(exportGlobalVariables());
41+
42 out.println("\\end{tikzpicture}");
43 if (option == TikZOutputOption.FULL_LATEX) {
44 out.println("\\end{document}");
45@@ -94,7 +97,7 @@
46 ArcPathPoint currentPoint = arc.getArcPath().getArcPathPoint(i);
47
48 if(currentPoint.getPointType() == ArcPathPoint.STRAIGHT) {
49- arcPoints += "to[bend right=0] (" + (currentPoint.getX()) + "," + (currentPoint.getY() * (-1)) + ") ";
50+ arcPoints += "to [bend right=0] (" + (currentPoint.getX()) + "," + (currentPoint.getY() * (-1)) + ") ";
51 } else if (currentPoint.getPointType() == ArcPathPoint.CURVED) {
52 double xCtrl1 = Math.round(currentPoint.getControl1().getX());
53 double yCtrl1 = Math.round(currentPoint.getControl1().getY() * (-1));
54@@ -109,21 +112,25 @@
55 }
56
57 String arrowType = getArcArrowType(arc);
58- String arcLabel = getArcLabels(arc);
59
60+ out.append("% Arc between " + arc.getSource().getName() + " and " + arc.getTarget().getName() + "\n");
61 out.append("\\draw[");
62 out.append(arrowType);
63- out.append("] (");
64+ out.append(",pos=0.5] (");
65 out.append(arc.getSource().getId());
66 out.append(") ");
67 out.append(arcPoints);
68- out.append("to[bend right=0]");
69- out.append(" (");
70- out.append(arc.getTarget().getId());
71- out.append(") {};\n");
72- if(!arcLabel.equals(""))
73- out.append("%% Label for arc between " + arc.getSource().getName() + " and " + arc.getTarget().getName() + "\n");
74- out.append(arcLabel);
75+ out.append("to node[bend right=0,auto,align=left]");
76+ out.append(" {");
77+
78+ if(arrowType.equals("inhibArc")) {
79+ out.append(handleNameLabel(arc.getNameLabel().getText().replace("x","\\times")));
80+ } else {
81+ out.append(handleNameLabel(arc.getNameLabel().getText()));
82+ }
83+
84+ out.append("} ");
85+ out.append("(" + arc.getTarget().getId() + ");\n");
86 }
87 return out;
88 }
89@@ -142,50 +149,6 @@
90 return arrowType;
91 }
92
93- protected String getArcLabels(Arc arc) {
94- String arcLabel = "";
95- String arcLabelPositionString = "\\draw (" + (arc.getNameLabel().getX()) + "," + (arc.getNameLabel().getY())*(-1) + ") node {";
96-
97- if (arc instanceof TimedInputArcComponent) {
98- if (!(arc.getSource() instanceof TimedTransitionComponent)) {
99- arcLabel = arcLabelPositionString;
100- if (arc.getWeight().value() > 1) {
101- arcLabel += "$" + arc.getWeight().value() + "\\times$\\ ";
102- }
103-
104- if(TAPAALGUI.getCurrentTab().getLens().isTimed()) {
105- arcLabel += "$\\mathrm{" + replaceWithMathLatex(getGuardAsStringIfNotHidden((TimedInputArcComponent) arc)) + "}$";
106- if (arc instanceof TimedTransportArcComponent)
107- arcLabel += ":" + ((TimedTransportArcComponent) arc).getGroupNr();
108- arcLabel += "};\n";
109- } else {
110- arcLabel += "};\n";
111- }
112-
113- } else {
114- arcLabel = arcLabelPositionString;
115- if (arc.getWeight().value() > 1) {
116- arcLabel += "$" + arc.getWeight().value() + "\\times$\\ ";
117- }
118- arcLabel += ":" + ((TimedTransportArcComponent) arc).getGroupNr() + "};\n";
119- }
120-
121- } else {
122- if (arc.getWeight().value() > 1) {
123- arcLabel += arcLabelPositionString + "$" + arc.getWeight().value() + "\\times$\\ };\n";
124- }
125- }
126- return arcLabel;
127- }
128-
129- private String getGuardAsStringIfNotHidden(TimedInputArcComponent arc) {
130- if (!TAPAALGUI.getAppGui().showZeroToInfinityIntervals() && arc.getGuardAsString().equals("[0,inf)")){
131- return "";
132- } else {
133- return arc.getGuardAsString();
134- }
135- }
136-
137 private StringBuffer exportTransitions(Transition[] transitions) {
138 StringBuffer out = new StringBuffer();
139 for (Transition trans : transitions) {
140@@ -195,7 +158,8 @@
141
142
143 out.append("\\node[transition");
144- out.append(angle);
145+ out.append(angle);
146+ out.append(handlePlaceAndTransitionLabel(trans.getNameLabel().getText(), trans.getName(), trans.getAttributesVisible()));
147 out.append("] at (");
148 out.append((trans.getPositionX()));
149 out.append(',');
150@@ -226,31 +190,30 @@
151 out.append(trans.getId());
152 out.append(".center) { };\n");
153 }
154- if (trans.getAttributesVisible()){
155- boolean isLabelAboveTransition = trans.getY() > trans.getNameLabel().getY();
156- boolean isLabelBehindTrans = trans.getX() < trans.getNameLabel().getX();
157- double xOffset = trans.getName().length() > 5 && !isLabelAboveTransition && !isLabelBehindTrans ? trans.getLayerOffset() : 0;
158-
159- out.append("%% label for transition " + trans.getName() + "\n");
160- out.append("\\draw (");
161- out.append(trans.getNameLabel().getX() + xOffset + "," + (trans.getNameLabel().getY() * -1) + ")");
162- out.append(" node ");
163- out.append(" {");
164- out.append(exportMathName(trans.getName()));
165- out.append("};\n");
166- }
167 }
168 return out;
169 }
170
171+ private String handlePlaceAndTransitionLabel(String nameLabelText, String name, boolean isVisible) {
172+ if(!isVisible)
173+ return "";
174+
175+ String result = ", label={[align=left,label distance=0cm]90:";
176+ result += "$\\mathrm{" + (name.replace("_","\\_")) + "}$";
177+ result += handleNameLabel(nameLabelText);
178+ result += "}";
179+ return result;
180+ }
181+
182 private StringBuffer exportPlacesWithTokens(Place[] places) {
183 StringBuffer out = new StringBuffer();
184
185 for (Place place : places) {
186- String invariant = getPlaceInvariantString(place);
187+ String invariant = "$" + getPlaceInvariantString(place) + "$";
188 String tokensInPlace = getTokenListStringFor(place);
189
190 out.append("\\node[place");
191+ out.append(handlePlaceAndTransitionLabel(place.getNameLabel().getText(), place.getName(), place.getAttributesVisible()));
192 out.append("] at (");
193 out.append(place.getPositionX());
194 out.append(',');
195@@ -262,37 +225,36 @@
196 exportPlaceTokens(place, out, ((TimedPlaceComponent) place).underlyingPlace().tokens().size());
197
198 if(((TimedPlaceComponent)place).underlyingPlace().isShared()){
199- out.append("\\node[sharedplace] at (");
200+ out.append("\\node[sharedplace ");
201+ out.append("] at (");
202 out.append(place.getId());
203 out.append(".center) { };\n");
204 }
205- if (place.getAttributesVisible() || !invariant.equals("")){
206- boolean isLabelAbovePlace = place.getY() > place.getNameLabel().getY();
207- boolean isLabelBehindPlace = place.getX() < place.getNameLabel().getX();
208- double xOffset = place.getName().length() > 6 && !isLabelAbovePlace && !isLabelBehindPlace ? place.getLayerOffset() : 0;
209- double yOffset = isLabelAbovePlace ? (place.getNameLabel().getHeight() / 2) : 0;
210-
211- out.append("%% label for place " + place.getName() + "\n");
212- out.append("\\draw (");
213- out.append((place.getNameLabel().getX() + xOffset) + "," + ((place.getNameLabel().getY() * -1) + yOffset) +")");
214- out.append(" node[align=left] ");
215- out.append("{");
216- if(place.getAttributesVisible())
217- out.append(exportMathName(place.getName()));
218- if(!invariant.equals("")) {
219- if((place.getAttributesVisible()))
220- out.append("\\\\");
221- out.append(invariant);
222- }else {
223- out.append("};\n");
224- }
225-
226- }
227 }
228-
229 return out;
230 }
231
232+ private String handleNameLabel(String nameLabel) {
233+ String nameLabelsString = "";
234+ String[] labelsInName = nameLabel.split("\n");
235+ for(int i = 0; i < labelsInName.length; i++) {
236+ if(labelsInName[i].contains("[")) {
237+ nameLabelsString += escapeSpacesInAndOrNot(replaceWithMathLatex(labelsInName[i]));
238+ }
239+ else if(!labelsInName[i].isEmpty()){
240+ nameLabelsString += escapeSpacesInAndOrNot(replaceWithMathLatex(labelsInName[i]));
241+ }
242+ if(i != labelsInName.length - 1) {
243+ nameLabelsString += "\\\\";
244+ }
245+ }
246+ return nameLabelsString;
247+ }
248+
249+ private String escapeSpacesInAndOrNot(String str) {
250+ return str.replace(" and ", "\\ and\\ ").replace(" or", "\\ or\\ ").replace(" not", "\\ not\\ ");
251+ }
252+
253 private void exportPlaceTokens(Place place, StringBuffer out, int numOfTokens) {
254 // Dot radius
255 final double tRadius = 1;
256@@ -317,14 +279,14 @@
257 out.append(",");
258 out.append(placeYpos + 4);
259 out.append(")");
260- out.append("{0,0};\n");
261+ out.append("{0.0};\n");
262
263 out.append("\\node at ("); // Bottom
264 out.append(placeXpos);
265 out.append(",");
266 out.append(placeYpos - 5);
267 out.append(")");
268- out.append("{0,0};\n");
269+ out.append("{0.0};\n");
270 return;
271 case 1:
272 out.append("\\node at ("); // Top
273@@ -332,7 +294,7 @@
274 out.append(",");
275 out.append(placeYpos);
276 out.append(")");
277- out.append("{0,0};\n");
278+ out.append("{0.0};\n");
279 return;
280 default:
281 out.append("\\node at (");
282@@ -437,6 +399,124 @@
283 }
284 }
285
286+ private StringBuffer exportGlobalVariables() {
287+ StringBuffer out = new StringBuffer();
288+
289+ Context context = new Context(TAPAALGUI.getCurrentTab());
290+ List<ColorType> listColorTypes = context.network().colorTypes();
291+ List<Constant> constantsList = new ArrayList<>(context.network().constants());
292+ List<Variable> variableList = context.network().variables();
293+
294+ if(!context.network().isColored()) {
295+ if(constantsList.isEmpty()) {
296+ return out;
297+ }
298+ out.append("%%Global box which contains global color types, variables and/or constants.\n");
299+ out.append("\\node [globalBox] (globalBox) at (current bounding box.north west) [anchor=south west] {");
300+ exportConstants(constantsList, out);
301+ out.append("};");
302+ return out;
303+ }
304+
305+ if((listColorTypes.isEmpty() && constantsList.isEmpty() && variableList.isEmpty()))
306+ return out;
307+
308+ out.append("%%Global box which contains global color types, variables and/or constants.\n");
309+ out.append("\\node [globalBox] (globalBox) at (current bounding box.north west) [anchor=south west] {");
310+
311+ exportColorTypes(listColorTypes, out);
312+
313+ if(listColorTypes.size() > 0 && (variableList.size() > 0 || constantsList.size() > 0)) {
314+ out.append("\\\\");
315+ }
316+
317+ exportVariables(variableList, out);
318+
319+ if(variableList.size() > 0 && !constantsList.isEmpty()) {
320+ out.append("\\\\");
321+ }
322+
323+ exportConstants(constantsList, out);
324+
325+ out.append("};");
326+
327+ return out;
328+ }
329+
330+ private void exportColorTypes(List<ColorType> listColorTypes, StringBuffer out) {
331+ String stringColorList = "";
332+ for(int i = 0; i < listColorTypes.size(); i++) {
333+ if(i == 0) {
334+ out.append("Color Types:\\\\");
335+ }
336+ out.append("$\\mathit{" + listColorTypes.get(i).getName() + "}$ \\textbf{is} ");
337+
338+ if(listColorTypes.get(i).isProductColorType()) {
339+ out.append("$\\mathit{<");
340+ for(int x = 0; x < listColorTypes.get(i).getProductColorTypes().size(); x++) {
341+ stringColorList += listColorTypes.get(i).getProductColorTypes().get(x).getName().replace("_", "\\_");
342+
343+ if(x != listColorTypes.get(i).getProductColorTypes().size() - 1){
344+ stringColorList += ", ";
345+ }
346+ }
347+ out.append(stringColorList + ">}$\\\\");
348+ stringColorList = "";
349+
350+ } else if(listColorTypes.get(i).isIntegerRange()) {
351+ out.append("$\\mathit{");
352+ if(listColorTypes.get(i).size() > 1) {
353+ int listSize = listColorTypes.get(i).size();
354+ out.append("[" + listColorTypes.get(i).getColors().get(0).getColorName().replace("_","\\_") + ".." + listColorTypes.get(i).getColors().get(listSize - 1).getColorName().replace("_","\\_") + "]");
355+ } else {
356+ out.append("[" + listColorTypes.get(i).getFirstColor().getColorName().replace("_","\\_") + "]");
357+ }
358+ out.append("}$\\\\");
359+
360+ } else {
361+ out.append("$\\mathit{[");
362+ for(int x = 0; x < listColorTypes.get(i).getColors().size(); x++) {
363+ stringColorList += listColorTypes.get(i).getColors().get(x).getName().replace("_","\\_");
364+
365+ if(x != listColorTypes.get(i).getColors().size() - 1){
366+ stringColorList += ", ";
367+ }
368+ }
369+ out.append(stringColorList + "]}$\\\\");
370+ stringColorList = "";
371+ }
372+ }
373+ }
374+
375+ private void exportVariables(List<Variable> variableList, StringBuffer out) {
376+ String result = "";
377+ for(int i = 0; i < variableList.size(); i++) {
378+ if (i == 0) {
379+ result += "Variables:\\\\ ";
380+ }
381+ result += "$\\mathit{" + variableList.get(i).getName().replace("_","\\_") + " \\textbf{ in } " + variableList.get(i).getColorType().getName().replace("_","\\_") + "}$";
382+ if(i != variableList.size() - 1) {
383+ result += "\\\\";
384+ }
385+ }
386+ out.append(result);
387+ }
388+
389+ private void exportConstants(List<Constant> constantsList, StringBuffer out) {
390+ String result = "";
391+
392+ for(int i = 0; i < constantsList.size(); i++) {
393+ if(i == 0) {
394+ result += "Constants:\\\\";
395+ }
396+ result += "$\\mathit{" + constantsList.get(i).toString().replace("_","\\_") + "}$";
397+ if(i != constantsList.size() - 1) {
398+ result += "\\\\";
399+ }
400+ }
401+ out.append(result);
402+ }
403+
404 protected String getTokenListStringFor(Place place) {
405 List<TimedToken> tokens = ((TimedPlaceComponent) place).underlyingPlace().tokens();
406
407@@ -455,7 +535,7 @@
408 String invariant = "";
409
410 if (!((TimedPlaceComponent) place).getInvariantAsString().contains("inf"))
411- invariant = "$\\mathrm{" + replaceWithMathLatex(((TimedPlaceComponent) place).getInvariantAsString()) + "}$};\n";
412+ invariant = replaceWithMathLatex(((TimedPlaceComponent) place).getInvariantAsString()) + "};\n";
413 return invariant;
414 }
415
416@@ -480,12 +560,14 @@
417 StringBuffer out = new StringBuffer();
418
419 out.append("\\begin{tikzpicture}[font=\\scriptsize, xscale=0.45, yscale=0.45, x=1.33pt, y=1.33pt]\n");
420- out.append("%% the figure can be scaled by changing xscale and yscale\n");
421+ out.append("%% the figure can be scaled by changing xscale and yscale or the size of the x- and y-coordinates\n");
422 out.append("%% positions of place/transition labels that are currently fixed to label=135 degrees\n");
423- out.append("%% can be adjusted so that they do not cover arcs\n");
424- out.append("%% similarly the curving of arcs can be done by adjusting bend left/right=XX\n");
425- out.append("%% labels may be slightly skewed compared to the tapaal drawing due to rounding.\n");
426- out.append("%% This can be adjusted by tuning the coordinates of the label\n");
427+ out.append("%% these can be adjusted by adjusting either the coordinates, the label distance or the label degree\n");
428+ out.append("%% that is placed right after the 'label-distance'. 90: is above (default), 180 is left, 270 is below etc.\n");
429+ out.append("%% The curving of arcs can be done by adjusting bend left/right=XX\n");
430+ out.append("%% labels may be slightly skewed compared to the Tapaal drawing due to rounding.\n");
431+ out.append("%% This can be adjusted by tuning the coordinates of the label, or the degree (see above)\n");
432+ out.append("%% The box containing global variables can also be moved by adjusting the anchor points / bounding box in the [globalBox] node at the end of the Tikz document\n");
433 out.append("\\tikzstyle{arc}=[->,>=stealth,thick]\n");
434
435 out.append("\\tikzstyle{transportArc}=[->,>=diamond,thick]\n");
436@@ -496,39 +578,24 @@
437 out.append("\\tikzstyle{every token}=[fill=white,text=black]\n");
438 out.append("\\tikzstyle{sharedplace}=[place,minimum size=7.5mm,dashed,thin]\n");
439 out.append("\\tikzstyle{sharedtransition}=[transition, fill opacity=0, minimum width=3.5mm, minimum height=6.5mm,dashed]\n");
440- out.append("\\tikzstyle{urgenttransition}=[place,fill=white,minimum size=2.0mm,thin]");
441- out.append("\\tikzstyle{uncontrollabletransition}=[transition,fill=white,draw=black,very thick]");
442+ out.append("\\tikzstyle{urgenttransition}=[place,fill=white,minimum size=2.0mm,thin]\n");
443+ out.append("\\tikzstyle{uncontrollabletransition}=[transition,fill=white,draw=black,very thick]\n");
444+ out.append("\\tikzstyle{globalBox} = [draw,thick,align=left]");
445 return out;
446 }
447
448 protected String replaceWithMathLatex(String text) {
449- return text.replace("inf", "\\infty").replace("<=", "\\leq ").replace("*", "\\cdot ");
450- }
451-
452- private String exportMathName(String name) {
453- StringBuilder out = new StringBuilder("$\\mathrm{");
454- int subscripts = 0;
455- for (int i = 0; i < name.length() - 1; i++) {
456- char c = name.charAt(i);
457- if (c == '_') {
458- out.append("_{");
459- subscripts++;
460- } else {
461- out.append(c);
462- }
463- }
464-
465- char last = name.charAt(name.length() - 1);
466- if (last == '_') {
467- out.append("\\_");
468- } else
469- out.append(last);
470-
471- for (int i = 0; i < subscripts; i++) {
472- out.append('}');
473- }
474- out.append("}$");
475- return out.toString();
476- }
477-
478+ String[] stringList = text.split(" ");
479+ String result = "";
480+
481+ for(int i = 0; i < stringList.length; i++) {
482+ result += "$\\mathit{" + replaceSpecialSymbols(stringList[i]) + "}$ ";
483+ }
484+ return result;
485+ }
486+
487+ protected String replaceSpecialSymbols(String text) {
488+ return text.replace("inf", "\\infty").replace("<=", "\\leq").replace("*", "\\cdot")
489+ .replace("\u2192", "\\rightarrow").replace("\u221E", "\\infty");
490+ }
491 }

Subscribers

People subscribed via source and target branches

to all changes: