From e575f95965c7ef6a254f718bc137cd5e74d5cec9 Mon Sep 17 00:00:00 2001 From: Isaak Date: Tue, 16 Jan 2024 01:08:13 +0100 Subject: [PATCH] initial commit --- main.py | 665 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..7026625 --- /dev/null +++ b/main.py @@ -0,0 +1,665 @@ +""" +Markdown Editor Application using PyQt6. + +Key Features: +- Create, edit, and preview Markdown files. +- Live HTML preview of Markdown content. +- Configurable settings stored in YAML. + +Dependencies: PyQt6, yaml, markdown2. + +Author: Isaak Buslovich +Date: 2024-01-13 +Version: 0.1 +""" +import pathlib +import platform +import sys +import webbrowser +import yaml +from PyQt6.QtCore import QFileSystemWatcher, Qt +from PyQt6.QtGui import QAction, QStandardItem, QStandardItemModel, QIcon, QPalette +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWebEngineCore import QWebEngineSettings +from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit, QHBoxLayout, QWidget, QToolBar, QStatusBar, \ + QMessageBox, QTreeView, QFileDialog, QAbstractItemView, QLineEdit, QVBoxLayout +from markdown2 import markdown +from fuzzywuzzy import process, fuzz + + +class MarkdownEditor(QMainWindow): + """ + A PyQt6-based Markdown editor with file navigation, text editing, and live HTML preview. + + Attributes: + config_path (pathlib.Path): Configuration file path. + config (dict): Application configuration. + fileSystemWatcher (QFileSystemWatcher): Monitors file system changes. + dataDirectory (pathlib.Path): Data directory path. + directoryTree (QTreeView): File navigation widget. + editor (QTextEdit): Markdown text editor. + preview (QWebEngineView): Live HTML preview widget. + """ + + def __init__(self): + """ Initializes the Markdown editor application with UI setup and configuration management. """ + super().__init__() + self.dataDirectory = None + self.preview = None + self.editor = None + self.directoryTree = None + self.config_path = pathlib.Path(__file__).parent / 'config.yaml' + self.toolbar = None + if not self.config_path.exists(): + self.config = {'window': {'width': 800, 'height': 600}, + 'dataDirectory': str(pathlib.Path(__file__).parent.resolve())} + self.save_config() + else: + self.config = self.load_config() + self.fileSystemWatcher = QFileSystemWatcher() + self.initialize_data_directory() + self.initialize_ui() + self.update_directory_tree_view() + self.fileSystemWatcher.directoryChanged.connect(self.update_directory_tree_view) + self.load_initial_file() + + def initialize_ui(self): + """ Sets up the application's user interface components. """ + self.setWindowTitle("Markdown Editor") + width = self.config['window']['width'] + height = self.config['window']['height'] + self.setGeometry(100, 100, width, height) + icon_path = self.dataDirectory / 'data' / 'smile.png' + self.setWindowIcon(QIcon(str(icon_path))) + + # Create the directory tree view + self.directoryTree = QTreeView() + self.directoryTree.setHeaderHidden(True) + self.directoryTree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.directoryTree.doubleClicked.connect(self.on_file_double_clicked) + + # Create the QLineEdit widget for the search box + self.searchBox = QLineEdit() + self.searchBox.setPlaceholderText("Search...") # Placeholder text for the search box + self.searchBox.textChanged.connect(self.on_search_text_changed) # Connect to search handler + + # Create a QVBoxLayout for the directory tree and search box + directory_layout = QVBoxLayout() + directory_layout.addWidget(self.searchBox) # Add the search box to the layout + directory_layout.addWidget(self.directoryTree) # Add the directory tree below the search box + + # Text editor setup + self.editor = QTextEdit() + self.editor.textChanged.connect(self.update_preview) + + # Preview setup + self.preview = QWebEngineView() + + web_engine_settings = self.preview.page().settings() + web_engine_settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True) + web_engine_settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True) + + # Main layout setup + layout = QHBoxLayout() + layout.addLayout(directory_layout, 0) # Add the QVBoxLayout with the search box and directory tree + layout.addWidget(self.editor, 1) + layout.addWidget(self.preview, 1) + + # Set the main layout + container = QWidget() + container.setLayout(layout) + self.setCentralWidget(container) + + # Toolbar, menu bar, and status bar setup + self.create_tool_bar() + self.create_menu_bar() + self.setStatusBar(QStatusBar(self)) + + + def initialize_data_directory(self): + """ Initializes the data directory and sets up the file system watcher. """ + data_directory = self.config['dataDirectory'] + self.dataDirectory = pathlib.Path(data_directory).resolve() + if not self.dataDirectory.is_dir(): + QMessageBox.critical(self, "Directory Error", "The application's directory is not valid.") + sys.exit(1) + self.fileSystemWatcher.addPath(str(self.dataDirectory)) + + + def on_search_text_changed(self, text): + """ + Handles the search functionality. + First, tries an exact match. If no results are found, uses fuzzy matching and gradually increases fuzziness. + """ + if not self.dataDirectory or not self.dataDirectory.is_dir(): + return + model = QStandardItemModel() + self.directoryTree.setModel(model) + try: + entries = list(self.dataDirectory.rglob('*')) + filtered_entries = [] + if text: + filtered_entries = [entry for entry in entries if text.lower() in entry.name.lower()] + fuzziness = 100 + while not filtered_entries and fuzziness > 0: + matches = [(entry, fuzz.partial_ratio(text.lower(), entry.name.lower())) + for entry in entries] + filtered_entries = [entry for entry, score in matches if score >= fuzziness] + if len(filtered_entries) >= 2: + break + fuzziness -= 5 + for entry in filtered_entries: + self.add_item_to_model(model, entry, is_dir=entry.is_dir()) + except Exception as e: + QMessageBox.critical(self, "Search Error", f"An error occurred during the search: {e}") + + + + def add_item_to_model(self, model, entry, is_dir=False): + """ + Adds an item to the directory tree model. + + Args: + model (QStandardItemModel): The model of the directory tree. + entry (Path): The file or directory path. + is_dir (bool): Indicates if the entry is a directory. + """ + item = QStandardItem(entry.name) + item.setData(str(entry)) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEnabled) + if is_dir: + item.setIcon(QIcon('icons/folder_icon.png')) + model.appendRow(item) + + + @staticmethod + def get_config_path(): + """ + Get the path to the configuration file based on the operating system. + + Returns: + Path: The path to the configuration file. + """ + app_name = "YourApp" + home = pathlib.Path.home() + if platform.system() == "Windows": + config_path = pathlib.Path(home, "AppData", "Roaming", app_name, "config.yaml") + elif platform.system() == "Darwin": + config_path = pathlib.Path(home, "Library", "Application Support", app_name, "config.yaml") + else: + config_path = pathlib.Path(home, ".config", app_name, "config.yaml") + config_path.parent.mkdir(parents=True, exist_ok=True) + return config_path + + config_file = get_config_path() + + def closeEvent(self, event): + """ + Handle the close event of the application. Save the configuration before closing. + + Args: + event (QCloseEvent): The close event. + """ + self.config['window']['width'] = self.size().width() + self.config['window']['height'] = self.size().height() + self.config['dataDirectory'] = str(self.dataDirectory) + self.save_config() + super().closeEvent(event) + + def load_config(self): + """ + Load the configuration from the configuration file. + + Returns: + dict: The loaded configuration. + """ + with open(self.config_path) as file: + return yaml.safe_load(file) + + def save_config(self): + """ + Save the configuration to the configuration file. + """ + with open(self.config_path, 'w') as file: + yaml.dump(self.config, file) + + def load_initial_file(self): + """ + Load an initial Markdown file for editing. This method looks for a 'cheatsheet.md' file in the + application's data directory and loads it into the editor if it exists. + """ + cheatsheet_path = self.dataDirectory / 'cheatsheet.md' + if cheatsheet_path.is_file(): + with open(cheatsheet_path, encoding='utf-8') as file: + markdown_content = file.read() + self.editor.setText(markdown_content) + self.update_preview() + else: + QMessageBox.warning(self, "File Not Found", f"Cheatsheet file not found: {cheatsheet_path}") + + def on_file_double_clicked(self, index): + """ + Loads a Markdown file into the editor on double-click in the directory tree. + + Args: + index (QModelIndex): Index of the clicked item in the directory tree. + """ + try: + file_path = pathlib.Path(index.model().itemFromIndex(index).data()) + if not file_path.exists(): + QMessageBox.critical(self, "Error", f"The item does not exist: {file_path}") + return + if not file_path.is_file(): + QMessageBox.critical(self, "Error", "The selected item is not a file.") + return + with open(file_path, encoding='utf-8') as file: + markdown_content = file.read() + self.editor.setText(markdown_content) + self.update_preview() + except Exception as e: + QMessageBox.critical(self, "File Open Error", f"An error occurred: {e}") + + def create_menu_bar(self): + """ + Create the menu bar for the application. It adds various menus like File, Edit, View, Window, and Help + with their respective actions. + """ + menu_bar = self.menuBar() + file_menu = menu_bar.addMenu("&File") + self.add_actions_to_menu(file_menu, [ + ("&New", self.new_file), + ("&Open", self.open_file), + ("&Save", self.save_entry), + ("Open Data Directory", self.open_data_directory), + ("Change Data Directory", self.change_data_directory), + ("Theme", self.change_theme), + ("Settings", self.open_settings), + ("E&xit", self.close) + ]) + + edit_menu = menu_bar.addMenu("&Edit") + self.add_actions_to_menu(edit_menu, [ + ("Undo", self.undo_action), + ("Redo", self.redo_action) + ]) + + view_menu = menu_bar.addMenu("&View") + self.add_actions_to_menu(view_menu, [ + ("Toggle Split View Mode", self.toggle_split_view), + ("Toggle Toolbar", self.toggle_toolbar) + ]) + + window_menu = menu_bar.addMenu("&Window") + self.add_actions_to_menu(window_menu, [ + ("Close", self.close), + ("Minimize", self.minimize), + ("Search", self.search) + ]) + + help_menu = menu_bar.addMenu("&Help") + self.add_actions_to_menu(help_menu, [ + ("&About", self.about), + ("View Cheatsheet", self.view_cheatsheet), + ("GitHub", self.open_git_hub) + ]) + + def add_actions_to_menu(self, menu, actions): + """ + Add actions to a given menu. + + Args: + menu (QMenu): The menu to which actions will be added. + actions (list of tuples): Each tuple contains the text for the menu item and the handler function. + """ + for action_text, action_handler in actions: + action = QAction(action_text, self) + action.triggered.connect(action_handler) + menu.addAction(action) + + def create_tool_bar(self): + """ + Create the main toolbar for the application. It adds actions like New, Open, and Save with icons. + """ + self.toolbar = QToolBar("Main Toolbar") + self.addToolBar(self.toolbar) + self.add_actions_to_toolbar(self.toolbar, [ + ("New", "📄", self.new_file), + ("Open", "📂", self.open_file), + ("Save", "💾", self.save_entry) + ]) + + def add_actions_to_toolbar(self, toolbar, actions): + """ + Add actions to a given toolbar. + """ + for name, emoji, handler in actions: + action = QAction(emoji + ' ' + name, self) + action.triggered.connect(handler) + toolbar.addAction(action) + + @staticmethod + def markdown_table_to_html(markdown_content): + """ + Convert Markdown tables in the given content to HTML table format. + + Args: + markdown_content (str): The Markdown content containing tables. + + Returns: + str: The HTML content with Markdown tables converted to HTML tables. + """ + lines = markdown_content.split('\n') + html_lines = [] + in_table = False + + for line in lines: + if '|' in line: + if not in_table: + html_lines.append('') + in_table = True + columns = line.split('|')[1:-1] + if '---' in columns[0]: + html_lines.append('' + ''.join(''.format(col.strip()) for col in columns) + '') + else: + html_lines.append('' + ''.join(''.format(col.strip()) for col in columns) + '') + else: + if in_table: + html_lines.append('
{}
{}
') + in_table = False + html_lines.append(line) + + if in_table: + html_lines.append('') + + return '\n'.join(html_lines) + + @staticmethod + def convert_image_paths(html_content, base_directory): + """ + Converts relative image paths in the HTML content to absolute file URLs. + + Args: + html_content (str): The HTML content with Markdown images. + base_directory (Path): The base directory where the 'data' folder is located. + + Returns: + str: Modified HTML content with absolute image paths. + """ + image_folder = base_directory / 'data' + + def replace_path(match): + """ + Replaces a matched image path with an absolute URL. + + Args: + match (MatchObject): Regex match object containing the image path. + + Returns: + str: The image URL with the absolute path. + """ + image_relative_path = match.group(1) + image_absolute_path = image_folder / image_relative_path + return f'src="{image_absolute_path.as_uri()}"' + + import re + html_content = re.sub(r'src="([^"]+)"', replace_path, html_content) + return html_content + + def update_preview(self): + """ Updates the HTML preview based on the current Markdown content. """ + markdown_content = self.editor.toPlainText() + html_content = markdown(markdown_content) + html_content = self.markdown_table_to_html(html_content) + data_directory = pathlib.Path(__file__).parent.resolve() + html_content = self.convert_image_paths(html_content, data_directory) + html_content = html_content.replace('[x]', '✅') + html_content = html_content.replace('[ ]', '❌') + palette = QApplication.instance().palette() + background_color = palette.color(QPalette.ColorRole.Window).name() + foreground_color = palette.color(QPalette.ColorRole.WindowText).name() + accent_color = "#9f74b3" + css = f""" + + """ + html_with_css = css + '
' + html_content + '
' + self.preview.setHtml(html_with_css) + + def update_directory_tree_view(self): + """ Refreshes the directory tree view with files and directories. """ + if not self.dataDirectory or not self.dataDirectory.is_dir(): + return + model = QStandardItemModel() + self.directoryTree.setModel(model) + self.populate_model(model, self.dataDirectory) + + def populate_model(self, parent_item, directory, depth=0): + """ + Populates the directory tree model with directory contents. + + Args: + parent_item (QStandardItem): Item to populate. + directory (pathlib.Path): Directory to scan. + depth (int): Depth level for recursive scanning. + """ + if depth > 5: + return + try: + for entry in sorted(directory.iterdir(), key=lambda e: (e.is_file(), e.name.lower())): + if entry.is_dir(): + if any(file.suffix == '.md' for file in entry.glob('*.md')): + dir_item = QStandardItem(entry.name) + dir_item.setData(str(entry)) + dir_item.setFlags(dir_item.flags() | Qt.ItemFlag.ItemIsEnabled) + dir_item.setIcon(QIcon('icons/folder_icon.png')) + parent_item.appendRow(dir_item) + self.populate_model(dir_item, entry, depth + 1) + elif entry.suffix == '.md': + file_item = QStandardItem(entry.name) + file_item.setData(str(entry)) + parent_item.appendRow(file_item) + except PermissionError: + pass + + def on_directory_tree_clicked(self, index): + """ + Handle clicks on the directory tree. It loads the selected file into the editor if it is a Markdown file. + + Args: + index (QModelIndex): The model index of the clicked item in the directory tree. + """ + file_path = pathlib.Path(index.data()) + if file_path.is_file(): + self.load_file(file_path) + + def load_file(self, file_path): + """ + Loads the content of a file into the text editor. + + Args: + file_path (pathlib.Path): The path of the file to load. + """ + if not file_path.is_file(): + QMessageBox.critical(self, "File Open Error", "The selected file does not exist.") + return + + try: + with open(file_path, encoding='utf-8') as file: + markdown_content = file.read() + except OSError as e: + QMessageBox.critical(self, "File Open Error", f"Error opening file: {e}") + return + + self.editor.setText(markdown_content) + self.update_preview() + + def new_file(self): + """ + Clear the editor to create a new file. This action empties the text editor for writing new content. + """ + self.editor.clear() + + def open_file(self): + """ + Open a file dialog to select a Markdown file. This method allows the user to select a Markdown file from + the file system and loads its content into the editor. + """ + filename, _ = QFileDialog.getOpenFileName( + self, + "Open Markdown File", + str(self.dataDirectory), + 'Markdown Files (*.md)') + if not filename: + return + try: + with open(filename, encoding='utf-8') as file: + markdown_content = file.read() + except OSError as e: + QMessageBox.critical(self, "File Open Error", f"Error opening file: {e}") + return + self.editor.setText(markdown_content) + self.update_preview() + + def save_entry(self): + """ + Save the current entry. This method is intended to save the content of the text editor to a file. + """ + pass + + def about(self): + """ + Displays an 'About' dialog with information about the Markdown Editor. + """ + QMessageBox.about(self, "About Markdown Editor", "Markdown Editor\nVersion 1.0") + + def open_data_directory(self): + """ Opens the application's data directory in the system's default file explorer. """ + try: + directory_url = pathlib.Path(self.dataDirectory).resolve().as_uri() + webbrowser.open(directory_url) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to open directory: {e}") + + def change_data_directory(self): + """ + Opens a dialog to select a new data directory for the application. + """ + new_data_directory = QFileDialog.getExistingDirectory(self, "Select Data Directory", str(self.dataDirectory)) + if new_data_directory: + new_path = pathlib.Path(new_data_directory) + if new_path.is_dir(): + self.dataDirectory = new_path + self.update_directory_tree_view() + else: + QMessageBox.critical(self, "Directory Selection Error", "Invalid directory path") + + def change_theme(self): + pass + + def open_settings(self): + pass + + def undo_action(self): + pass + + def redo_action(self): + pass + + def toggle_split_view(self): + """ Toggles the visibility of the preview pane. """ + self.preview.setVisible(not self.preview.isVisible()) + self.centralWidget().layout().activate() + + def toggle_toolbar(self): + """ Toggle the visibility of the toolbar. """ + if self.toolbar: + self.toolbar.setVisible(not self.toolbar.isVisible()) + + def minimize(self): + """ Minimize the main window. """ + self.showMinimized() + + def search(self): + pass + + def view_cheatsheet(self): + """ Load and display the cheatsheet markdown file. """ + cheatsheet_path = self.dataDirectory / 'cheatsheet.md' + if cheatsheet_path.is_file(): + self.load_file(cheatsheet_path) + else: + QMessageBox.warning(self, "Cheatsheet Not Found", f"Cheatsheet file not found: {cheatsheet_path}") + + def open_git_hub(self): + pass + + +def main(): + """ Main function to run the Markdown Editor application. """ + app = QApplication(sys.argv) + editor = MarkdownEditor() + editor.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main()