代码拉取完成,页面将自动刷新
"""
The following tables are generated by: https://tableconvert.com/csv-to-ascii
This file includes following class:
+------------------------+------------------------------------------------------------------------------------------+
| Core Class | Usage |
+------------------------+------------------------------------------------------------------------------------------+
| ReqNode | The croe data structure for requirement data management. |
+------------------------+------------------------------------------------------------------------------------------+
| IReqObserver | The interface of IReqAgent observer. |
+------------------------+------------------------------------------------------------------------------------------+
| IReqAgent | The interface to manage requirement data storage and access. |
+------------------------+------------------------------------------------------------------------------------------+
| ReqSingleJsonFileAgent | The implementation of IReqAgent that supports json file local storage. |
+------------------------+------------------------------------------------------------------------------------------+
| ReqModel | The Model for QTreeView. Adapting IReqAgent to QAbstractItemModel. |
+------------------------+------------------------------------------------------------------------------------------+
| ReqEditorBoard | The UI for Reqirement data editing. |
+------------------------+------------------------------------------------------------------------------------------+
| ReqMetaBoard | The UI for Meta data config. |
+------------------------+------------------------------------------------------------------------------------------+
| RequirementUI | The main UI. Includes the TreeView/ReqEditorBoard/ReqMetaBoard |
+------------------------+------------------------------------------------------------------------------------------+
+------------------------+------------------------------------------------------------------------------------------+
| Helper Class | Usage |
+------------------------+------------------------------------------------------------------------------------------+
| ObserverNotifier | The observer notification helper. |
+------------------------+------------------------------------------------------------------------------------------+
| Hookable | A decorator to make a function can be easily hooked. |
+------------------------+------------------------------------------------------------------------------------------+
| MarkdownEditor | A custom Markdown editor. |
+------------------------+------------------------------------------------------------------------------------------+
| WebViewPrinter | PDF printing class with QWebEngineView. |
+------------------------+------------------------------------------------------------------------------------------+
| IndexListUI | A widget that shows Req Index and jump to specified Req on clicking. For search feature. |
+------------------------+------------------------------------------------------------------------------------------+
The reason of putting all classes in one file:
1. Only one file to run the basic requirement viewer / editor
2. You can put this script with your requirement file, without mess file structure
3. We may add more ReqAgent and UI for FreeReq, but we'll keep this file as the basic requirement viewer / editor.
"""
from __future__ import annotations
import datetime
import hashlib
import os
import re
import html
import base64
import sys
import csv
import uuid
import json
import shutil
import platform
import markdown2
import traceback
import subprocess
from io import StringIO
from typing import Callable, List, Tuple, Union
from functools import partial
from bs4 import BeautifulSoup
from PyPDF2 import PdfMerger
try:
# Use try catch for running FreeReq without UI
from PyQt5.QtGui import QFont, QCursor, QPdfWriter, QPagedPaintDevice, QTextCursor, QDesktopServices
from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrinter
from PyQt5.QtCore import Qt, QAbstractItemModel, QModelIndex, QFileSystemWatcher, \
QSize, QPoint, QItemSelection, QFile, QIODevice, QUrl, QTimer, QSettings
from PyQt5.QtWidgets import qApp, QApplication, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, \
QPushButton, QMessageBox, QLabel, QGroupBox, QTableWidget, QTabWidget, QTextEdit, QMenu, \
QLineEdit, QCheckBox, QComboBox, QTreeView, QInputDialog, QFileDialog, QSplitter, QTableWidgetItem, \
QAbstractItemView, QScrollArea, QAction, QDockWidget, QMainWindow, QDialog
except Exception as e:
print('UI disabled.')
print(str(e))
print(traceback.format_exc())
finally:
pass
try:
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
# https://stackoverflow.com/questions/47736408/qwebengineview-how-to-open-links-in-system-browser
class QCustomerWebEnginePage(QWebEnginePage):
def acceptNavigationRequest(self, url, _type, isMainFrame):
if _type == QWebEnginePage.NavigationTypeLinkClicked:
QDesktopServices.openUrl(url)
return False
return True
except Exception as e:
print(e)
print('No QWebEngineView')
finally:
pass
self_path = os.path.dirname(os.path.abspath(__file__))
def is_web_engine_view(view) -> bool:
try:
return isinstance(view, QWebEngineView)
except NameError:
return False
def has_web_engine_view() -> bool:
try:
from PyQt5.QtWebEngineWidgets import QWebEngineView
return True
except ImportError:
return False
class ObserverNotifier:
"""
A class to simplify the observer notification.
You call notify_xxx on this class and all observer's on_xxx function will be invoked.
"""
def __init__(self):
self.__observers = []
def add_observer(self, observer):
if observer not in self.__observers:
self.__observers.append(observer)
def remove_observer(self, observer):
if observer in self.__observers:
self.__observers.remove(observer)
def __getattr__(self, item):
if item.startswith('notify_'):
action = item[len('notify_'):]
def dynamic_notify(*args, **kwargs):
method_name = f"on_{action}"
for observer in self.__observers:
try:
getattr(observer, method_name)(*args, **kwargs)
except AttributeError:
print(f"Warning: Observer {observer} does not implement method {method_name}")
except Exception as e:
print(str(e))
print(f"=> {action.capitalize()} notified.")
return dynamic_notify
else:
return None
class Hookable:
def __init__(self, func):
self._func = func
self._pre_hooks = []
self._post_hooks = []
self._replace_hook = None
self._hooked_instance = None
def __call__(self, *args, **kwargs):
for pre_hook in self._pre_hooks:
pre_hook(*args, **kwargs)
if self._hooked_instance:
result = self._func(self._hooked_instance, *args, **kwargs) \
if not self._replace_hook else self._replace_hook(self._hooked_instance, *args, **kwargs)
else:
result = self._func(*args, **kwargs) if not self._replace_hook else self._replace_hook(*args, **kwargs)
for post_hook in self._post_hooks:
post_hook(*args, **kwargs)
return result
def __get__(self, instance, owner):
self._hooked_instance = instance
return self
def __repr__(self):
return f"<Hooked Function {self._func.__name__}>"
def add_pre_hook(self, hook):
self._pre_hooks.append(hook)
def remove_pre_hook(self, hook):
self._pre_hooks.remove(hook)
def add_post_hook(self, hook):
self._post_hooks.append(hook)
def remove_post_hook(self, hook):
self._post_hooks.remove(hook)
def set_replacement_hook(self, hook, force=False) -> bool:
if self._replace_hook and not force:
return False
self._replace_hook = hook
return True
# ----------------------------------------------------------------------------------------------------------------------
# Plugin:
# req_agent_prepared(req: IReqAgent)
# after_ui_created(req_ui: RequirementUI)
try:
from extra.easy_config import EasyConfig
from extra.plugin_manager import PluginManager
easy_config = EasyConfig()
plugin_manager = PluginManager(os.path.join(self_path, 'plugin'))
except Exception as e:
print(e)
print('No Extra Components')
easy_config = None
plugin_manager = None
finally:
pass
# ----------------------------------------------------------------------------------------------------------------------
STATIC_FIELD_ID = 'id'
STATIC_FIELD_UUID = 'uuid'
STATIC_FIELD_TITLE = 'title'
STATIC_FIELD_CHILD = 'child'
STATIC_FIELD_CONTENT = 'content'
STATIC_FIELD_LAST_EDITOR = 'last_editor'
STATIC_FIELD_LAST_CHANGE_TIME = 'last_change_time'
STATIC_FIELDS = [STATIC_FIELD_ID, STATIC_FIELD_UUID, STATIC_FIELD_TITLE, STATIC_FIELD_CHILD,
STATIC_FIELD_CONTENT, STATIC_FIELD_LAST_EDITOR, STATIC_FIELD_LAST_CHANGE_TIME]
STATIC_META_ID_PREFIX = 'meta_group'
ABOUT_MESSAGE = """FreeReq by Sleepy
Github : https://github.com/SleepySoft/FreeReq"""
class ReqNode:
def __call__(self):
return self
def __init__(self, title: str = 'New Item'):
self.__data = {
STATIC_FIELD_ID: '',
STATIC_FIELD_UUID: str(uuid.uuid4().hex),
STATIC_FIELD_TITLE: title,
STATIC_FIELD_CHILD: [], # Special field. Only for serialize/deserialize.
STATIC_FIELD_CONTENT: ''
}
self.__parent = None
self.__sibling = self.__parent.children() if self.__parent is not None else []
self.__children = []
# -------------------------------------- Data ---------------------------------------
def get(self, key: str, default_val: any = None) -> any:
return self.__data.get(key, default_val)
def set(self, key: str, val: any):
if key != STATIC_FIELD_CHILD:
self.__data[key] = val
else:
raise ValueError('The key cannot be "%s"' % STATIC_FIELD_CHILD)
def data(self) -> dict:
return self.__data
def clone(self):
new_node = ReqNode()
new_node.__data = self.__data.copy()
new_node.__parent = self.__parent
new_node.__sibling = self.__sibling.copy()
new_node.__children = self.__children.copy()
return new_node
def copy_data(self, ref_node: ReqNode):
ref_data = ref_node.__data.copy()
ref_data[STATIC_FIELD_UUID] = self.__data[STATIC_FIELD_UUID]
self.__data = ref_data
def data_equals(self, compare_node: ReqNode):
data1 = self.__data.copy()
data2 = compare_node.__data.copy()
data1.pop(STATIC_FIELD_UUID, None)
data2.pop(STATIC_FIELD_UUID, None)
return data1 == data2
def get_uuid(self) -> str:
return self.__data.get(STATIC_FIELD_UUID, '')
def set_title(self, text: str):
self.__data[STATIC_FIELD_TITLE] = text
def get_title(self) -> str:
return self.__data.get(STATIC_FIELD_TITLE)
def re_assign_uuid(self):
self.__data[STATIC_FIELD_UUID] = str(uuid.uuid4().hex)
# ------------------------------------ Property ------------------------------------
def order(self) -> int:
if self in self.__sibling:
return self.__sibling.index(self)
else:
print('Warning: Error sibling. It should be a BUG')
return -1
def child_count(self) -> int:
return len(self.__children)
# ------------------------------------ Iteration ------------------------------------
def parent(self) -> ReqNode:
return self.__parent
def sibling(self) -> [ReqNode]:
return self.__sibling
def prev(self) -> ReqNode:
order = self.order()
return self.__sibling[order - 1] if order > 0 else None
def next(self) -> ReqNode:
order = self.order()
return self.__sibling[order + 1] if order < len(self.__sibling) else None
def child(self, order: int):
return self.__children[order] if 0 <= order < len(self.__children) else None
def children(self):
return self.__children
# ---------------------------------- Construction ----------------------------------
def set_parent(self, parent: ReqNode):
self.__parent = parent
self.__sibling = self.__parent.children() if self.__parent is not None else []
def append_child(self, node: ReqNode) -> int:
node.set_parent(self)
self.__children.append(node)
return len(self.__children) - 1
def insert_children(self, node: ReqNode or [ReqNode], pos: int):
if isinstance(node, ReqNode):
node = [node]
for n in node:
n.set_parent(self)
self.__children[pos:pos] = node
def remove_child(self, node: ReqNode) -> bool:
if node in self.__children:
self.__children.remove(node)
node.set_parent(None)
return True
else:
return False
def remove_children(self):
self.__children.clear()
def insert_sibling_left(self, node: ReqNode) -> int:
if self.__parent is not None:
index = self.order()
self.__parent.insert_children(node, index)
return index
else:
return -1
def insert_sibling_right(self, node: ReqNode) -> int:
if self.__parent is not None:
index = self.order() + 1
self.__parent.insert_children(node, index)
return index
else:
return -1
# ------------------------------------ Persists ------------------------------------
def to_dict(self) -> dict:
dic = self.__data.copy()
dic[STATIC_FIELD_CHILD] = [c.to_dict() for c in self.__children]
return dic
def from_dict(self, dic: dict):
self.__data = dic
for k in STATIC_FIELDS:
if k not in self.__data.keys():
print(f'Warning: Static fields missing {k}')
if STATIC_FIELD_UUID not in self.__data.keys():
# Because of old issue, the uuid is missing. Fix it on load phase.
print('Fix UUID missing issue.')
self.__data[STATIC_FIELD_UUID] = str(uuid.uuid4().hex)
self.__children = []
if STATIC_FIELD_CHILD in self.__data.keys():
for sub_dict in dic[STATIC_FIELD_CHILD]:
node = ReqNode()
node.from_dict(sub_dict)
self.append_child(node)
del self.__data[STATIC_FIELD_CHILD]
def serialize(self) -> str:
req_data_dict = self.to_dict()
json_text = json.dumps(req_data_dict, indent=4, ensure_ascii=False)
return json_text
@staticmethod
def deserialize(json_text: str) -> ReqNode:
try:
req_data_dict = json.loads(json_text)
req_node = ReqNode()
req_node.from_dict(req_data_dict)
return req_node
except Exception as e:
print(str(e))
print(traceback.format_exc())
return None
finally:
pass
# ------------------------------------------------------------------------------
def map(self, func: Callable[[ReqNode], None]):
func(self)
for child in self.__children:
child.map(func)
def filter(self, func: Callable[[ReqNode], bool]) -> List[ReqNode]:
result = []
if func(self):
result.append(self)
for child in self.__children:
result.extend(child.filter(func))
return result
def for_each(self, func: Callable[[ReqNode], None]):
func(self)
for child in self.__children:
child.for_each(func)
# ----------------------------------------------------------------------------------------------------------------------
class IReqObserver:
def __init__(self):
pass
def on_req_saved(self, req_uri: str):
pass
def on_req_loaded(self, req_uri: str):
pass
def on_req_closed(self, req_uri: str):
pass
def on_req_exception(self, exception_name: str, **kwargs):
pass
def on_meta_data_changed(self, req_name: str):
pass
def on_node_data_changed(self, req_name: str, req_node: ReqNode):
pass
def on_node_structure_changed(self, req_name: str, parent_node: ReqNode, child_node: [ReqNode], operation: str):
"""
:param req_name:
:param parent_node:
:param child_node:
:param operation: Can be 'add', 'remove'
:return:
"""
pass
class IReqAgent:
def __init__(self):
self.ob_notifier = ObserverNotifier()
def init(self, *args, **kwargs) -> bool:
raise ValueError('Not implemented')
# ------------------------ Override: Req management -----------------------
def list_req(self) -> [str]:
raise NotImplementedError('Not implemented: list_req')
def new_req(self, req_name: str, overwrite: bool = False) -> bool:
raise NotImplementedError('Not implemented: new_req')
def open_req(self, req_name: str) -> bool:
raise NotImplementedError('Not implemented: open_req')
def save_req(self) -> bool:
raise NotImplementedError('Not implemented: save_req')
def delete_req(self, req_name: str) -> bool:
raise NotImplementedError('Not implemented: delete_req')
def check_req_consistency(self) -> bool:
raise NotImplementedError('Not implemented: check_req_consistency')
# -------------------- Override: After select_op_req() --------------------
def get_req_name(self) -> str:
raise NotImplementedError('Not implemented: get_req_name')
def get_req_path(self) -> str:
raise NotImplementedError('Not implemented: get_req_path')
def get_req_meta(self) -> dict:
raise NotImplementedError('Not implemented: get_req_meta')
def set_req_meta(self, req_meta: dict) -> bool:
raise NotImplementedError('Not implemented: set_req_meta')
def get_req_root(self) -> ReqNode:
raise NotImplementedError('Not implemented: get_req_root')
def get_req_node(self, req_uuid: str) -> ReqNode:
raise NotImplementedError('Not implemented: get_req_node')
# ----------------------- Override: Node Operation -----------------------
def insert_node(self, parent_uuid: str, insert_pos: int, insert_nodes: Union[ReqNode, List[ReqNode]]):
raise NotImplementedError('Not implemented: insert_node')
def remove_node(self, node_uuid: str):
raise NotImplementedError('Not implemented: remove_node')
def update_node(self, node: ReqNode):
raise NotImplementedError('Not implemented: update_node')
def shift_node(self, node_uuid: str, shift_offset: int):
raise NotImplementedError('Not implemented: shift_offset')
# ----------------------- Override: Other Functions -----------------------
def new_req_id(self, id_prefix: str, digit_count: int = 5) -> str:
raise NotImplementedError('Not implemented: new_req_id')
# -------------------------------- Observer -------------------------------
def add_observer(self, ob: IReqObserver):
self.ob_notifier.add_observer(ob)
def remove_observer(self, ob: IReqObserver):
self.ob_notifier.remove_observer(ob)
# -------------------------------- Assistant -------------------------------
@staticmethod
def req_dict_to_nodes(req_dict: dict) -> ReqNode:
req_node_root = ReqNode()
req_node_root.from_dict(req_dict)
return req_node_root
@staticmethod
def req_map(root_node: ReqNode, map_operation) -> dict:
"""
Iterate all nodes and call map_operation with each node
:param root_node: The root node you want to iterate since.
:param map_operation: Callable object.
: Declaration: f(node: ReqNode, context: dict)
: node: Current node in iteration.
: context: A dict that pass to map_operation and finally return by req_map()
:return: The context that passed to map_operation
"""
context = {}
IReqAgent.__node_iteration(root_node, map_operation, context)
return context
@staticmethod
def __node_iteration(node: ReqNode, map_operation, context: dict):
if node is not None:
map_operation(node, context)
for child_node in node.children():
IReqAgent.__node_iteration(child_node, map_operation, context)
@staticmethod
def collect_node_information(node: ReqNode) -> dict:
"""
Collect node information by __node_information_collector()
:param node: The root node you want to collect from.
:return: A dict that includes following keys
'req_id_conflict' - bool
'uuid_instance_table' - dict
'req_id_instance_table' - dict
"""
return IReqAgent.req_map(node, IReqAgent.__node_information_collector)
@staticmethod
def __node_information_collector(node: ReqNode, ctx: dict):
if len(ctx) == 0:
# Init context
ctx['req_id_conflict'] = False
ctx['uuid_instance_table'] = {}
ctx['req_id_instance_table'] = {}
_uuid = node.get_uuid().strip()
req_id = node.get(STATIC_FIELD_ID, '').strip()
if _uuid != '':
ctx['uuid_instance_table'][_uuid] = node
if req_id != '':
if req_id in ctx['req_id_instance_table'].keys():
ctx['req_id_conflict'] = True
print(f'Warning: Duplicated Req ID detected: {req_id}')
ctx['req_id_instance_table'][req_id] = node
@staticmethod
def calculate_max_req_id(req_id_prefixes: list, req_id_list: list) -> dict:
"""
Find the max id for each req id prefix.
:param req_id_prefixes: The req id prefix list
:param req_id_list: The req id list of existing nodes
:return: The dict that groups the max id number by id prefix
"""
id_prefixes = req_id_prefixes.copy()
id_prefixes = sorted(id_prefixes, key=len, reverse=True)
req_id_max = {}
for req_id in req_id_list:
for prefix in id_prefixes:
if req_id.startswith(prefix):
try:
id_num = int(req_id[len(prefix):])
if prefix not in req_id_max.keys():
req_id_max[prefix] = id_num
else:
req_id_max[prefix] = max(id_num, req_id_max[prefix])
except Exception as e:
print(str(e))
continue
finally:
break
return req_id_max
# ----------------------------------------------------------------------------------------------------------------------
class ReqSingleJsonFileAgent(IReqAgent):
def __init__(self, req_path: str = self_path):
super(ReqSingleJsonFileAgent, self).__init__()
self.__req_path = req_path
self.__req_file_name = ''
self.__req_meta_dict = {}
self.__req_data_dict = {}
self.__req_node_root: ReqNode = None
# Node information
self.__req_id_max = {}
self.__uuid_node_index = {}
self.__req_id_node_index = {}
self.__collected_information = {}
self.req_hash = None
def init(self) -> bool:
return True
def req_full_path(self) -> str:
return os.path.join(self.__req_path, self.__req_file_name)
# ----------------------- Req management -----------------------
def list_req(self) -> [str]:
req_names = []
for f in os.scandir(self.__req_path):
if f.is_file() and f.name.lower().endswith('.req'):
req_names.append(f.name[:-4])
return req_names
def new_req(self, req_name: str, overwrite: bool = False) -> bool:
self.__do_touch(req_name)
return self.open_req(req_name)
def open_req(self, req_name: str) -> bool:
self.__do_close()
if req_name.lower().endswith('.req'):
req_file = req_name
else:
req_file = req_name + '.req'
path_part = os.path.dirname(req_file)
file_part = os.path.basename(req_file)
if path_part != '':
self.__req_path = path_part
os.chdir( self.__req_path)
print(f'Change current path to: ${self.__req_path}')
return self.__do_load(file_part)
def save_req(self) -> bool:
return self.__do_save()
def delete_req(self, req_name: str) -> bool:
return False
def check_req_consistency(self) -> bool:
return self.__check_req_hash()
# --------------------- After select_op_req() ---------------------
def get_req_name(self) -> str:
return self.__req_node_root.get_title() if self.__req_node_root is not None else ''
def get_req_path(self) -> str:
return self.__req_path
# req_path = os.path.dirname(self.__req_file_name)
# if req_path == '':
# req_path = self_path
# return req_path
def get_req_meta(self) -> dict:
return self.__req_meta_dict
def set_req_meta(self, req_meta: dict) -> bool:
self.__req_meta_dict = req_meta
self.ob_notifier.notify('meta_data_changed')
self.ob_notifier.notify_meta_data_changed()
return self.__do_save()
def get_req_root(self) -> ReqNode:
return self.__req_node_root
def get_req_node(self, req_uuid: str) -> ReqNode:
return self.__uuid_node_index.get(req_uuid, None)
# ----------------------- Override: Node Operation -----------------------
def insert_node(self, parent_uuid: str, insert_pos: int, insert_nodes: Union[ReqNode, List[ReqNode]]):
insert_nodes = [insert_nodes] if isinstance(insert_nodes, ReqNode) else insert_nodes
parent_node = self.__req_node_root.filter(lambda x: x.get_uuid() == parent_uuid)
if len(parent_node) != 1:
print('Warning: Cannot find parent node or multiple node has the same uuid.')
else:
parent_node[0].insert_children(insert_nodes, insert_pos)
self.__do_save()
self.ob_notifier.notify_node_structure_changed(self.get_req_name(), parent_node[0], insert_nodes, 'add')
def remove_node(self, node_uuid: str):
remove_node = self.__req_node_root.filter(lambda x: x.get_uuid() == node_uuid)
if len(remove_node) != 1:
print('Cannot find remove node.')
return
parent_node = remove_node[0].parent()
if parent_node is not None:
parent_node.remove_child(remove_node[0])
self.__do_save()
self.ob_notifier.notify_node_structure_changed(self.get_req_name(), parent_node, remove_node, 'remove')
def update_node(self, node: ReqNode):
update_node = self.__req_node_root.filter(lambda x: x.get_uuid() == node.get_uuid())
if len(update_node) == 0:
print('Warning: Cannot find update node.')
return
if len(update_node) > 1:
print('Warning: Find multiple nodes, the uuid may have issue.')
return
if update_node[0] is not node:
# If they are not the same instance
update_node[0].copy_data(node)
else:
print("Warning: You'd better using a node copy to update target node.")
self.__do_save()
self.ob_notifier.notify_node_data_changed(update_node[0])
def shift_node(self, node_uuid: str, shift_offset: int):
shift_node = self.__req_node_root.filter(lambda x: x.get_uuid() == node_uuid)
if len(shift_node) != 1:
return
shift_node = shift_node[0]
parent_node = shift_node.parent()
children = parent_node.children()
# 获取当前节点在子节点列表中的索引
current_index = children.index(shift_node)
# 计算新的索引
new_index = current_index + shift_offset
# 确保新的索引不越界
new_index = max(0, min(new_index, len(children) - 1))
# 移除当前节点
children.remove(shift_node)
# 在新的位置插入当前节点
children.insert(new_index, shift_node)
# -------------------------- Other Functions -------------------------
def new_req_id(self, id_prefix: str, digit_count: int = 5) -> str:
id_prefixes = self.__req_meta_dict.get(STATIC_META_ID_PREFIX, [])
if id_prefix in id_prefixes:
if id_prefix in self.__req_id_max.keys():
self.__req_id_max[id_prefix] += 1
else:
self.__req_id_max[id_prefix] = 1
format_str = '%%s%%0%dd' % digit_count
return format_str % (id_prefix, self.__req_id_max[id_prefix])
else:
# Unknown prefix
return id_prefix
# -------------------------------------------------------------------------------
def __on_node_data_updated(self):
self.__do_save()
def __on_node_child_updated(self):
self.__do_save()
def __do_load(self, req_file) -> bool:
self.__req_file_name = req_file
self.__update_req_hash()
ret = self.__load_req_json()
issues = self.__check_correct_req_data()
if len(issues) > 0:
print('---------------------- Issue Detected ----------------------')
for issue in issues:
print(issue)
print('------------------------------------------------------------')
self.ob_notifier.notify_req_loaded(self.req_full_path())
return ret
def __do_close(self):
if self.__req_file_name != '':
editing_file = self.req_full_path()
# Keeping self.__req_path
self.__req_file_name = ''
self.__req_meta_dict = {}
self.__req_data_dict = {}
self.__req_node_root = None
self.__req_id_max = {}
self.__uuid_node_index = {}
self.__req_id_node_index = {}
self.__collected_information = {}
self.req_hash = None
self.ob_notifier.notify_req_closed(editing_file)
def __do_save(self) -> bool:
if not self.check_req_consistency():
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S_%f')[:-3]
backup_file_name = self.__req_file_name + f'.{timestamp}.conflict'
print(f'Warning: Detect file changed outside. Save as a conflict file: {backup_file_name}')
self.ob_notifier.notify_req_exception('conflict', oringle_file=self.__req_file_name, backup_file=backup_file_name)
self.__req_file_name = backup_file_name
result = self.__save_req_json()
if result:
self.__update_req_hash()
self.ob_notifier.notify_req_saved(self.req_full_path())
return result
def __do_touch(self, req_file) -> bool:
try:
with open(req_file, 'wt', encoding='utf-8') as f:
f.write('{}')
return True
except Exception as e:
print(str(e))
print(traceback.format_exc())
return False
def __load_req_json(self) -> bool:
try:
with open(self.req_full_path(), 'rt', encoding='utf-8') as f:
json_dict = json.load(f)
self.__req_meta_dict = json_dict.get('req_meta', {})
self.__req_data_dict = json_dict.get('req_data', {})
self.__req_node_root = self.req_dict_to_nodes(self.__req_data_dict)
self.__indexing()
except Exception as e:
print(str(e))
print(traceback.format_exc())
self.__req_meta_dict = {}
self.__req_data_dict = {}
self.__req_node_root = ReqNode('New Requirement')
return False
finally:
pass
return True
def __save_req_json(self) -> bool:
try:
self.__req_data_dict = self.__req_node_root.to_dict()
json_dict = {
'req_meta': self.__req_meta_dict,
'req_data': self.__req_data_dict
}
json_text = json.dumps(json_dict, indent=4, ensure_ascii=False)
with open(self.req_full_path(), 'wt', encoding='utf-8') as f:
f.write(json_text)
except Exception as e:
print(str(e))
print(traceback.format_exc())
return False
finally:
pass
return True
def __hash_req(self):
sha256_hash = hashlib.sha256()
try:
with open(self.req_full_path(), "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
except FileNotFoundError:
# The first save may cause file not found fail.
return ''
except Exception as e:
print('Error: Hash req file fail.')
print(e)
return ''
return sha256_hash.hexdigest()
def __check_req_hash(self) -> bool:
current_req_hash = self.__hash_req()
return current_req_hash == self.req_hash
def __update_req_hash(self):
current_req_hash = self.__hash_req()
self.req_hash = current_req_hash
def __indexing(self):
self.__collected_information = IReqAgent.collect_node_information(self.__req_node_root)
self.__req_id_node_index = self.__collected_information.get('req_id_instance_table', {})
self.__uuid_node_index = self.__collected_information.get('uuid_instance_table', {})
id_prefixes = self.__req_meta_dict.get(STATIC_META_ID_PREFIX, [])
self.__req_id_max = IReqAgent.calculate_max_req_id(id_prefixes, list(self.__req_id_node_index.keys()))
def __check_correct_req_data(self) -> List:
uuids = {}
issues = []
def node_checker(node: ReqNode):
node_uuid = node.get_uuid()
if node_uuid in uuids.keys():
issues.append(f'Detect UUID duplicate: ${node.get_title()}, ${uuids[node_uuid].get_title()}')
node.re_assign_uuid()
uuids[node.get_uuid()] = node
self.__req_node_root.for_each(node_checker)
return issues
# -------------------------------------------------------------------------------
@staticmethod
def get_filename_without_extension(path):
filename_with_extension = os.path.basename(path)
filename_without_extension, _ = os.path.splitext(filename_with_extension)
return filename_without_extension
# ----------------------------------------------------------------------------------------------------------------------
class ReqModel(QAbstractItemModel):
def __init__(self, req_data_agent: IReqAgent):
super(ReqModel, self).__init__()
self.__req_data_agent = req_data_agent
self.__show_meta = False
self.__meta_full_columns = []
self.__meta_selected_columns = []
# ------------------------------------- Method -------------------------------------
def show_meta(self, show_meta: bool):
if self.__show_meta != show_meta:
self.beginResetModel()
self.__show_meta = show_meta
self.endResetModel()
def begin_edit(self):
self.layoutAboutToBeChanged.emit()
def end_edit(self):
self.layoutChanged.emit()
def index_of_node(self, node: ReqNode) -> QModelIndex:
if node is None or self.__req_data_agent is None or self.__req_data_agent.get_req_root() is None:
return QModelIndex()
if node == self.__req_data_agent.get_req_root():
return self.createIndex(-1, 0, node)
return self.createIndex(node.order(), 0, node) if node is not None else QModelIndex()
@staticmethod
def get_node_from_index(index: QModelIndex) -> ReqNode:
return index.internalPointer() if index is not None and index.isValid() else None
# ------------------------------------ Override ------------------------------------
def data(self, index: QModelIndex, role=None):
if index is None or not index.isValid() or not index.internalPointer():
return None
req_node: ReqNode = index.internalPointer()
if role == Qt.DisplayRole:
column_index = index.column()
if column_index == 0:
return req_node.get_title()
elif column_index == 1:
return req_node.get(STATIC_FIELD_ID, '')
elif self.__show_meta:
column_titles = self.column_titles()
meta_data_key = column_titles[column_index] if column_index < len(column_titles) else ''
return req_node.data().get(meta_data_key, '')
return None
def index(self, row, column, parent: QModelIndex = None, *args, **kwargs):
if self.__req_data_agent is None or self.__req_data_agent.get_req_root() is None:
return QModelIndex()
if parent is None or not parent.isValid():
parent_item = self.__req_data_agent.get_req_root()
else:
parent_item: ReqNode = parent.internalPointer()
if not QAbstractItemModel.hasIndex(self, row, column, parent):
return QModelIndex()
child_item = parent_item.child(row)
if child_item is not None:
return QAbstractItemModel.createIndex(self, row, column, child_item)
return QModelIndex()
def parent(self, index: QModelIndex = None):
if self.__req_data_agent is None or self.__req_data_agent.get_req_root() is None:
return QModelIndex()
if index is None or not index.isValid():
return QModelIndex()
child_item: ReqNode = index.internalPointer()
parent_item: ReqNode = child_item.parent()
if parent_item is None:
return QModelIndex()
if parent_item == self.__req_data_agent.get_req_root():
return QAbstractItemModel.createIndex(self, 0, 0, parent_item)
row = parent_item.order()
return QAbstractItemModel.createIndex(self, row, 0, parent_item)
def rowCount(self, parent: QModelIndex = None, *args, **kwargs):
if self.__req_data_agent is None or self.__req_data_agent.get_req_root() is None:
return 0
if parent is None or not parent.isValid():
parent_item = self.__req_data_agent.get_req_root()
else:
parent_item: ReqNode = parent.internalPointer()
row_count = parent_item.child_count()
return row_count
def columnCount(self, parent: QModelIndex = None, *args, **kwargs):
return 1 if not self.__show_meta else len(self.column_titles())
def headerData(self, section, orientation, role=0):
if self.__req_data_agent is None:
return None
role = Qt.ItemDataRole(role)
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal:
if self.__show_meta:
title = self.column_titles()
return title[section] if section < len(title) else ''
else:
return self.__req_data_agent.get_req_name()
return None
def insertRow(self, row: int, parent: QModelIndex = None, *args, **kwargs) -> bool:
return self.insertRows(row, 1, parent)
def insertRows(self, row: int, count: int, parent=None, *args, **kwargs) -> bool:
if self.__req_data_agent is None or self.__req_data_agent.get_req_root() is None:
return False
if parent is None or not parent.isValid():
# parent = QModelIndex()
parent_node: ReqNode = self.__req_data_agent.get_req_root()
else:
parent_node: ReqNode = parent.internalPointer()
self.insert_node_children(parent_node, [ReqNode() for _ in range(count)], row)
return True
# ---------------------------------- Node Operation ----------------------------------
def insert_node_children(self, parent_node: ReqNode, insert_nodes: ReqNode or [ReqNode], pos: int):
if isinstance(insert_nodes, ReqNode):
insert_nodes = [insert_nodes]
if parent_node is None:
parent = QModelIndex()
parent_node = self.__req_data_agent.get_req_root()
else:
parent = self.index_of_node(parent_node)
if pos < 0:
pos = parent_node.child_count()
self.begin_edit()
self.beginInsertRows(parent, pos, pos + len(insert_nodes) - 1)
# All operation by agent
# parent_node.insert_children(insert_nodes, pos)
self.__req_data_agent.insert_node(parent_node.get_uuid(), pos, insert_nodes)
self.endInsertRows()
self.end_edit()
# -------------------------------------------------------------------------------------
def refresh_meta_config(self):
self.__meta_full_columns = self.column_titles()
# TODO: Column filter
self.__meta_selected_columns = self.__meta_full_columns
def column_titles(self) -> List[str]:
# TODO: Optimise - Just update when MetaData updated
# Why +2 : Reserve the 1st column for tree structure and the 2nd column for Req ID
# Why -1 : Ignore STATIC_META_ID_PREFIX column
meta_data = self.__req_data_agent.get_req_meta()
meta_title = list(meta_data.keys())
if STATIC_META_ID_PREFIX in meta_title:
meta_title.remove(STATIC_META_ID_PREFIX)
meta_title.insert(0, 'Req ID')
meta_title.insert(0, 'Tree')
return meta_title
# https://gist.github.com/xiaolai/aa190255b7dde302d10208ae247fc9f2
MARK_DOWN_CSS_TABLE = """
.markdown-here-wrapper {
font-size: 16px;
line-height: 1.8em;
letter-spacing: 0.1em;
}
pre, code {
font-size: 14px;
font-family: Roboto, 'Courier New', Consolas, Inconsolata, Courier, monospace;
margin: auto 5px;
}
code {
white-space: pre-wrap;
border-radius: 2px;
display: inline;
}
pre {
font-size: 15px;
line-height: 1.4em;
display: block; !important;
}
pre code {
white-space: pre;
overflow: auto;
border-radius: 3px;
padding: 1px 1px;
display: block !important;
}
strong, b{
color: #BF360C;
}
em, i {
color: #009688;
}
hr {
border: 1px solid #BF360C;
margin: 1.5em auto;
}
p {
margin: 1.5em 5px !important;
}
table, pre, dl, blockquote, q, ul, ol {
margin: 10px 5px;
}
ul, ol {
padding-left: 15px;
}
li {
margin: 10px;
}
li p {
margin: 10px 0 !important;
}
ul ul, ul ol, ol ul, ol ol {
margin: 0;
padding-left: 10px;
}
ul {
list-style-type: circle;
}
dl {
padding: 0;
}
dl dt {
font-size: 1em;
font-weight: bold;
font-style: italic;
}
dl dd {
margin: 0 0 10px;
padding: 0 10px;
}
blockquote, q {
border-left: 2px solid #009688;
padding: 0 10px;
color: #777;
quotes: none;
margin-left: 1em;
}
blockquote::before, blockquote::after, q::before, q::after {
content: none;
}
h1, h2, h3, h4, h5, h6 {
margin: 20px 0 10px;
padding: 0;
font-style: bold !important;
color: #009688 !important;
text-align: center !important;
margin: 1.5em 5px !important;
padding: 0.5em 1em !important;
}
h1 {
font-size: 24px !important;
border-bottom: 1px solid #ddd !important;
}
h2 {
font-size: 20px !important;
border-bottom: 1px solid #eee !important;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
table {
padding: 0;
border-collapse: collapse;
border-spacing: 0;
font-size: 1em;
font: inherit;
border: 0;
margin: 0 auto;
}
tbody {
margin: 0;
padding: 0;
border: 0;
}
table tr {
border: 0;
border-top: 1px solid #CCC;
background-color: white;
margin: 0;
padding: 0;
}
table tr:nth-child(2n) {
background-color: #F8F8F8;
}
table tr th, table tr td {
font-size: 16px;
border: 1px solid #CCC;
margin: 0;
padding: 5px 10px;
}
table tr th {
font-weight: bold;
color: #eee;
border: 1px solid #009688;
background-color: #009688;
}
"""
HTML_TEMPLATE = """
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="content-type" />
<style>
{css}
</style>
</head>
<body>
{content}
</body>
</html>
"""
def convert_table_to_markdown(data):
# 解析TSV数据
reader = csv.reader(StringIO(data), delimiter='\t')
markdown_table = list(reader)
# 获取表格中的行和列
num_cols = max([len(row) for row in markdown_table])
# 格式化Markdown表格
markdown_text = ''
for i, row in enumerate(markdown_table):
# formatted_row = [cell.replace('\n', '<br>') for cell in row]
formatted_row = [html.escape(cell).replace('\n', '<br>') for cell in row]
markdown_text += '| ' + ' | '.join(formatted_row) + ' |\n'
if i == 0:
markdown_text += '| ' + ' | '.join(['---'] * num_cols) + ' |\n'
return markdown_text
def html_has_table(html):
# 解析HTML数据
soup = BeautifulSoup(html, 'html.parser')
table = soup.find('table')
# 检查是否存在表格
return bool(table)
def is_image(file_path):
# 获取文件的扩展名
extension = os.path.splitext(file_path)[1].lower()
# 判断扩展名是否是Markdown支持的图片格式
return extension in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']
KEEP_ATTR = ['rowspan', 'colspan']
def pick_table_from_html(html):
# 解析HTML数据
soup = BeautifulSoup(html, 'html.parser')
table = soup.find('table')
# 移除表格中的样式
table.attrs = {}
for tag in table.find_all(True):
attrs_to_keep = {k: v for k, v in tag.attrs.items() if k in KEEP_ATTR}
tag.attrs = attrs_to_keep
# 删除所有以 <? 开头,以 ?> 结尾的标签
clean_html = re.sub(r'<\?.*?\?>', '', str(table))
# 在返回之前删除所有非标准的 Microsoft Office 标签
clean_html = re.sub(r'</?o:[^>]*>', '', clean_html)
# 在返回之前删除 <tr> 和 </tr> 之间标签之间的换行符
clean_html = re.sub(r'(?<=>)\s+(?=<)', '', clean_html)
# 删除多余的空格和换行符,并在 <tr> 标签前添加换行符
# compact_html = re.sub(r'>\s+<', '><', clean_html)
# formatted_html = re.sub(r'(<tr[^>]*>)', r'\n\1', compact_html)
return clean_html
class MarkdownEditor(QTextEdit):
def __init__(self, attachment_folder='attachment', parent=None):
super(MarkdownEditor, self).__init__(parent)
self.attachment_folder = attachment_folder
def insertFromMimeData(self, source):
if source.hasFormat('application/x-qt-windows-mime;value="XML Spreadsheet"'):
msgBox = QMessageBox()
msgBox.setWindowTitle("FreeReq")
msgBox.setText("FreeReq发现你正在粘贴表格,请选择粘贴的方式。\n\n'"
"'Markdown便于修改;\n但如果表格中有跨行或跨列的布局,建议粘贴为HTML或图片。")
msgBox.addButton("转换为Markdown", QMessageBox.AcceptRole)
msgBox.addButton("转换为HTML", QMessageBox.AcceptRole)
if source.hasImage():
msgBox.addButton("转换为图片", QMessageBox.AcceptRole)
msgBox.addButton("取消", QMessageBox.RejectRole)
ret = msgBox.exec_()
if ret == 0:
# Complex table sheet may cause exception.
# If so, just paste it as image.
try:
# 粘贴的数据是表格类型
markdown_text = convert_table_to_markdown(source.text())
self.insertPlainText(markdown_text)
return
except Exception as e:
print('Error: Try to parse paste table data to markdown format fail.')
print(e)
QMessageBox.information(None, 'Error', 'Parse table to markdown error. Paste it as image.')
finally:
pass
elif ret == 1:
try:
html_text = pick_table_from_html(source.html())
self.insertPlainText(html_text)
return
except Exception as e:
print('Error: Try to parse paste table data to HTML fail.')
print(e)
QMessageBox.information(None, 'Error', 'Parse table to HTML error. Paste it as image.')
finally:
pass
elif ret == 2:
# 转换为图片
pass
else:
# 取消
pass
elif source.hasFormat('text/html'):
html_text = source.html()
if html_has_table(html_text):
# Especially for word copied table data.
try:
table_html = pick_table_from_html(html_text)
self.insertPlainText(table_html)
return
except Exception as e:
print('Error: Try to parse paste table data to HTML fail.')
print(e)
QMessageBox.information(None, 'Error', 'Parse table to HTML error. Paste it as image.')
finally:
pass
if source.hasImage():
image = source.imageData()
new_file_path, file_name = self.__require_file_name(self.attachment_folder)
if new_file_path != '':
if not new_file_path.lower().endswith('.png'):
new_file_path += '.png'
image.save(new_file_path, "PNG")
markdown_text = f"![{file_name}]({new_file_path})"
self.__insert_text_to_cursor(markdown_text)
else:
super(MarkdownEditor, self).insertFromMimeData(source)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
# 获取鼠标在QTextEdit中的位置
mouse_pos = event.pos()
# 将鼠标位置转换为光标位置
cursor = self.cursorForPosition(mouse_pos)
# 设置QTextEdit的光标位置
self.setTextCursor(cursor)
if event.mimeData().hasUrls():
url = event.mimeData().urls()[0]
file_path = url.toLocalFile()
file_name = os.path.basename(file_path)
markdown_text = ''
msgBox = QMessageBox()
msgBox.setText("Select operation")
copyButton = msgBox.addButton("Copy to attachment folder", QMessageBox.ActionRole)
linkButton = msgBox.addButton("Link to original file", QMessageBox.ActionRole)
msgBox.addButton(QMessageBox.Cancel)
msgBox.exec_()
if msgBox.clickedButton() == copyButton:
if not os.path.exists(self.attachment_folder):
os.makedirs(self.attachment_folder)
new_file_path = os.path.join(self.attachment_folder, file_name)
if os.path.exists(new_file_path):
new_file_path, file_name = self.__require_file_name(self.attachment_folder)
if new_file_path != '':
shutil.copy(file_path, new_file_path)
markdown_text = f"{'!' if is_image(file_path) else ''}[{file_name}]({new_file_path})"
elif msgBox.clickedButton() == linkButton:
markdown_text = f"[{file_name}]({file_path})"
if markdown_text != '':
self.__insert_text_to_cursor(markdown_text)
self.setFocus()
super(MarkdownEditor, self).dropEvent(event)
def __insert_text_to_cursor(self, text: str):
# 获取当前的文本光标
cursor = self.textCursor()
# 插入文本
cursor.insertText(text)
# 设置新的光标位置
self.setTextCursor(cursor)
def __require_file_name(self, folder: str) -> Tuple[str, str]:
if not os.path.exists(folder):
os.makedirs(folder)
while True:
file_name, ok = QInputDialog.getText(self, "Name for the file", "File Name: ",
QLineEdit.Normal, str(uuid.uuid4()))
if ok:
new_file_path = os.path.join(folder, file_name)
if not os.path.exists(new_file_path):
return new_file_path, file_name
else:
return '', ''
def render_markdown(md_text: str) -> str:
"""
https://zhuanlan.zhihu.com/p/34549578
:param md_text:
:return:
"""
# https://github.com/trentm/python-markdown2/blob/master/lib/markdown2.py
extras = ['strike', 'underline', 'tg-spoiler', 'smarty-pants', 'break-on-newline',
'code-friendly', 'fenced-code-blocks', 'footnotes', 'tables', 'code-color', 'pyshell', 'nofollow',
'cuddled-lists', 'header ids', 'nofollow']
ret = markdown2.markdown(md_text, extras=extras)
return HTML_TEMPLATE.format(css=MARK_DOWN_CSS_TABLE, content=ret)
def html_to_view(html_text: str, view, root_path: str = ''):
html_text = html_text.replace('strike>', 'del>')
try:
if is_web_engine_view(view):
req_root_path_url = f'file:///{root_path}'.replace('\\', '/') + '/'
# I can't believe that it can be solved in this simple way.
# I've talked with NewBing for a whole afternoon but it totally useless.
# Reference: https://stackoverflow.com/questions/74191037/why-qwebengineview-doesnt-show-my-image
view.setHtml(html_text, baseUrl=QUrl(req_root_path_url))
elif isinstance(view, QTextEdit):
view.setHtml(html_text)
else:
raise ValueError('Neither a QWebEngineView nor a QTextView.')
except Exception as e:
print(e)
print(traceback.format_exc())
finally:
pass
def markdown_to_view(md_text: str, view, root_path: str = ''):
html_text = render_markdown(md_text)
html_to_view(html_text, view, root_path)
def image_to_base64(image_path):
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode()
def embed_html_images(html_content):
soup = BeautifulSoup(html_content, 'html.parser')
for img in soup.find_all('img'):
if img.has_attr('src'):
image_path = img['src']
base64_image = image_to_base64(image_path)
img['src'] = f'data:image/png;base64,{base64_image}'
return str(soup)
def save_view_html(view, file_name, img_embedding: bool):
def __save_html(file: str, img_emb: bool, text: str):
if img_emb:
text = embed_html_images(text)
with open(file, 'wt', encoding='utf-8') as f:
f.write(text)
msgBox = QMessageBox()
msgBox.setWindowTitle('Save as HTML')
msgBox.setText(f'Saved to {file}.')
msgBox.exec_()
if isinstance(view, QTextEdit):
__save_html(file_name, img_embedding, view.toHtml())
elif is_web_engine_view(view):
view.page().toHtml(partial(__save_html, file_name, img_embedding))
else:
print('Warning: Unsupported view type.')
def print_text_edit(text_edit: QTextEdit, file_name: str = 'export.pdf'):
printer = QPrinter()
preview = QPrintPreviewDialog(printer)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(file_name)
preview.paintRequested.connect(lambda: text_edit.print_(printer))
preview.exec_()
def print_web_view(web_view, file_name: str = 'export.pdf'):
def handle_print_finished(filename, success):
if success:
if platform.system() == "Windows":
os.startfile(filename)
elif platform.system() == "Darwin":
subprocess.call(["open", filename])
else:
subprocess.call(["xdg-open", filename])
web_view.page().printToPdf(file_name)
web_view.page().pdfPrintingFinished.connect(handle_print_finished)
class WebViewPrinter:
TEMP_PATH = 'temp-pdf'
def __init__(self, filename: str, markdowns: List[str], root_path: str = '', cb_on_finish_or_error=None):
self.filename = filename
self.root_path = root_path
self.markdowns = markdowns
self.callback = cb_on_finish_or_error
self.current_index = 0
self.web_view = QWebEngineView()
self.web_view.setHidden(True) # Hide the web view
self.web_view.loadFinished.connect(self.__handle_load_finished)
self.web_view.page().pdfPrintingFinished.connect(self.__handle_print_finished)
self.temp_file_name = ''
self.merger = PdfMerger()
def print(self):
self.__ensure_temp_path()
self.__print_next()
def __print_next(self):
print('> Print Next')
if self.current_index < len(self.markdowns):
md_text = self.markdowns[self.current_index]
markdown_to_view(md_text, self.web_view, self.root_path)
print(f'Printing ({self.current_index}/{len(self.markdowns)})......')
else:
try:
self.merger.write(self.filename)
self.merger.close()
result = 'finished'
except Exception as e:
print(e)
result = 'fail'
finally:
if self.callback is not None:
self.callback(result)
print("Print finished")
def __handle_load_finished(self, ok):
print('> Load finished')
if ok: # The page was loaded successfully
self.temp_file_name = os.path.join(WebViewPrinter.TEMP_PATH, f'temp_{self.current_index}.pdf')
print(f'Print to file: {self.temp_file_name}')
self.web_view.page().printToPdf(self.temp_file_name)
else:
print('Load fail and ignore.')
def __handle_print_finished(self, ok: bool):
print('> Print finished')
print('\r\n')
if ok:
self.merger.append(self.temp_file_name)
self.current_index += 1
self.__print_next() # Start loading the next page
else:
print('Fail.')
if self.callback is not None:
self.callback('fail')
def __ensure_temp_path(self):
if not os.path.exists(WebViewPrinter.TEMP_PATH):
os.makedirs(WebViewPrinter.TEMP_PATH)
def __collect_req_content_r(req_node: ReqNode, markdowns: List[str], node_stack: List[ReqNode], recursive: bool):
contents = req_node.get(STATIC_FIELD_CONTENT)
if contents.strip() != '':
# The content empty node is just a path. Does not print it.
# Use path as main title
# title = req_node.get_title()
title = ' >> '.join([node.get_title() for node in node_stack])
page_contents = f"\n\n# {title}\n\n{contents}"
markdowns.append(page_contents)
if recursive:
for sub_req_node in req_node.children():
node_stack.append(sub_req_node)
__collect_req_content_r(sub_req_node, markdowns, node_stack, True)
node_stack.pop()
def collect_req_content(req_nodes: ReqNode or List[ReqNode], recursive: bool = True):
if isinstance(req_nodes, ReqNode):
req_nodes = [req_nodes]
else:
req_nodes = list(req_nodes)
markdowns = []
node_stack = []
for req_node in req_nodes:
node_stack.append(req_node)
__collect_req_content_r(req_node, markdowns, node_stack, recursive)
node_stack.pop()
return markdowns
printing_web_view: WebViewPrinter = None
def print_req_nodes(req_nodes: ReqNode or List[ReqNode], filename: str,
recursive: bool = True, root_path: str = '',
dense: bool = False, on_finished=None):
global printing_web_view
if printing_web_view is not None:
print('** Printing is on progress. **')
return
markdowns = collect_req_content(req_nodes, recursive)
if dense:
markdowns = ['\n\n'.join(markdowns)]
try:
def on_print_done(result):
print(f'Print finished: {result}')
global printing_web_view
printing_web_view = None
if on_finished is not None:
on_finished(result)
printing_web_view = WebViewPrinter(filename, markdowns, root_path, on_print_done)
printing_web_view.print()
except Exception as e:
print(e)
print(traceback.format_exc())
print('** Note: Recursive print only support QWebEngineView **')
finally:
pass
class ReqEditorBoard(QWidget):
def __init__(self, req_data_agent: IReqAgent, req_model: ReqModel):
super(ReqEditorBoard, self).__init__()
self.__req_data_agent = req_data_agent
self.__req_model = req_model
self.__editing_node: ReqNode = None
self.__content_edited = False
self.__meta_data_layouts = []
self.__meta_data_controls = {}
self.__line_id = QLineEdit('')
self.__line_title = QLineEdit('')
self.__layout_dynamic = QGridLayout()
self.layout_root = QVBoxLayout()
self.layout_feature_area = QHBoxLayout()
self.layout_plugin_area = QHBoxLayout()
self.text_md_editor = MarkdownEditor()
try:
self.text_md_viewer = QWebEngineView()
self.text_md_viewer.setPage(QCustomerWebEnginePage(self.text_md_viewer))
except Exception as e:
print(e)
print(traceback.format_exc())
print('Try to use QtWebEngineWidgets fail. Just use QTextEdit to render HTML.')
self.text_md_viewer = QTextEdit()
self.text_md_viewer.setReadOnly(True)
self.text_md_viewer.setAcceptRichText(False)
finally:
pass
self.__group_meta_data = QGroupBox()
# self.__check_editor = QCheckBox('Editor')
# self.__check_viewer = QCheckBox('Viewer')
self.__button_increase_font = QPushButton('+')
self.__button_decrease_font = QPushButton('-')
self.__button_req_refresh = QPushButton('Refresh')
self.__button_re_assign_id = QPushButton('Re-assign ID')
self.__button_print_preview = QPushButton('Print to PDF')
self.__button_save_preview = QPushButton('Save as HTML')
self.__button_save_content = QPushButton('Save Content')
self.__init_ui()
def __init_ui(self):
self.__layout_ui()
self.__config_ui()
# self.__layout_meta_area()
def __layout_ui(self):
self.setLayout(self.layout_root)
# up - meta area
meta_layout = QVBoxLayout()
static_meta_layout = QHBoxLayout()
static_meta_layout.addWidget(QLabel('Name: '))
static_meta_layout.addWidget(self.__line_title, 90)
static_meta_layout.addWidget(QLabel(' '))
static_meta_layout.addWidget(QLabel('ID: '))
static_meta_layout.addWidget(self.__line_id)
static_meta_layout.addWidget(self.__button_re_assign_id)
meta_layout.addLayout(static_meta_layout)
# dynamic_meta_layout = QGridLayout()
# # TODO: Dynamic create controls by meta data
meta_layout.addLayout(self.__layout_dynamic)
self.__group_meta_data.setLayout(meta_layout)
self.layout_root.addWidget(self.__group_meta_data, 1)
# mid
self.layout_feature_area.addWidget(self.__button_decrease_font)
self.layout_feature_area.addWidget(self.__button_increase_font)
self.layout_feature_area.addLayout(self.layout_plugin_area)
self.layout_feature_area.addWidget(QLabel(''), 99)
self.layout_feature_area.addWidget(self.__button_print_preview)
self.layout_feature_area.addWidget(self.__button_save_preview)
self.layout_feature_area.addWidget(self.__button_save_content)
self.layout_root.addLayout(self.layout_feature_area)
# bottom
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.text_md_editor)
splitter.addWidget(self.text_md_viewer)
splitter.setSizes([200, 200])
self.layout_root.addWidget(splitter, 9)
def __config_ui(self):
self.__line_id.setReadOnly(True)
self.__button_increase_font.setMaximumSize(30, 30)
self.__button_decrease_font.setMaximumSize(30, 30)
editor_font = self.text_md_editor.font()
editor_font.setPointSizeF(10)
self.text_md_editor.setFont(editor_font)
self.text_md_editor.setAcceptRichText(False)
self.__line_title.textChanged.connect(self.on_content_changed)
self.text_md_editor.textChanged.connect(self.on_text_content_edit)
self.__button_increase_font.clicked.connect(self.on_button_increase_font)
self.__button_decrease_font.clicked.connect(self.on_button_decrease_font)
self.__button_re_assign_id.clicked.connect(self.on_button_re_assign_id)
self.__button_print_preview.clicked.connect(self.on_button_print_preview)
self.__button_save_preview.clicked.connect(self.on_button_save_preview)
self.__button_save_content.clicked.connect(self.on_button_save_content)
def __layout_meta_area(self):
self.__reset_layout()
self.__rebuild_meta_ctrl()
self.__layout_meta_data_ctrl()
def __reset_layout(self):
# https://stackoverflow.com/a/25330164
for i in reversed(range(self.__layout_dynamic.count())):
widget_to_remove = self.__layout_dynamic.itemAt(i).widget()
if widget_to_remove is not None:
# remove it from the layout list
self.__layout_dynamic.removeWidget(widget_to_remove)
# remove it from the gui
widget_to_remove.setParent(None)
def __rebuild_meta_ctrl(self):
meta_data_layouts = []
meta_data_controls = {}
meta_data = self.__req_data_agent.get_req_meta()
for meta_name, meta_selection in meta_data.items():
if meta_name == STATIC_META_ID_PREFIX:
continue
# line = QHBoxLayout()
# line.addWidget(QLabel(meta_name))
if len(meta_selection) != 0:
meta_data_edit_ctrl = QComboBox()
meta_data_edit_ctrl.addItem('', '')
meta_data_edit_ctrl.setEditable(False)
for selection in meta_selection:
meta_data_edit_ctrl.addItem(selection, selection)
meta_data_edit_ctrl.currentTextChanged.connect(self.on_content_changed)
else:
meta_data_edit_ctrl = QLineEdit()
meta_data_edit_ctrl.textChanged.connect(self.on_content_changed)
# line.addWidget(meta_data_edit_ctrl)
# meta_data_layouts.append(line)
meta_data_layouts.append([QLabel(meta_name + ': '), meta_data_edit_ctrl])
meta_data_controls[meta_name] = meta_data_edit_ctrl
self.__meta_data_layouts = meta_data_layouts
self.__meta_data_controls = meta_data_controls
def __layout_meta_data_ctrl(self):
count = 0
config_per_row = 3
for _label, _input in self.__meta_data_layouts:
self.__layout_dynamic.addWidget(_label, count // config_per_row, (count % config_per_row) * 2 + 0)
self.__layout_dynamic.addWidget(_input, count // config_per_row, (count % config_per_row) * 2 + 1)
count += 1
def re_layout_meta_area(self):
self.__layout_meta_area()
def on_button_increase_font(self):
editor_font = self.text_md_editor.font()
font_size = editor_font.pointSizeF()
editor_font.setPointSizeF(font_size * 1.05)
self.text_md_editor.setFont(editor_font)
# self.__text_md_viewer.setFont(editor_font)
def on_button_decrease_font(self):
editor_font = self.text_md_editor.font()
font_size = editor_font.pointSizeF()
editor_font.setPointSizeF(font_size / 1.05)
self.text_md_editor.setFont(editor_font)
# self.__text_md_viewer.setFont(editor_font)
def on_button_re_assign_id(self):
id_prefixes = self.__req_data_agent.get_req_meta().get(STATIC_META_ID_PREFIX, [])
if len(id_prefixes) == 0:
QMessageBox.information(self, 'No ID Define', 'Please define the ID prefix in mete data first.')
return
if len(id_prefixes) == 1:
self.on_menu_assign_id(id_prefixes[0])
self.update_content_edited_status(True)
else:
menu = QMenu()
for id_prefix in id_prefixes:
menu.addAction(id_prefix, partial(self.on_menu_assign_id, id_prefix))
menu.exec(QCursor.pos())
def on_menu_assign_id(self, id_prefix: str):
req_id = self.__req_data_agent.new_req_id(id_prefix)
self.__line_id.setText(req_id)
self.update_content_edited_status(True)
def on_button_print_preview(self):
file_name = (self.__editing_node.get_title() + '.pdf') if \
self.__editing_node is not None else 'export.pdf'
if isinstance(self.text_md_viewer, QTextEdit):
print_text_edit(self.text_md_viewer, file_name)
elif is_web_engine_view(self.text_md_viewer):
print_web_view(self.text_md_viewer, file_name)
msgBox = QMessageBox()
msgBox.setWindowTitle('Printed to PDF')
msgBox.setText(f'Printed to {file_name}. \nPlease do extra operation (save as, print) to this opened PDF.')
msgBox.exec_()
else:
# Should not reach here
assert False
def on_button_save_preview(self):
file_name = (self.__editing_node.get_title() + '.html') if \
self.__editing_node is not None else 'export.html'
save_view_html(self.text_md_viewer, file_name, True)
def on_button_save_content(self):
if self.__editing_node is not None:
self.__ui_to_meta_data(self.__editing_node)
self.__ui_to_req_node_data(self.__editing_node)
self.update_content_edited_status(False)
def on_text_content_edit(self):
self.render_markdown()
self.on_content_changed()
def on_content_changed(self, *args):
self.update_content_edited_status(True)
def render_markdown(self):
md_text = self.text_md_editor.toPlainText()
markdown_to_view(md_text, self.text_md_viewer, self.__req_data_agent.get_req_path())
# ---------------------------------------------------------------------------
def __meta_data_to_ui(self, req_node: ReqNode):
meta_data = self.__req_data_agent.get_req_meta()
for meta_name, meta_selection in meta_data.items():
if meta_name == STATIC_META_ID_PREFIX:
continue
meta_ctrl = self.__meta_data_controls.get(meta_name, None)
if meta_ctrl is None:
print('Warning: Cannot find the control for the meta data.')
continue
meta_content = req_node.get(meta_name, '')
if len(meta_selection) > 1:
if isinstance(meta_ctrl, QComboBox):
index = meta_ctrl.findData(meta_content)
if index == -1:
print('Warning: Meta content out of meta data selection.')
meta_ctrl.setEditable(True)
meta_ctrl.setEditText(meta_content)
else:
meta_ctrl.setEditable(False)
meta_ctrl.setCurrentIndex(index)
else:
raise ValueError('The control should be QComboBox')
else:
if isinstance(meta_ctrl, QLineEdit):
meta_ctrl.setText(meta_content)
else:
raise ValueError('The control should be QLineEdit')
def __ui_to_meta_data(self, req_node: ReqNode):
meta_data = self.__req_data_agent.get_req_meta()
for meta_name, meta_selection in meta_data.items():
if meta_name == STATIC_META_ID_PREFIX:
continue
meta_ctrl = self.__meta_data_controls.get(meta_name, None)
if meta_ctrl is None:
print('Warning: Cannot find the control for the meta data.')
continue
if isinstance(meta_ctrl, QComboBox):
meta_content = meta_ctrl.currentText()
elif isinstance(meta_ctrl, QLineEdit):
meta_content = meta_ctrl.text()
else:
raise ValueError('Warning: The control for meta must be QComboBox or QLineEdit')
req_node.set(meta_name, meta_content)
def __req_node_data_to_ui(self, req_node: ReqNode):
self.__req_node_data_to_ui_title(req_node)
self.__line_title.setText(req_node.get_title())
self.__line_id.setText(req_node.get(STATIC_FIELD_ID, ''))
self.text_md_editor.setPlainText(req_node.get(STATIC_FIELD_CONTENT, ''))
def __req_node_data_to_ui_title(self, req_node: ReqNode):
_uuid = req_node.get_uuid()
last_editor = req_node.get(STATIC_FIELD_LAST_EDITOR, '')
last_update = req_node.get(STATIC_FIELD_LAST_CHANGE_TIME, '')
addition = []
if last_editor:
addition.append(last_editor)
if last_update:
addition.append(last_update)
addition_text = ', '.join(addition)
title = f"Req UUID: {_uuid}"
if addition_text:
title += ' | ' + addition_text
self.__group_meta_data.setTitle(title)
def __ui_to_req_node_data(self, req_node: ReqNode):
self.__req_model.begin_edit()
req_node.set(STATIC_FIELD_ID, self.__line_id.text())
req_node.set(STATIC_FIELD_TITLE, self.__line_title.text())
# 20240625 : Add last edit information
current_user = self.get_current_user()
current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
req_node.set(STATIC_FIELD_LAST_EDITOR, current_user)
req_node.set(STATIC_FIELD_LAST_CHANGE_TIME, current_time)
# Update UI directly
self.__req_node_data_to_ui_title(req_node)
req_node.set(STATIC_FIELD_CONTENT, self.text_md_editor.toPlainText())
self.__req_data_agent.update_node(req_node)
self.__req_model.end_edit()
# self.__req_data_agent.inform_node_data_updated(req_node)
def __reset_ui_content(self):
for _, meta_ctrl in self.__meta_data_controls.items():
if isinstance(meta_ctrl, QComboBox):
meta_ctrl.setCurrentIndex(-1)
elif isinstance(meta_ctrl, QLineEdit):
meta_ctrl.setText('')
self.__line_id.setText('')
self.__line_title.setText('')
self.text_md_editor.setPlainText('')
self.__group_meta_data.setTitle('')
def update_content_edited_status(self, edited: bool):
self.__content_edited = edited
self.__button_save_content.setText('* Save Content' if edited else 'Save Content')
# ----------------------------------------------------------------------------------
def edit_req(self, req_node: ReqNode):
if req_node is not None:
self.__editing_node = req_node.clone()
self.__meta_data_to_ui(self.__editing_node)
self.__req_node_data_to_ui(self.__editing_node)
else:
self.__editing_node = None
self.__reset_ui_content()
self.update_content_edited_status(False)
# def set_data_agent(self, req_data_agent: IReqAgent):
# self.edit_req(None)
# self.__req_data_agent = req_data_agent
def is_content_edited(self) -> bool:
return self.__content_edited
def on_meta_data_updated(self):
self.__layout_meta_area()
def get_current_user(self) -> str:
try:
username = os.getlogin()
return username
except OSError as e:
print("Failed to get the current username:", e)
return 'Unknown'
# ----------------------------------------------------------------------------------------------------------------------
ID_COMMENTS = """
You can specify the prefix of Req ID like: WHY, WHAT, HOW
Then you will get assigned Req ID like WHY00001, WHAT00001, HOW00001
"""
ID_DEFAULT = 'WHY, WHAT, HOW'
META_COMMENTS = """"Meta Name 1": [],
"Meta Name 2": ["Selection 1", "Selection 2"],
Meta Name: The name of config.
Selections: If selection is not empty, the config will be limited with selection, otherwise free input text.
The meta items are divided by comma (,).
"""
META_DEFAULT = """"Owner": [],
"Version": [],
"Status": ["Draft", "Submitted", "Reviewing", "Reserved", "Approved", "Deferred", "Rejected"],
"Priority": ["Must / Vital", "Should / Necessary", "Could / Nice to Have", "To Be Defined"],
"Implementation": ["Not Implemented", "Planing", "Designing", "Implementing", "Verifying", "Full Implemented", "Partial Implemented"]
"""
class ReqMetaBoard(QWidget):
def __init__(self, req_data_agent: IReqAgent, meta_update_cb=None):
super(ReqMetaBoard, self).__init__()
self.__req_data_agent = req_data_agent
self.__on_meta_data_updated = meta_update_cb
self.__group_id = QGroupBox('ID Config')
self.__group_meta = QGroupBox('Meta Data Config')
self.__button_save = QPushButton('Save')
self.__button_fill_default_id = QPushButton('Fill Example Value')
self.__button_fill_default_meta = QPushButton('Fill Example Value')
self.__text_id_prefixes = QTextEdit(ID_DEFAULT)
self.__text_meta_defines = QTextEdit(META_DEFAULT)
self.layout_root = QVBoxLayout()
self.__init_ui()
def __init_ui(self):
self.__layout_ui()
self.__config_ui()
def __layout_ui(self):
self.setLayout(self.layout_root)
self.layout_root.addWidget(self.__group_id, 2)
self.layout_root.addWidget(self.__group_meta, 8)
group_layout = QVBoxLayout()
line = QHBoxLayout()
line.addWidget(QLabel(ID_COMMENTS), 99)
line.addWidget(self.__button_fill_default_id)
group_layout.addLayout(line)
group_layout.addWidget(self.__text_id_prefixes, 99)
self.__group_id.setLayout(group_layout)
group_layout = QVBoxLayout()
line = QHBoxLayout()
line.addWidget(QLabel(META_COMMENTS), 99)
line.addWidget(self.__button_fill_default_meta)
group_layout.addLayout(line)
group_layout.addWidget(self.__text_meta_defines, 99)
self.__group_meta.setLayout(group_layout)
line = QHBoxLayout()
line.addStretch(100)
line.addWidget(self.__button_save)
self.layout_root.addLayout(line)
def __config_ui(self):
self.__button_save.clicked.connect(self.on_button_save)
self.__button_fill_default_id.clicked.connect(self.on_button_fill_default_id)
self.__button_fill_default_meta.clicked.connect(self.on_button_fill_default_meta)
def on_button_save(self):
if self.__req_data_agent is None:
return
try:
meta_data = self.__ui_to_meta()
except Exception as e:
print(str(e))
meta_data = None
QMessageBox.information(self, 'Parse Meta Data Fail',
'Parse Meta Data Fail. Please check the format')
finally:
pass
if meta_data is not None:
self.__req_data_agent.set_req_meta(meta_data)
if self.__on_meta_data_updated is not None:
self.__on_meta_data_updated()
def on_button_fill_default_id(self):
self.__text_id_prefixes.setText(ID_DEFAULT)
def on_button_fill_default_meta(self):
self.__text_meta_defines.setText(META_DEFAULT)
def reload_meta_data(self):
self.__meta_to_ui()
# ----------------------------------------------------------------------
def __meta_to_ui(self):
if self.__req_data_agent is not None:
meta_data = self.__req_data_agent.get_req_meta()
meta_data = meta_data.copy()
if STATIC_META_ID_PREFIX in meta_data.keys():
id_prefix = meta_data[STATIC_META_ID_PREFIX]
del meta_data[STATIC_META_ID_PREFIX]
else:
id_prefix = []
id_prefix_text = ', '.join(id_prefix)
meta_data_lines = []
for meta_name, meta_selection in meta_data.items():
selection_text = ', '.join(['"%s"' % s for s in meta_selection])
meta_data_lines.append('"%s": [%s]' % (meta_name, selection_text))
meta_data_text = ', \n'.join(meta_data_lines)
self.__text_id_prefixes.setText(id_prefix_text)
self.__text_meta_defines.setText(meta_data_text)
def __ui_to_meta(self) -> dict:
id_prefix_text = self.__text_id_prefixes.toPlainText()
meta_data_text = self.__text_meta_defines.toPlainText()
id_prefix = id_prefix_text.split(',')
id_prefix = [_id.strip() for _id in id_prefix]
meta_data = json.loads('{' + meta_data_text + '}')
meta_data[STATIC_META_ID_PREFIX] = id_prefix
return meta_data
# ----------------------------------------------------------------------------------------------------------------------
class IndexListUI(QWidget):
def __init__(self, main_ui: RequirementUI):
super().__init__()
self.main_ui = main_ui
# 设置窗口初始大小和位置
self.setGeometry(0, 0, 400, 600)
# 设置窗口无最大最小化按钮
self.setWindowFlags(Qt.WindowCloseButtonHint)
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
# 创建列表控件
self.index_list = QTableWidget(self)
self.index_list.setColumnCount(2)
self.index_list.setHorizontalHeaderLabels(['Title', 'ID'])
self.index_list.setSelectionBehavior(QAbstractItemView.SelectRows)
self.index_list.setSelectionMode(QAbstractItemView.SingleSelection)
# 布局
layout = QVBoxLayout(self)
layout.addWidget(self.index_list)
# 信号连接
self.index_list.cellClicked.connect(self.on_index_list_cell_clicked)
def append_index(self, title: str, index: str):
row = self.index_list.rowCount()
self.index_list.insertRow(row)
self.index_list.setItem(row, 0, QTableWidgetItem(title))
self.index_list.setItem(row, 1, QTableWidgetItem(index))
def clear_index(self):
self.index_list.setRowCount(0)
def on_index_list_cell_clicked(self, row, column):
index = self.index_list.item(row, 1).text()
self.main_ui.jump_by_id(index)
# print(f'Index clicked: {index}')
def show_right_bottom(self):
# 计算并设置窗口初始位置
main_ui_geometry = self.main_ui.frameGeometry()
self.move(main_ui_geometry.bottomRight() - self.rect().bottomRight())
self.show()
def closeEvent(self, event):
# 当用户点击关闭时,窗口隐藏
event.ignore()
self.hide()
# ----------------------------------------------------------------------------------------------------------------------
class RequirementUI(QMainWindow, IReqObserver):
def __init__(self, req_data_agent: IReqAgent):
super().__init__()
self.__req_data_agent = req_data_agent
self.__req_data_agent.add_observer(self)
self.__req_model = ReqModel(self.__req_data_agent)
self.watcher = QFileSystemWatcher()
self.watcher.fileChanged.connect(self.on_editing_file_changed)
self.__cut_items = []
# self.__filter_index = -1
# self.__filter_nodes = []
self.__selected_node: ReqNode = None
self.__selected_index: QModelIndex = None
# self.__combo_req_select = QComboBox()
self.__tree_requirements = QTreeView()
self.layout_root = QHBoxLayout()
self.sub_window_index = {'default': IndexListUI(self)}
# self.__button_req_refresh = QPushButton('Refresh')
self.menu_bar = self.menuBar()
self.status_bar = self.statusBar()
self.status_bar_label = QLabel('')
self.dock_tree_requirements = QDockWidget("Outline", self)
self.dock_tree_requirements.visibilityChanged.connect(self.on_dock_visibility_changed)
self.edit_tab = QTabWidget()
self.meta_board = ReqMetaBoard(self.__req_data_agent, self.__on_meta_data_updated)
self.edit_board = ReqEditorBoard(self.__req_data_agent, self.__req_model)
self.module = sys.modules[__name__]
self.__init_ui()
if plugin_manager is not None:
plugin_manager.invoke_all('after_ui_created', self)
def __init_ui(self):
self.__layout_ui()
self.__config_ui()
self.__init_menu()
def __layout_ui(self):
# ---------------------- Dock widget (left) ----------------------
self.dock_tree_requirements.setWidget(self.__tree_requirements)
self.addDockWidget(Qt.LeftDockWidgetArea, self.dock_tree_requirements)
# ----------------------- Main area (right) ----------------------
central_widget = QWidget()
self.setCentralWidget(central_widget)
central_widget.setLayout(self.layout_root)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.edit_tab)
self.layout_root.addWidget(splitter)
self.edit_tab.addTab(self.edit_board, 'Requirement Edit')
self.edit_tab.addTab(self.meta_board, 'Meta Config')
# ----------------------- Status bar (bottom) ----------------------
self.status_bar.addPermanentWidget(self.status_bar_label)
def __config_ui(self):
self.setMinimumSize(800, 600)
self.setWindowTitle('Free Requirement - by Sleepy')
self.sub_window_index['default'].setWindowTitle('Search Result')
self.__tree_requirements.setModel(self.__req_model)
self.__tree_requirements.setAlternatingRowColors(True)
self.__tree_requirements.setContextMenuPolicy(Qt.CustomContextMenu)
try:
with open(os.path.join(self_path, 'res', 'tree_style.qss'), 'rt', encoding='utf-8') as f:
tree_style = f.read()
tree_style_abs_path = tree_style.replace('res/', os.path.join(self_path, 'res', ''))
tree_style_abs_path = tree_style_abs_path.replace('\\', '/')
self.__tree_requirements.setStyleSheet(tree_style_abs_path)
except Exception as e:
print(str(e))
print('QTreeView style not applied.')
finally:
pass
self.__tree_requirements.customContextMenuRequested.connect(self.on_requirement_tree_menu)
self.__tree_requirements.selectionModel().selectionChanged.connect(self.on_requirement_tree_selection_changed)
def __init_menu(self):
# File Menu
file_menu = self.menu_bar.addMenu('File')
open_action = QAction('Open...', self)
new_action = QAction('New Empty...', self)
new_template_action = QAction('New Template...', self)
save_action = QAction('Save', self)
exit_action = QAction('Exit', self)
file_menu.addAction(open_action)
file_menu.addSeparator()
file_menu.addAction(new_action)
file_menu.addAction(new_template_action)
file_menu.addSeparator()
file_menu.addAction(save_action)
file_menu.addSeparator()
file_menu.addAction(exit_action)
# Edit Menu
edit_menu = self.menu_bar.addMenu('Edit')
search_action = QAction('Find', self)
add_top_action = QAction('Add New Top Item', self)
rename_req_action = QAction('Rename Requirement', self)
edit_menu.addAction(search_action)
edit_menu.addSeparator()
edit_menu.addAction(add_top_action)
edit_menu.addAction(rename_req_action)
# View Menu
view_menu = self.menu_bar.addMenu('View')
self.toggle_tree_action = QAction('Toggle Requirements', self, checkable=True, checked=True)
self.toggle_tree_meta_stat = QAction('Tree Meta Statistics View')
view_menu.addAction(self.toggle_tree_action)
view_menu.addAction(self.toggle_tree_meta_stat)
self.toggle_tree_action.triggered.connect(self.toggle_tree_requirements)
self.toggle_tree_meta_stat.triggered.connect(self.toggle_tree_meta_statistics)
# About Menu
about_menu = self.menu_bar.addMenu('About')
about_action = QAction('About', self)
about_menu.addAction(about_action)
# Connect actions to handlers (leave handlers empty for now)
new_action.triggered.connect(self.handle_new)
new_template_action.triggered.connect(self.handle_new_template)
open_action.triggered.connect(self.handle_open)
save_action.triggered.connect(self.handle_save)
exit_action.triggered.connect(self.handle_exit)
search_action.triggered.connect(self.handle_search)
rename_req_action.triggered.connect(self.handle_rename_req)
add_top_action.triggered.connect(self.handle_add_top)
about_action.triggered.connect(self.handle_about)
def toggle_tree_requirements(self):
if self.dock_tree_requirements.isVisible():
self.dock_tree_requirements.hide()
else:
self.dock_tree_requirements.show()
def toggle_tree_meta_statistics(self):
# Keeping tree view visibility.
dock_tree_was_visible = self.dock_tree_requirements.isVisible()
# Hide for good looking.
self.dock_tree_requirements.hide()
self.__req_model.show_meta(True)
self.show_tree_in_dialog()
self.__req_model.show_meta(False)
if dock_tree_was_visible:
self.dock_tree_requirements.show()
def on_dock_visibility_changed(self, visible):
self.toggle_tree_action.setChecked(visible)
# ------------------------ Menu actions ------------------------
def handle_new(self):
self.on_menu_create_new_req()
def handle_new_template(self):
if self.on_menu_create_new_req():
req_root = self.__req_data_agent.get_req_root()
if req_root.child_count() == 0:
self.__req_model.insert_node_children(req_root, [
ReqNode('Business Needs (WHY)'),
ReqNode('Stakeholder Requirements (WHAT)'),
ReqNode('System Requirements (HOW)')
], 0)
def handle_open(self):
self.on_menu_open_local_file()
def handle_save(self):
self.edit_board.on_button_save_content()
def handle_exit(self):
self.close()
def handle_search(self):
self.pop_search()
def handle_add_top(self):
self.on_requirement_tree_menu_add_top_item()
def handle_rename_req(self):
self.on_menu_rename_req()
def handle_about(self):
QMessageBox.information(self, 'About', ABOUT_MESSAGE)
# ---------------------- Agent observer -----------------------
@Hookable
def on_req_loaded(self, req_uri: str):
self.watcher.addPath(req_uri)
# self.meta_board.reload_meta_data()
self.edit_board.re_layout_meta_area()
def on_req_closed(self, req_uri: str):
self.watcher.removePath(req_uri)
def on_req_exception(self, exception_name: str, **kwargs):
if exception_name == 'conflict':
QMessageBox.information(self, 'Conflict detected',
'The req file has been changed outside.\n'
f'Avoiding data lost, editing req file is renamed to {kwargs.get("backup_file")}\n'
'Please close this file and merge them by manual.')
self.update_status('Conflict')
# --------------------- File observer path ---------------------
def on_editing_file_changed(self, file_path: str):
def check_req_consistency(file: str):
if not self.__req_data_agent.check_req_consistency():
QMessageBox.information(self, 'File changed outside',
'The req file has been changed outside.\n'
'Please backup your current editing content and re-open this file.')
QTimer.singleShot(1000, lambda: check_req_consistency(file_path))
# -------------------------- UI Events --------------------------
def keyPressEvent(self, event):
if event.key() == Qt.Key_F and event.modifiers() == Qt.ControlModifier:
self.pop_search()
elif event.key() == Qt.Key_S and event.modifiers() == Qt.ControlModifier:
self.edit_board.on_button_save_content()
def on_requirement_tree_menu(self, pos: QPoint):
menu = QMenu()
sel_index: QModelIndex = self.__tree_requirements.indexAt(pos)
menu.addAction('Expand All', partial(self.on_requirement_tree_menu_expand_all, sel_index))
menu.addAction('Collapse All', partial(self.on_requirement_tree_menu_collapse_all, sel_index))
menu.addSeparator()
if sel_index is not None and sel_index.isValid():
# When all item expanded. You may not able to R-Click on an empty place.
menu.addAction('Collapse Whole Tree', partial(self.on_requirement_tree_menu_collapse_all, None))
menu.addSeparator()
menu.addAction('Append Child', self.on_requirement_tree_menu_append_child)
# Add submenu
id_prefixes = self.__req_data_agent.get_req_meta().get(STATIC_META_ID_PREFIX, [])
if len(id_prefixes) > 0:
sub_menu = menu.addMenu("Batch Assign Req ID")
for id_prefix in id_prefixes:
sub_menu.addAction(id_prefix, partial(self.on_menu_assign_id_for_tree, id_prefix))
menu.addSeparator()
menu.addAction('Insert sibling up', self.on_requirement_tree_menu_add_sibling_up)
menu.addAction('Insert sibling down', self.on_requirement_tree_menu_add_sibling_down)
menu.addSeparator()
menu.addAction('Shift item up', self.on_requirement_tree_menu_shift_item_up)
menu.addAction('Shift item Down', self.on_requirement_tree_menu_shift_item_down)
menu.addSeparator()
if len(self.__cut_items) > 0:
menu.addAction('Paste Item as child', partial(self.on_requirement_tree_menu_paste_item, 'child'))
menu.addAction('Paste Item as up sibling', partial(self.on_requirement_tree_menu_paste_item, 'up'))
menu.addAction('Paste Item as down sibling', partial(self.on_requirement_tree_menu_paste_item, 'down'))
menu.addSeparator()
menu.addAction('Cut item', self.on_requirement_tree_menu_cut_item)
menu.addSeparator()
menu.addAction('Delete item (Caution!!!)', self.on_requirement_tree_menu_delete_item)
menu.addSeparator()
menu.addAction('Print Tree (Dense)', partial(self.on_requirement_tree_menu_print_tree, True))
menu.addAction('Print Tree (Sparse)', partial(self.on_requirement_tree_menu_print_tree, False))
else:
menu.addAction('Add New Top Item', self.on_requirement_tree_menu_add_top_item)
if len(self.__cut_items) > 0:
menu.addAction('Paste as Top Item', partial(self.on_requirement_tree_menu_paste_item, 'top'))
menu.addSeparator()
menu.addAction('Rename Requirement', self.on_menu_rename_req)
menu.addSeparator()
menu.addAction('Create a New Requirement', self.on_menu_create_new_req)
menu.addSeparator()
menu.addAction('Open Local Requirement File', self.on_menu_open_local_file)
menu.exec(QCursor.pos())
@Hookable
def on_requirement_tree_selection_changed(self, selected: QItemSelection, deselected: QItemSelection):
# print(f'Tree Selection Changed: {deselected} -> {selected}')
if self.__selected_node is not None and self.edit_board.is_content_edited():
ret = QMessageBox.question(self, 'Save or Not',
'Requirement Content Changed.\r\nSave?',
QMessageBox.Yes | QMessageBox.No)
if ret == QMessageBox.Yes:
self.edit_board.on_button_save_content()
selected_indexes = selected.indexes()
if len(selected_indexes) > 0:
selected_index = selected_indexes[0]
self.__update_selected_index(selected_index)
def on_requirement_tree_menu_add_top_item(self):
self.__req_model.insertRow(-1)
def on_requirement_tree_menu_append_child(self):
if self.__tree_item_selected():
self.__req_model.insertRow(-1, self.__selected_index)
def on_menu_assign_id_for_tree(self, id_prefix: str):
def assign_req_id_if_empty(node: ReqNode, context: dict):
if node.get(STATIC_FIELD_ID, '').strip() == '':
req_id = self.__req_data_agent.new_req_id(id_prefix)
node.set(STATIC_FIELD_ID, req_id)
context[node.get_uuid()] = req_id
reply = QMessageBox.question(self, "Assign Req ID",
"This operation will automatically assign Req ID to this item and its children.\n"
"Note that existing IDs will not be affected.\n"
f"Will assign Req ID with prefix [{id_prefix}]. \n\n"
"Do you confirm?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
if self.__tree_item_selected():
result = self.__req_data_agent.req_map(self.__selected_node, assign_req_id_if_empty)
msg_box = QMessageBox()
msg_box.setWindowTitle('Assign Req ID Report')
if len(result) > 0:
self.__req_data_agent.save_req()
update_list = [f'{k} - {v}' for k, v in result.items()]
edit = QTextEdit()
edit.setReadOnly(True)
edit.setLineWrapMode(QTextEdit.NoWrap)
edit.setMinimumSize(800, 600)
edit.setText('Assign report: \n\n' + '\n'.join(update_list))
msg_box.layout().addWidget(edit, 0, 0, 1, msg_box.layout().columnCount())
else:
msg_box.setText('All Req ID has been assigned. No update.')
msg_box.exec_()
def on_requirement_tree_menu_expand_all(self, sel_index):
if sel_index is not None and sel_index.isValid():
self.do_expand_node_all(sel_index)
else:
self.__tree_requirements.expandAll()
def on_requirement_tree_menu_collapse_all(self, sel_index):
if sel_index is not None and sel_index.isValid():
self.do_collapse_node_all(sel_index)
else:
self.__tree_requirements.collapseAll()
def on_requirement_tree_menu_add_sibling_up(self):
if self.__tree_item_selected():
insert_pos = self.__selected_node.order()
parent_index = self.__req_model.parent(self.__selected_index)
self.__req_model.insertRow(insert_pos, parent_index)
def on_requirement_tree_menu_add_sibling_down(self):
if self.__tree_item_selected():
insert_pos = self.__selected_node.order() + 1
parent_index = self.__req_model.parent(self.__selected_index)
self.__req_model.insertRow(insert_pos, parent_index)
def on_requirement_tree_menu_shift_item_up(self):
if self.__tree_item_selected():
self.__req_model.begin_edit()
self.__req_data_agent.shift_node(self.__selected_node.get_uuid(), -1)
self.__req_model.end_edit()
def on_requirement_tree_menu_shift_item_down(self):
if self.__tree_item_selected():
self.__req_model.begin_edit()
self.__req_data_agent.shift_node(self.__selected_node.get_uuid(), 1)
self.__req_model.end_edit()
def on_requirement_tree_menu_cut_item(self):
if self.__tree_item_selected():
self.__cut_items.append(self.__selected_node)
self.on_requirement_tree_menu_delete_item()
def on_requirement_tree_menu_paste_item(self, pos: str):
if pos == 'top' or self.__tree_item_selected():
if len(self.__cut_items) > 0:
paste_node = self.__cut_items.pop()
if pos == 'top':
parent_node = self.__req_data_agent.get_req_root()
elif pos == 'child':
parent_node = self.__selected_node
else:
parent_node = self.__selected_node.parent()
if pos in ['top', 'child']:
self.__req_model.insert_node_children(parent_node, paste_node, -1)
elif pos == 'up':
paste_pos = self.__selected_node.order()
self.__req_model.insert_node_children(parent_node, paste_node, paste_pos)
elif pos == 'down':
paste_pos = self.__selected_node.order() + 1
self.__req_model.insert_node_children(parent_node, paste_node, paste_pos)
# self.__req_data_agent.inform_node_child_updated(parent_node)
def on_requirement_tree_menu_delete_item(self):
if self.__tree_item_selected():
# print(f'Tree Selection attempt delete -> {self.__selected_index}')
selected_node = self.__selected_node
node_order = self.__selected_node.order()
node_parent = self.__selected_node.parent()
if node_parent is not None:
# Because beginRemoveRows() will change the selected node
self.__req_model.beginRemoveRows(
self.__req_model.parent(self.__selected_index), node_order, node_order)
self.__req_data_agent.remove_node(selected_node.get_uuid())
self.__req_model.endRemoveRows()
# print(f'Tree Selection deleted -> {self.__selected_index}')
def on_requirement_tree_menu_print_tree(self, dense: bool):
if self.__selected_node is not None:
selected_node = self.__selected_node
file_name = (selected_node.get_title() + '.pdf') if selected_node is not None else 'export.pdf'
def handle_print_finished(result):
msgBox = QMessageBox()
msgBox.setWindowTitle('Print to PDF')
msgBox.setText(f'Printed to [{file_name}] Done.')
msgBox.exec_()
print_req_nodes(selected_node, file_name, recursive=True,
root_path=self.__req_data_agent.get_req_path(),
dense=dense, on_finished=handle_print_finished)
msgBox = QMessageBox()
msgBox.setWindowTitle('Print to PDF')
msgBox.setText(f'Async printing to {file_name}. \nPlease wait for printing finished.')
msgBox.exec_()
def on_menu_rename_req(self):
req_name, is_ok = QInputDialog.getText(
self, "Rename Requirement", "Requirement Name: ", QLineEdit.Normal, "")
req_name = req_name.strip()
if is_ok and req_name != '':
node_root = self.__req_data_agent.get_req_root()
if node_root is not None:
edit_node = node_root.clone()
edit_node.set_title(req_name)
self.__req_data_agent.update_node(edit_node)
# self.__req_data_agent.inform_node_data_updated(node_root)
def on_menu_create_new_req(self) -> bool:
# Reserved: FreeReq may open a request by connecting to a remote server,
# in which case selecting req requires going through the Agent.
#
# req_name, is_ok = QInputDialog.getText(
# self, "Create New Requirement", "Requirement Name: ", QLineEdit.Normal, "")
# req_name = req_name.strip()
# if is_ok and req_name != '':
# if req_name in self.__req_data_agent.list_req():
# ret = QMessageBox.question(
# self, 'Overwrite', 'Requirement already exists.\n\nOverwrite?',
# QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
# if ret != QMessageBox.Yes:
# return
file_path, is_ok = QFileDialog.getSaveFileName(
self, "Save As", "", "Requirement Files (*.req);;All Files (*)")
req_name = file_path.strip()
if is_ok and req_name:
self.__req_model.beginRemoveRows(QModelIndex(), 0, 0)
success = self.__req_data_agent.new_req(req_name, overwrite=True)
self.__req_model.endRemoveRows()
self.edit_board.edit_req(None)
self.meta_board.reload_meta_data()
else:
success = False
return success
def on_menu_open_local_file(self):
settings = QSettings("SleepySoft", "FreeReq")
# 获取上次打开文件的目录路径
last_open_dir = settings.value("last_open", "")
# 打开文件对话框,使用上次的目录路径作为默认路径
file_path, is_ok = QFileDialog.getOpenFileName(
self, 'Select File', last_open_dir, 'Requirement File (*.req);;All files (*.*)')
# 如果成功选择了文件,更新last_open的值为当前文件的目录路径
if is_ok and file_path:
# 获取文件的目录路径
last_open_dir = os.path.dirname(file_path)
# 保存到QSettings
settings.setValue("last_open", last_open_dir)
self.__req_model.beginRemoveRows(QModelIndex(), 0, 0)
self.__req_data_agent.open_req(file_path)
self.__req_model.endRemoveRows()
self.edit_board.edit_req(None)
self.meta_board.reload_meta_data()
# -------------------------------- Public function --------------------------------
def pop_search(self):
text, ok = QInputDialog.getText(self, 'Search', 'Enter search text:')
if ok:
self.search_tree(text)
def search_tree(self, text: str):
root_node = self.__req_data_agent.get_req_root()
filter_nodes = root_node.filter(partial(RequirementUI.__find_node_any_data, text))
default_index_window = self.sub_window_index['default']
default_index_window.clear_index()
for node in filter_nodes:
default_index_window.append_index(node.get_title(), node.get_uuid())
default_index_window.show_right_bottom()
def jump_by_id(self, node_id: str):
root_node = self.__req_data_agent.get_req_root()
find_node = root_node.filter(lambda x: x.get_uuid() == node_id)
if len(find_node) > 0:
self.jump_to_node(find_node[0])
def jump_to_node(self, node: ReqNode):
index = self.__req_model.index_of_node(node)
self.__tree_requirements.expand(index)
self.__tree_requirements.scrollTo(index)
self.__tree_requirements.setCurrentIndex(index)
def get_plugin(self) -> PluginManager:
# Workaround
return plugin_manager
def get_selected(self) -> Tuple[QModelIndex, ReqNode]:
return self.__selected_index, self.__selected_node
def toast_status(self, text: str, time_ms: int = 3000):
self.status_bar.showMessage(text, time_ms)
def update_status(self, text: str):
self.status_bar_label.setText(text)
def show_tree_in_dialog(self):
# 创建模态对话框
dialog = QDialog(self, Qt.Window)
dialog.setWindowTitle("Requirements Meta Statistics")
dialog.setMinimumSize(1024, 768)
dialog.setModal(True) # 设置为模态对话框
# 创建布局并添加QTreeView
layout = QVBoxLayout(dialog)
layout.addWidget(self.__tree_requirements)
# 调整对话框大小以适应内容
dialog.resize(self.__tree_requirements.sizeHint())
# 显示对话框
dialog.exec_()
# 对话框关闭后,将QTreeView重新放回QDockWidget
self.dock_tree_requirements.setWidget(self.__tree_requirements)
def do_expand_node_all(self, index):
if index.isValid():
self.__tree_requirements.expand(index)
for i in range(self.__tree_requirements.model().rowCount(index)):
self.do_expand_node_all(self.__tree_requirements.model().index(i, 0, index))
def do_collapse_node_all(self, index):
if index.isValid():
self.__tree_requirements.collapse(index)
for i in range(self.__tree_requirements.model().rowCount(index)):
self.do_collapse_node_all(self.__tree_requirements.model().index(i, 0, index))
# --------------------------------------------------------
@staticmethod
def __find_node_any_data(text: str, node: ReqNode) -> bool:
for v in node.data().values():
if isinstance(v, str) and text in v:
return True
return False
def __tree_item_selected(self) -> bool:
return self.__selected_index is not None and \
self.__selected_index.isValid() and \
self.__selected_node is not None
def __update_selected_index(self, index: QModelIndex or None):
if index is not None and index.isValid():
req_node: ReqNode = index.internalPointer()
self.__selected_node = req_node
self.__selected_index = index
self.edit_board.edit_req(req_node)
else:
self.__selected_node = None
self.__selected_index = None
self.edit_board.edit_req(None)
def __on_meta_data_updated(self):
self.edit_board.on_meta_data_updated()
# ---------------------------------------------------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
req_agent = ReqSingleJsonFileAgent()
req_agent.init()
if easy_config is not None and plugin_manager is not None:
plugins = easy_config.get('plugin', [])
for plugin in plugins:
plugin_manager.load_plugin(plugin)
for plugin in plugin_manager.plugins.keys():
print(f'Loaded plugin: {plugin}')
plugin_manager.invoke_all('req_agent_prepared', req_agent)
w = RequirementUI(req_agent)
if not req_agent.open_req('FreeReq'):
req_agent.new_req('FreeReq', True)
print('Current path: ' + os.getcwd())
w.show()
sys.exit(app.exec_())
# ----------------------------------------------------------------------------------------------------------------------
if __name__ == "__main__":
try:
main()
except Exception as e:
print('Error =>', e)
print('Error =>', traceback.format_exc())
exit()
finally:
pass
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。