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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- 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()
|