openprocess 0.7.0__py3-none-any.whl

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. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,675 @@
1
+ """Editing an event log inside the app.
2
+
3
+ Two ways to edit, both on a working copy that only replaces the log when you
4
+ press *Save changes*:
5
+
6
+ * **Notation** -- the log as the course writes it, ``[<a,b,c>^3, <a,c>]``,
7
+ with live checking. A log typed with *Log from notation…* (or opened from
8
+ a ``….log.txt`` file) opens here with exactly the text you typed, so you
9
+ can go back to the notation and change it.
10
+ * **Cases and events** -- one case at a time: rename it, add, remove,
11
+ duplicate and reorder events, edit their activity, timestamp, resource and
12
+ lifecycle, and rename or remove an activity throughout the log. This keeps
13
+ every other attribute of a log read from XES or CSV.
14
+
15
+ Saving a log in notation form keeps only the activity sequences, so for a log
16
+ with timestamps or other attributes the notation tab says so before you use
17
+ it. The page then writes the edited log back to its file (see
18
+ :meth:`.app.StudioWindow._log_edited`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import copy
24
+ from datetime import datetime
25
+
26
+ from PySide6.QtCore import Qt
27
+ from PySide6.QtGui import QColor
28
+ from PySide6.QtWidgets import (
29
+ QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHeaderView,
30
+ QInputDialog, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
31
+ QSplitter, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
32
+ )
33
+
34
+ from ...mining.log import (
35
+ KEY_LIFECYCLE, KEY_NAME, KEY_RESOURCE, KEY_TIME, Event, EventLog, Trace,
36
+ format_simple_log, parse_simple_log,
37
+ )
38
+ from .. import theme
39
+ from . import style
40
+ from .widgets import SegmentedControl, button, hbox, label
41
+
42
+ #: Logs to start from in the notation editor.
43
+ EXAMPLES = {
44
+ "Textbook L₁ (α-algorithm example)": "[<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>]",
45
+ "Textbook L₂ (loop)": "[<a,b,c,d>^3, <a,c,b,d>^4, <a,b,c,e,f,b,c,d>^2, "
46
+ "<a,b,c,e,f,c,b,d>, <a,c,b,e,f,b,c,d>^2, <a,c,b,e,f,b,c,e,f,c,b,d>]",
47
+ "Textbook L₃ (IM paper)": "[<a,b,c,d,e,f,b,d,c,e,g>, <a,b,d,c,e,g>^2, "
48
+ "<a,b,c,d,e,f,b,c,d,e,f,b,d,c,e,g>]",
49
+ "Short loops (α limitation)": "[<a,c>^2, <a,b,c>^3, <a,b,b,c>^2, <a,b,b,b,b,c>]",
50
+ "Non-free choice": "[<a,c,d>^45, <b,c,e>^42]",
51
+ }
52
+
53
+ NOTATION_HELP = ("Write traces as <a,b,c>^n, separated by commas. Activity names may contain "
54
+ "spaces. <> is the empty trace. Pasting ⟨a,b,c⟩³ from the book or the slides "
55
+ "works too.")
56
+
57
+
58
+ def is_plain(log: EventLog) -> bool:
59
+ """Nothing but activity names: saving it in notation loses nothing."""
60
+ for trace in log.traces:
61
+ if set(trace.attributes) - {KEY_NAME}:
62
+ return False
63
+ for event in trace.events:
64
+ if set(event.attributes) - {KEY_NAME}:
65
+ return False
66
+ return True
67
+
68
+
69
+ def notation_of(log: EventLog, classifier=None) -> str:
70
+ """The log in the course's notation (most frequent trace first)."""
71
+ return format_simple_log(log.simple_log(classifier))
72
+
73
+
74
+ def log_from_notation(text: str, name: str) -> EventLog:
75
+ return EventLog.from_simple_log(parse_simple_log(text), name)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # The notation editor (also used by "New log from notation…")
80
+ # ---------------------------------------------------------------------------
81
+ class NotationEditor(QWidget):
82
+ """A text box for a log in notation, with live feedback and examples."""
83
+
84
+ def __init__(self, text: str = "", examples: bool = True, parent=None) -> None:
85
+ super().__init__(parent)
86
+ layout = QVBoxLayout(self)
87
+ layout.setContentsMargins(0, 0, 0, 0)
88
+ layout.setSpacing(8)
89
+ self.help = label(NOTATION_HELP, "muted", wrap=True)
90
+ layout.addWidget(self.help)
91
+ self.editor = QPlainTextEdit(text)
92
+ self.editor.setFont(theme.mono_font(13))
93
+ self.editor.setMinimumHeight(120)
94
+ layout.addWidget(self.editor, 1)
95
+ self.feedback = label("", "muted", wrap=True)
96
+ self.examples = QComboBox()
97
+ self.examples.addItem("Insert an example…")
98
+ self.examples.addItems(list(EXAMPLES))
99
+ self.examples.currentTextChanged.connect(self._example)
100
+ self.examples.setVisible(examples)
101
+ row = hbox()
102
+ row.addWidget(self.feedback, 1)
103
+ row.addWidget(self.examples)
104
+ layout.addLayout(row)
105
+ self.editor.textChanged.connect(self.validate)
106
+ self.example_name = None
107
+ self.validate()
108
+
109
+ def _example(self, name: str) -> None:
110
+ if name in EXAMPLES:
111
+ self.example_name = name.split(" (")[0]
112
+ self.editor.setPlainText(EXAMPLES[name])
113
+
114
+ def text(self) -> str:
115
+ return self.editor.toPlainText()
116
+
117
+ def set_text(self, text: str) -> None:
118
+ self.editor.setPlainText(text)
119
+
120
+ def validate(self) -> bool:
121
+ try:
122
+ log = parse_simple_log(self.text())
123
+ except ValueError as error:
124
+ self.feedback.setText(str(error))
125
+ self.feedback.setStyleSheet(f"color: {style.STATUS['critical']};")
126
+ self.valid = False
127
+ return False
128
+ activities = {a for trace in log for a in trace}
129
+ self.feedback.setStyleSheet("")
130
+ self.feedback.setText(f"{sum(log.values())} traces · {len(log)} variants · "
131
+ f"{len(activities)} activities")
132
+ self.valid = True
133
+ return True
134
+
135
+
136
+ class NotationDialog(QDialog):
137
+ """Create a log from the textbook's multiset notation."""
138
+
139
+ def __init__(self, parent=None, text: str = "", name: str = "") -> None:
140
+ super().__init__(parent)
141
+ self.setWindowTitle("New log from notation")
142
+ self.setMinimumWidth(560)
143
+ self.name = QLineEdit(name or "My log")
144
+ self.notation = NotationEditor(text or EXAMPLES["Textbook L₁ (α-algorithm example)"])
145
+ self.editor, self.feedback = self.notation.editor, self.notation.feedback
146
+ self.examples = self.notation.examples
147
+ self.examples.currentTextChanged.connect(
148
+ lambda _name: self.notation.example_name and self.name.setText(
149
+ self.notation.example_name))
150
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
151
+ buttons.button(QDialogButtonBox.Ok).setText("Create log")
152
+ buttons.accepted.connect(self.accept)
153
+ buttons.rejected.connect(self.reject)
154
+ self.ok = buttons.button(QDialogButtonBox.Ok)
155
+ layout = QVBoxLayout(self)
156
+ form = QFormLayout()
157
+ form.addRow("Name", self.name)
158
+ layout.addLayout(form)
159
+ layout.addWidget(self.notation, 1)
160
+ layout.addWidget(label("You can come back to this text later: Edit… on the log's "
161
+ "page.", "muted", wrap=True))
162
+ layout.addWidget(buttons)
163
+ self.editor.textChanged.connect(lambda: self.ok.setEnabled(self.notation.valid))
164
+ self.ok.setEnabled(self.notation.valid)
165
+
166
+ def text(self) -> str:
167
+ return self.notation.text().strip()
168
+
169
+ def log(self) -> EventLog:
170
+ return log_from_notation(self.notation.text(), self.name.text().strip() or "Log")
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Cases and events
175
+ # ---------------------------------------------------------------------------
176
+ #: The event columns the editor can show: (attribute key, heading).
177
+ COLUMNS = [(KEY_NAME, "Activity"), (KEY_TIME, "Timestamp"), (KEY_RESOURCE, "Resource"),
178
+ (KEY_LIFECYCLE, "Lifecycle")]
179
+
180
+
181
+ def format_time(value) -> str:
182
+ if isinstance(value, datetime):
183
+ text = value.isoformat(sep=" ")
184
+ return text
185
+ return "" if value is None else str(value)
186
+
187
+
188
+ def parse_time(text: str):
189
+ """A timestamp typed in the table (ISO 8601: ``2024-03-01 09:30``), or None."""
190
+ text = text.strip()
191
+ if not text:
192
+ return None
193
+ if text.endswith(("Z", "z")):
194
+ text = text[:-1] + "+00:00"
195
+ return datetime.fromisoformat(text.replace("T", " ", 1) if "T" in text else text)
196
+
197
+
198
+ class CaseEditor(QWidget):
199
+ """Cases on the left, the events of the selected case on the right."""
200
+
201
+ def __init__(self, log: EventLog, columns: list[str] | None = None, parent=None) -> None:
202
+ super().__init__(parent)
203
+ self.log = log
204
+ present = {key for trace in log.traces for event in trace.events
205
+ for key in event.attributes}
206
+ self.columns = [(k, h) for k, h in COLUMNS
207
+ if k == KEY_NAME or (columns is None and k in present)
208
+ or (columns is not None and k in columns)]
209
+ self.changed = False
210
+ self._filling = False
211
+ #: The time zone of the log's timestamps: a time typed without an offset
212
+ #: gets it (naive and zone-aware times cannot be compared).
213
+ self.zone = next((e.timestamp.tzinfo for t in log.traces for e in t.events
214
+ if e.timestamp is not None and e.timestamp.tzinfo is not None), None)
215
+ self.bad_cells: set[tuple[int, int, int]] = set() # (trace id, row, column)
216
+
217
+ layout = QVBoxLayout(self)
218
+ layout.setContentsMargins(0, 0, 0, 0)
219
+ layout.setSpacing(8)
220
+ layout.addLayout(hbox(
221
+ button("Rename activity…", self.rename_activity,
222
+ tooltip="Rename an activity in every case"),
223
+ button("Remove activity…", self.remove_activity,
224
+ tooltip="Remove every event of an activity"),
225
+ None, spacing=6))
226
+
227
+ # Cases
228
+ left = QWidget()
229
+ column = QVBoxLayout(left)
230
+ column.setContentsMargins(0, 0, 0, 0)
231
+ column.setSpacing(6)
232
+ self.search = QLineEdit()
233
+ self.search.setPlaceholderText("Find a case or activity")
234
+ self.search.setClearButtonEnabled(True)
235
+ self.search.textChanged.connect(self._filter)
236
+ column.addWidget(self.search)
237
+ self.cases = QListWidget()
238
+ self.cases.currentRowChanged.connect(lambda _row: self._show_case())
239
+ column.addWidget(self.cases, 1)
240
+ column.addLayout(hbox(button("+ Case", self.add_case, tooltip="Add an empty case"),
241
+ button("Duplicate", self.duplicate_case,
242
+ tooltip="Add a copy of this case"),
243
+ button("Delete", self.delete_case, tooltip="Delete this case"),
244
+ None, spacing=4))
245
+
246
+ # Events
247
+ right = QWidget()
248
+ column = QVBoxLayout(right)
249
+ column.setContentsMargins(0, 0, 0, 0)
250
+ column.setSpacing(6)
251
+ self.case_name = QLineEdit()
252
+ self.case_name.setPlaceholderText("Case id")
253
+ self.case_name.editingFinished.connect(self._rename_case)
254
+ column.addLayout(hbox(label("Case"), self.case_name, spacing=6))
255
+ self.events = QTableWidget(0, len(self.columns))
256
+ self.events.setHorizontalHeaderLabels([h for _, h in self.columns])
257
+ self.events.verticalHeader().setVisible(True)
258
+ self.events.setSelectionBehavior(QAbstractItemView.SelectRows)
259
+ self.events.setSelectionMode(QAbstractItemView.SingleSelection)
260
+ header = self.events.horizontalHeader()
261
+ header.setSectionResizeMode(QHeaderView.Stretch)
262
+ for index, (key, _) in enumerate(self.columns):
263
+ if key == KEY_TIME: # a timestamp is read whole
264
+ header.setSectionResizeMode(index, QHeaderView.ResizeToContents)
265
+ self.events.itemChanged.connect(self._cell_changed)
266
+ column.addWidget(self.events, 1)
267
+ column.addLayout(hbox(button("+ Event", self.add_event,
268
+ tooltip="Add an event below the selected one"),
269
+ button("Delete", self.delete_event, tooltip="Delete the event"),
270
+ button("↑", lambda: self.move_event(-1), tooltip="Move up"),
271
+ button("↓", lambda: self.move_event(1), tooltip="Move down"),
272
+ None, spacing=4))
273
+ self.problem = label("", "muted", wrap=True)
274
+ column.addWidget(self.problem)
275
+
276
+ splitter = QSplitter(Qt.Horizontal)
277
+ splitter.addWidget(left)
278
+ splitter.addWidget(right)
279
+ splitter.setStretchFactor(1, 1)
280
+ splitter.setSizes([230, 520])
281
+ splitter.setChildrenCollapsible(False)
282
+ layout.addWidget(splitter, 1)
283
+ self._fill_cases()
284
+
285
+ # -- cases -----------------------------------------------------------------------
286
+ def _case_text(self, trace: Trace) -> str:
287
+ return f"{trace.case_id or '(no id)'} · {len(trace.events)}"
288
+
289
+ def _fill_cases(self, select: int = 0) -> None:
290
+ self.cases.blockSignals(True)
291
+ self.cases.clear()
292
+ for index, trace in enumerate(self.log.traces):
293
+ item = QListWidgetItem(self._case_text(trace))
294
+ item.setData(Qt.UserRole, index)
295
+ item.setToolTip(" → ".join(str(e.activity) for e in trace.events))
296
+ self.cases.addItem(item)
297
+ self.cases.blockSignals(False)
298
+ self._filter(self.search.text())
299
+ if self.log.traces:
300
+ self.cases.setCurrentRow(max(0, min(select, len(self.log.traces) - 1)))
301
+ self._show_case()
302
+
303
+ def _filter(self, text: str) -> None:
304
+ needle = text.strip().lower()
305
+ for row in range(self.cases.count()):
306
+ trace = self.log.traces[row]
307
+ hit = not needle or needle in trace.case_id.lower() or any(
308
+ needle in str(e.activity or "").lower() for e in trace.events)
309
+ self.cases.item(row).setHidden(not hit)
310
+
311
+ def current_trace(self) -> Trace | None:
312
+ row = self.cases.currentRow()
313
+ return self.log.traces[row] if 0 <= row < len(self.log.traces) else None
314
+
315
+ def _refresh_case_row(self) -> None:
316
+ row = self.cases.currentRow()
317
+ trace = self.current_trace()
318
+ if trace is not None:
319
+ self.cases.item(row).setText(self._case_text(trace))
320
+ self.cases.item(row).setToolTip(" → ".join(str(e.activity) for e in trace.events))
321
+
322
+ def _unique_case_id(self, base: str = "case") -> str:
323
+ taken = {t.case_id for t in self.log.traces}
324
+ number = len(self.log.traces) + 1
325
+ while f"{base} {number}" in taken:
326
+ number += 1
327
+ return f"{base} {number}"
328
+
329
+ def add_case(self) -> None:
330
+ self.log.traces.append(Trace(attributes={KEY_NAME: self._unique_case_id()}))
331
+ self._modified()
332
+ self._fill_cases(len(self.log.traces) - 1)
333
+
334
+ def duplicate_case(self) -> None:
335
+ trace = self.current_trace()
336
+ if trace is None:
337
+ return
338
+ twin = copy.deepcopy(trace)
339
+ twin.attributes[KEY_NAME] = self._unique_case_id()
340
+ index = self.cases.currentRow() + 1
341
+ self.log.traces.insert(index, twin)
342
+ self._modified()
343
+ self._fill_cases(index)
344
+
345
+ def delete_case(self) -> None:
346
+ row = self.cases.currentRow()
347
+ if row < 0:
348
+ return
349
+ gone = id(self.log.traces[row])
350
+ del self.log.traces[row]
351
+ self.bad_cells = {cell for cell in self.bad_cells if cell[0] != gone}
352
+ self._modified()
353
+ self._fill_cases(row)
354
+
355
+ def _rename_case(self) -> None:
356
+ trace = self.current_trace()
357
+ if trace is None or self.case_name.text() == trace.case_id:
358
+ return
359
+ trace.attributes[KEY_NAME] = self.case_name.text()
360
+ self._modified()
361
+ self._refresh_case_row()
362
+
363
+ # -- events -----------------------------------------------------------------------
364
+ def _show_case(self) -> None:
365
+ trace = self.current_trace()
366
+ self._filling = True
367
+ self.events.setRowCount(0)
368
+ self.case_name.setText(trace.case_id if trace else "")
369
+ self.case_name.setEnabled(trace is not None)
370
+ if trace is not None:
371
+ self.events.setRowCount(len(trace.events))
372
+ for row, event in enumerate(trace.events):
373
+ for column, (key, _heading) in enumerate(self.columns):
374
+ value = event.attributes.get(key)
375
+ item = QTableWidgetItem(format_time(value) if key == KEY_TIME
376
+ else "" if value is None else str(value))
377
+ if (id(trace), row, column) in self.bad_cells:
378
+ item.setBackground(QColor(style.STATUS["critical"]).lighter(170))
379
+ self.events.setItem(row, column, item)
380
+ self._filling = False
381
+ self._update_problem()
382
+
383
+ def _cell_changed(self, item: QTableWidgetItem) -> None:
384
+ if self._filling:
385
+ return
386
+ trace = self.current_trace()
387
+ if trace is None or item.row() >= len(trace.events):
388
+ return
389
+ event = trace.events[item.row()]
390
+ key = self.columns[item.column()][0]
391
+ text = item.text()
392
+ cell = (id(trace), item.row(), item.column())
393
+ if key == KEY_TIME:
394
+ try:
395
+ value = parse_time(text)
396
+ if value is not None and value.tzinfo is None and self.zone is not None:
397
+ value = value.replace(tzinfo=self.zone)
398
+ except ValueError:
399
+ self.bad_cells.add(cell)
400
+ self._filling = True
401
+ item.setBackground(QColor(style.STATUS["critical"]).lighter(170))
402
+ self._filling = False
403
+ self._update_problem()
404
+ return
405
+ else:
406
+ value = text.strip() or None
407
+ self.bad_cells.discard(cell)
408
+ self._filling = True
409
+ item.setData(Qt.BackgroundRole, None)
410
+ self._filling = False
411
+ if value is None and key != KEY_NAME:
412
+ event.attributes.pop(key, None)
413
+ else:
414
+ event.attributes[key] = value if value is not None else ""
415
+ self._modified()
416
+ self._refresh_case_row()
417
+ self._update_problem()
418
+
419
+ def _selected_event(self) -> int:
420
+ rows = self.events.selectionModel().selectedRows() if self.events.selectionModel() else []
421
+ return rows[0].row() if rows else self.events.currentRow()
422
+
423
+ def add_event(self) -> None:
424
+ trace = self.current_trace()
425
+ if trace is None:
426
+ return
427
+ row = self._selected_event()
428
+ index = row + 1 if row >= 0 else len(trace.events)
429
+ attributes = {KEY_NAME: "new activity"}
430
+ neighbour = trace.events[index - 1] if index > 0 else (
431
+ trace.events[0] if trace.events else None)
432
+ if neighbour is not None:
433
+ for key in (KEY_TIME, KEY_LIFECYCLE):
434
+ if key in neighbour.attributes and any(k == key for k, _ in self.columns):
435
+ attributes[key] = neighbour.attributes[key]
436
+ trace.events.insert(index, Event(attributes))
437
+ self._shift_bad_cells(trace, index, 1)
438
+ self._modified()
439
+ self._show_case()
440
+ self._refresh_case_row()
441
+ self.events.selectRow(index)
442
+ self.events.setCurrentCell(index, 0)
443
+ self.events.editItem(self.events.item(index, 0))
444
+
445
+ def delete_event(self) -> None:
446
+ trace = self.current_trace()
447
+ row = self._selected_event()
448
+ if trace is None or not 0 <= row < len(trace.events):
449
+ return
450
+ del trace.events[row]
451
+ self.bad_cells = {c for c in self.bad_cells if not (c[0] == id(trace) and c[1] == row)}
452
+ self._shift_bad_cells(trace, row + 1, -1)
453
+ self._modified()
454
+ self._show_case()
455
+ self._refresh_case_row()
456
+ if trace.events:
457
+ self.events.selectRow(min(row, len(trace.events) - 1))
458
+
459
+ def move_event(self, step: int) -> None:
460
+ trace = self.current_trace()
461
+ row = self._selected_event()
462
+ target = row + step
463
+ if trace is None or not (0 <= row < len(trace.events) and 0 <= target < len(trace.events)):
464
+ return
465
+ events = trace.events
466
+ events[row], events[target] = events[target], events[row]
467
+ swapped = set()
468
+ for cell in self.bad_cells:
469
+ if cell[0] == id(trace) and cell[1] in (row, target):
470
+ cell = (cell[0], target if cell[1] == row else row, cell[2])
471
+ swapped.add(cell)
472
+ self.bad_cells = swapped
473
+ self._modified()
474
+ self._show_case()
475
+ self._refresh_case_row()
476
+ self.events.selectRow(target)
477
+
478
+ def _shift_bad_cells(self, trace: Trace, start: int, step: int) -> None:
479
+ self.bad_cells = {(t, r + step if t == id(trace) and r >= start else r, c)
480
+ for t, r, c in self.bad_cells}
481
+
482
+ # -- whole log ----------------------------------------------------------------------
483
+ def activities(self) -> list[str]:
484
+ return sorted({str(e.activity) for t in self.log.traces for e in t.events
485
+ if e.activity is not None})
486
+
487
+ def rename_activity(self) -> None:
488
+ names = self.activities()
489
+ if not names:
490
+ return
491
+ old, ok = QInputDialog.getItem(self, "Rename activity", "Activity", names, 0, False)
492
+ if not ok:
493
+ return
494
+ new, ok = QInputDialog.getText(self, "Rename activity", f"New name for “{old}”",
495
+ text=old)
496
+ new = new.strip()
497
+ if not ok or not new or new == old:
498
+ return
499
+ for trace in self.log.traces:
500
+ for event in trace.events:
501
+ if event.activity == old:
502
+ event.attributes[KEY_NAME] = new
503
+ self._modified()
504
+ self._fill_cases(self.cases.currentRow())
505
+
506
+ def remove_activity(self) -> None:
507
+ names = self.activities()
508
+ if not names:
509
+ return
510
+ old, ok = QInputDialog.getItem(self, "Remove activity", "Remove every event of",
511
+ names, 0, False)
512
+ if not ok:
513
+ return
514
+ for trace in self.log.traces:
515
+ trace.events = [e for e in trace.events if e.activity != old]
516
+ self.bad_cells.clear()
517
+ self._modified()
518
+ self._fill_cases(self.cases.currentRow())
519
+
520
+ def _modified(self) -> None:
521
+ self.changed = True
522
+
523
+ def _update_problem(self) -> None:
524
+ if self.bad_cells:
525
+ self.problem.setText("A timestamp is not a date and time. Write it as "
526
+ "2024-03-01 09:30 (or 2024-03-01T09:30:00+01:00).")
527
+ self.problem.setStyleSheet(f"color: {style.STATUS['critical']};")
528
+ else:
529
+ self.problem.setText("")
530
+ self.problem.setStyleSheet("")
531
+
532
+ @property
533
+ def valid(self) -> bool:
534
+ return not self.bad_cells
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # The dialog
539
+ # ---------------------------------------------------------------------------
540
+ class LogEditorDialog(QDialog):
541
+ """Edit a log in notation or case by case; :meth:`result_log` is the outcome."""
542
+
543
+ NOTATION, EVENTS = 0, 1
544
+
545
+ def __init__(self, document, parent=None) -> None:
546
+ super().__init__(parent)
547
+ self.document = document
548
+ self.setWindowTitle(f"Edit “{document.name}”")
549
+ self.resize(860, 600)
550
+ self.plain = is_plain(document.log)
551
+ #: The original notation, when the log was written in it.
552
+ self.original_notation = getattr(document, "notation", None)
553
+ self.working = copy.deepcopy(document.log)
554
+ layout = QVBoxLayout(self)
555
+ layout.setSpacing(10)
556
+
557
+ self.name = QLineEdit(document.name)
558
+ self.modes = SegmentedControl(["Notation", "Cases and events"])
559
+ layout.addLayout(hbox(label("Name"), self.name, 16, self.modes, spacing=8))
560
+
561
+ self.stack = QStackedWidget()
562
+ notation_page = QWidget()
563
+ column = QVBoxLayout(notation_page)
564
+ column.setContentsMargins(0, 0, 0, 0)
565
+ self.notation = NotationEditor(self.original_notation
566
+ or notation_of(self.working, document.classifier),
567
+ examples=False)
568
+ self.notation_warning = label(
569
+ "This log has timestamps or other attributes. Saving it from the notation keeps "
570
+ "only the activity sequences (as seen through the current classifier). Use Cases "
571
+ "and events to keep everything else.", "muted", wrap=True)
572
+ self.notation_warning.setStyleSheet(f"color: {style.STATUS['warning']};")
573
+ self.notation_warning.setVisible(not self.plain)
574
+ column.addWidget(self.notation_warning)
575
+ column.addWidget(self.notation, 1)
576
+ self.stack.addWidget(notation_page)
577
+ self.cases = CaseEditor(self.working, columns=[KEY_NAME] if self.plain else None)
578
+ self.stack.addWidget(self.cases)
579
+ layout.addWidget(self.stack, 1)
580
+
581
+ self._notation_text = self._initial_notation = self.notation.text()
582
+ self._mode = self.NOTATION if self.plain else self.EVENTS
583
+ self.modes.set_index(self._mode)
584
+ self.stack.setCurrentIndex(self._mode)
585
+ self.modes.changed.connect(self._switch)
586
+
587
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
588
+ self.buttons.button(QDialogButtonBox.Save).setText("Save changes")
589
+ self.buttons.accepted.connect(self._accept)
590
+ self.buttons.rejected.connect(self.reject)
591
+ layout.addWidget(self.buttons)
592
+ self.notation.editor.textChanged.connect(self._validate)
593
+ self.cases.events.itemChanged.connect(lambda _item: self._validate())
594
+ self._validate()
595
+ #: Set when accepted: the new log, and its notation (None: not a notation log).
596
+ self.new_log: EventLog | None = None
597
+ self.new_notation: str | None = None
598
+
599
+ @property
600
+ def mode(self) -> int:
601
+ return self._mode
602
+
603
+ def _switch(self, index: int) -> None:
604
+ if index == self._mode:
605
+ return
606
+ if index == self.EVENTS:
607
+ if self.notation.text() != self._notation_text:
608
+ if not self.notation.valid:
609
+ QMessageBox.information(self, "Edit log", "Fix the notation first: "
610
+ + self.notation.feedback.text())
611
+ self.modes.blockSignals(True)
612
+ self.modes.set_index(self.NOTATION)
613
+ self.modes.blockSignals(False)
614
+ return
615
+ self._replace_working(log_from_notation(self.notation.text(), self.name.text()))
616
+ else:
617
+ if self.cases.changed:
618
+ if not self.cases.valid:
619
+ QMessageBox.information(self, "Edit log", "Fix the highlighted timestamp "
620
+ "first.")
621
+ self.modes.blockSignals(True)
622
+ self.modes.set_index(self.EVENTS)
623
+ self.modes.blockSignals(False)
624
+ return
625
+ self.notation.set_text(notation_of(self.working, self._classifier()))
626
+ self._notation_text = self.notation.text()
627
+ self._mode = index
628
+ self.stack.setCurrentIndex(index)
629
+ self._validate()
630
+
631
+ def _classifier(self):
632
+ classifier = self.document.classifier
633
+ return classifier if classifier in self.working.available_classifiers() else None
634
+
635
+ def _replace_working(self, log: EventLog) -> None:
636
+ """Notation edits made: the case editor starts over from them."""
637
+ log.attributes = dict(self.working.attributes)
638
+ self.working = log
639
+ self.plain = True
640
+ self.notation_warning.setVisible(False)
641
+ old = self.cases
642
+ self.cases = CaseEditor(self.working, columns=[KEY_NAME])
643
+ self.cases.changed = True
644
+ self.cases.events.itemChanged.connect(lambda _item: self._validate())
645
+ self.stack.insertWidget(self.EVENTS, self.cases)
646
+ self.stack.removeWidget(old)
647
+ old.deleteLater()
648
+ self._notation_text = self.notation.text()
649
+
650
+ def _validate(self) -> None:
651
+ ok = self.notation.valid if self._mode == self.NOTATION else self.cases.valid
652
+ self.buttons.button(QDialogButtonBox.Save).setEnabled(ok)
653
+
654
+ def _accept(self) -> None:
655
+ name = self.name.text().strip() or self.document.name
656
+ if self._mode == self.NOTATION:
657
+ text = self.notation.text().strip()
658
+ if text == self._initial_notation.strip() and not self.cases.changed \
659
+ and name == self.document.name:
660
+ self.reject() # nothing changed
661
+ return
662
+ log = log_from_notation(text, name)
663
+ if not self.plain or self.cases.changed:
664
+ log.attributes = {**self.working.attributes, KEY_NAME: name}
665
+ self.new_log, self.new_notation = log, text
666
+ else:
667
+ if not self.cases.changed and name == self.document.name:
668
+ self.reject()
669
+ return
670
+ self.working.attributes[KEY_NAME] = name
671
+ self.new_log = self.working
672
+ # Still nothing but activities: it stays a notation log.
673
+ self.new_notation = notation_of(self.working) if (
674
+ self.original_notation is not None and is_plain(self.working)) else None
675
+ self.accept()