diff --git a/.github/workflows/mac_build.yml b/.github/workflows/mac_build.yml index b6a9fcf..d0100e6 100644 --- a/.github/workflows/mac_build.yml +++ b/.github/workflows/mac_build.yml @@ -10,7 +10,7 @@ on: jobs: build: - runs-on: macos-13 + runs-on: macos-12 steps: - uses: actions/checkout@v3 name: Check out the repository code @@ -32,15 +32,25 @@ jobs: - name: Build the executable with PyInstaller run: | pyinstaller --noconfirm --onedir --windowed --icon "./cmdcompass/static/icon.icns" --name "cmdCompass" \ - --add-data "./cmdcompass/data/man_pages:data/man_pages" \ + --add-data "./cmdcompass/data/man_pages:data" \ --add-data "./cmdcompass/static:static" \ --add-data "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/customtkinter:customtkinter" \ --collect-all "tkinterweb" \ --distpath "./dist" \ "./cmdcompass/main.py" + - name: Reorganize Files + run: | + mv dist/cmdCompass/_internal/static dist/ + mv dist/cmdCompass/_internal/data dist/ + rm -rf dist/cmdCompass/ + + - name: Create tar.gz Archive + run: | + tar -czvf dist/cmdCompass.tar.gz -C dist cmdCompass.app static data + - name: Upload Artifacts uses: actions/upload-artifact@v3 with: - name: executable - path: dist/* + name: cmdCompass-artifacts + path: dist/cmdCompass.tar.gz \ No newline at end of file diff --git a/.gitignore b/.gitignore index a39f803..dba4934 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ cmdcompass/data/man_pages/html_download cmdcompass/data/man_pages/man cmdcompass/data/man_pages/tmp build/ -dist/ \ No newline at end of file +dist/ +*.spec \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6561c41..089a537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,14 @@ * Add a button to create a command for the current selected collection. * Add extract_options() for command model class to extract options(with -- or -) as a list. * Add unit tests for extract_options() and get_template_variables() for the command model class. +* Add GitHub Action to build executable on Mac ### CHANGED -* Use static images for some buttons +* Use static images for some buttons. +* GitHub Action for Mac build now build on MACOS 12 to support for more systems. ### DEBUGGED +* Fix `extract_options` that was unable to identify options start with `--` but contains `-` like `--convert-links`. ## Version 0.1.0 (Initial Development) diff --git a/cmdcompass/datamanager.py b/cmdcompass/datamanager.py index 7d716f0..053d168 100644 --- a/cmdcompass/datamanager.py +++ b/cmdcompass/datamanager.py @@ -4,9 +4,17 @@ from cmdcompass.models.collection import Collection from cmdcompass.models.command import Command from cmdcompass.models.tag import Tag - +from cmdcompass.utils.utils import get_current_working_dir +import sys +import os + +if getattr(sys, 'frozen', False): + BASE_DIR = get_current_working_dir() +else: + BASE_DIR = "." +DATA_FILE = os.path.join(BASE_DIR, "data", "data.json") class DataManager: - def __init__(self, data_file="./data/data.json"): + def __init__(self, data_file=DATA_FILE): self.data_file = data_file self.data: Dict[str, Collection] = {} # {collection_name: Collection object} self.load_data() diff --git a/cmdcompass/gui/commandbodybox.py b/cmdcompass/gui/commandbodybox.py index 40e7d30..b0d0a0d 100644 --- a/cmdcompass/gui/commandbodybox.py +++ b/cmdcompass/gui/commandbodybox.py @@ -49,7 +49,8 @@ def save_command(self): self.save_button.configure(state="disabled", fg_color="gray") self.main_window.refresh_command_list() # Refresh the command list self.main_window.utility_box.set_command(self.main_window.selected_command) - self.main_window.man_page_box.set_man_page(self.main_window.selected_command) + if self.main_window.active_tab == "man_page": + self.main_window.man_page_box.set_man_page(self.main_window.selected_command) def copy_command(self): command_str = self.command_textbox.get("1.0", "end-1c") diff --git a/cmdcompass/gui/manpagebox.py b/cmdcompass/gui/manpagebox.py index f4c911a..9989349 100644 --- a/cmdcompass/gui/manpagebox.py +++ b/cmdcompass/gui/manpagebox.py @@ -1,17 +1,22 @@ import customtkinter as ctk from tkinterweb import HtmlFrame import os -from cmdcompass.utils.utils import get_command_name, highlight_options +from cmdcompass.utils.utils import get_command_name, highlight_options, get_current_working_dir from cmdcompass.man_parser.loader import download_and_process_package from cmdcompass.man_parser.html_coverter import OUTPUT_DIR from cmdcompass.gui.progresswindow import ProgressWindow from tkinter import ttk +import sys -HTML_CORE_DIR = os.path.join('.', 'data', 'man_pages', 'html_core') +if getattr(sys, 'frozen', False): + BASE_DIR = get_current_working_dir() +else: + BASE_DIR = "." +HTML_CORE_DIR = os.path.join(BASE_DIR, 'data', 'man_pages', 'html_core') class ManPageBox(ctk.CTkFrame): - def __init__(self, master, dark_theme_enabled=False, **kwargs): + def __init__(self, master, **kwargs): super().__init__(master, **kwargs) self.grid_rowconfigure(1, weight=1) self.grid_columnconfigure(0, weight=1) @@ -52,6 +57,7 @@ def set_man_page(self, command): else: def download_and_update(): progress_window = self.create_progress_window() + progress_window.update_progress(HTML_CORE_DIR) progress_window.update_progress(f"Downloading {command_name}...") download_and_process_package(command_name, progress_window) # After download and processing is complete, update the HTML @@ -70,7 +76,7 @@ def download_and_update(): print(f"Error getting man page: {e}") self.html_content = html_content self.options = command.extract_options() - self.apply_with_highlight() + self.change_theme() if self.options: self.highlight_switch.grid(row=0, column=0, pady=(10, 0), sticky="w") else: diff --git a/cmdcompass/main.py b/cmdcompass/main.py index 26dd614..cd6c0a4 100644 --- a/cmdcompass/main.py +++ b/cmdcompass/main.py @@ -1,10 +1,20 @@ from cmdcompass.gui.main_window import MainWindow +from cmdcompass.utils.utils import get_current_working_dir import os +import platform if __name__ == "__main__": app = MainWindow() app.lift() app.attributes('-topmost', True) app.after_idle(app.attributes, '-topmost', False) - app.iconbitmap(os.path.join(".", "static", "icon.ico")) + if platform.system() == "Windows": + icon_path = os.path.join(".", "static", "icon.ico") + elif platform.system() == "Darwin": # Darwin is the system name for macOS + icon_path = os.path.join(get_current_working_dir(), "static", "icon.icns") + else: + icon_path = None + + if icon_path: + app.iconbitmap(icon_path) app.mainloop() \ No newline at end of file diff --git a/cmdcompass/man_parser/html_coverter.py b/cmdcompass/man_parser/html_coverter.py index 0656e39..9c09069 100644 --- a/cmdcompass/man_parser/html_coverter.py +++ b/cmdcompass/man_parser/html_coverter.py @@ -1,9 +1,17 @@ import os import subprocess import platform +import sys +from cmdcompass.utils.utils import get_current_working_dir +import shutil +from pathlib import Path -WINDOWS_GROFF_BIN = os.path.join('.', 'data', 'bin', 'groff-1.22.4-w32-bin', 'bin', 'groff.exe') -OUTPUT_DIR = os.path.join('.', 'data', 'man_pages', 'html_download') +if getattr(sys, 'frozen', False): + BASE_DIR = get_current_working_dir() +else: + BASE_DIR = "." +WINDOWS_GROFF_BIN = os.path.join(BASE_DIR, 'data', 'bin', 'groff-1.22.4-w32-bin', 'bin', 'groff.exe') +OUTPUT_DIR = os.path.join(BASE_DIR, 'data', 'man_pages', 'html_download') CREATE_NO_WINDOW = 0x08000000 # To used on Windows to not open any terminal window while using subprocess def convert_man_pages(man_file, output_dir=OUTPUT_DIR): @@ -16,7 +24,19 @@ def convert_man_pages(man_file, output_dir=OUTPUT_DIR): if platform.system() == 'Windows': groff_command = [WINDOWS_GROFF_BIN, '-mandoc', '-Thtml', man_file] else: - groff_command = ['groff', '-mandoc', '-Thtml', man_file] + # Try to locate 'groff' using 'which' command + # As per https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html#macos, + # If the app is run from Finder, the app may have a reduced set of PATH vars, which may not include groff + groff_path = shutil.which('groff') + if not groff_path: + # Fallback to common paths if not found + for path in ['/usr/local/bin/groff', '/opt/homebrew/bin/groff']: + if Path(path).exists(): + groff_path = path + break + if groff_path is None: + raise FileNotFoundError("The 'groff' command is not available in the system PATH.") + groff_command = [groff_path, '-mandoc', '-Thtml', man_file] base_name = os.path.basename(man_file) # Get the file name without the directory path name_without_ext = os.path.splitext(base_name)[0] # Remove extension diff --git a/cmdcompass/man_parser/loader.py b/cmdcompass/man_parser/loader.py index d5e0712..2d6b45a 100644 --- a/cmdcompass/man_parser/loader.py +++ b/cmdcompass/man_parser/loader.py @@ -7,14 +7,20 @@ import re from sqlitedict import SqliteDict from cmdcompass.man_parser.html_coverter import convert_man_pages +from cmdcompass.utils.utils import get_current_working_dir import platform +import sys ARCH = "binary-amd64" DEB_PACKAGE_URL = f"https://ftp.debian.org/debian/dists/Debian12.5/main/{ARCH}/Packages.gz" -UNPACKING_DIR = os.path.join('.', 'data', 'man_pages', 'tmp') -MAN_PAGE_DIR = os.path.join('.', 'data', 'man_pages', 'man') -KV_DB_PATH = os.path.join('.', 'data', 'man_pages_kv.db') +if getattr(sys, 'frozen', False): + BASE_DIR = get_current_working_dir() +else: + BASE_DIR = "." +UNPACKING_DIR = os.path.join(BASE_DIR, 'data', 'man_pages', 'tmp') +MAN_PAGE_DIR = os.path.join(BASE_DIR, 'data', 'man_pages', 'man') +KV_DB_PATH = os.path.join(BASE_DIR, 'data', 'man_pages_kv.db') DEB_DOWNLOAD_LINK = "http://ftp.ca.debian.org/debian" @@ -23,7 +29,6 @@ def extract_deb(data_path, extract_to): print(f"Extracting DEB package: {data_path} to {extract_to}") if not os.path.exists(extract_to): os.makedirs(extract_to) - # Open the DEB file as an AR archive with arpy.Archive(data_path) as archive: archive.read_all_headers() @@ -105,10 +110,11 @@ def download_and_process_package(package_name, progress_window): # Ensure destination folders exist if not os.path.exists(UNPACKING_DIR): os.makedirs(UNPACKING_DIR) - + progress_window.update_progress(f"read from {KV_DB_PATH}") with SqliteDict(KV_DB_PATH) as db: if package_name in db: try: + progress_window.update_progress(f"found") # Note the db value can contain a list of package that includes the command man page. # The value is in tuple of ("package_link", #number of man pages in this package) and ordered by the #number # We can have a feature to allow user to choose which package to use diff --git a/cmdcompass/models/command.py b/cmdcompass/models/command.py index a7b0c1a..8779343 100644 --- a/cmdcompass/models/command.py +++ b/cmdcompass/models/command.py @@ -31,15 +31,14 @@ def replace_var(match): return re.sub(pattern, replace_var, self.command_str) def extract_options(self): - command = self.command_str options = set() # Split the command into parts respecting quotes - parts = shlex.split(command) + parts = shlex.split(self.command_str) for part in parts: # Handle long-form options starting with -- if part.startswith('--'): - match = re.match(r'--(\w+)', part) + match = re.match(r'--([\w-]+)', part) if match: options.add(match.group(1)) @@ -51,3 +50,4 @@ def extract_options(self): options.update(match.group(1)) return options + diff --git a/cmdcompass/utils/utils.py b/cmdcompass/utils/utils.py index 5552148..c093e72 100644 --- a/cmdcompass/utils/utils.py +++ b/cmdcompass/utils/utils.py @@ -2,8 +2,12 @@ import os from PIL import Image import customtkinter as ctk +import sys -IMG_DIR = os.path.join(".", "static") +from pathlib import Path +import platform + +IMG_DIR = "static" def get_command_name(command_str): # Regular expression to match environment variables with optional quotes around the value @@ -19,8 +23,25 @@ def get_command_name(command_str): raise ValueError(f"Unable to find the command name for {command_str}") +def get_current_working_dir(): + # Get the working dir when app is built as an executable. + if getattr(sys, 'frozen', False) and platform.system() == "Darwin": + # sys.executable in a PyInstaller bundle on macOS points to the MacOS executable + # This resolves to MyApp.app/Contents/MacOS/MyApp. + # So when user lick on Myapp.app, the real executable is 3 directory down. + app_path = Path(sys.executable).resolve() + # Navigate three levels up to get to the .app's parent directory + application_root = app_path.parents[3] + else: + # If running as a normal script + application_root = "." + + return application_root + + def load_ctk_image(image_file_name, **kwargs): - image_path = os.path.join(IMG_DIR, image_file_name) + img_dir = os.path.join(get_current_working_dir(), IMG_DIR) + image_path = os.path.join(img_dir, image_file_name) return ctk.CTkImage(light_image=Image.open(image_path), **kwargs) @@ -33,17 +54,17 @@ def highlight_options(options, html, highlight_class="highlight"): for option in options: if len(option) == 1: - # For single-letter options, expect them to be followed by a specific character like "<" - pattern = r'(-{})<'.format(re.escape(option)) + # For single-letter options, match both '-' and '−' + pattern = r'([-−]{})<'.format(re.escape(option)) # Replace the pattern in the HTML with a highlighted version using with inline style replacement = r'\1<'.format(highlighted_color) else: - # For multi-letter options, just match the option itself with "--" - pattern = r'(--{})'.format(re.escape(option)) + # For multi-letter options, match both '--' and '−−' and handle hyphens in option names + option_pattern = re.escape(option).replace('-', '[-−]') + pattern = r'([-−][-−]{})'.format(option_pattern) # Replace the pattern in the HTML with a highlighted version using with inline style replacement = r'\1'.format(highlighted_color) highlighted_html = re.sub(pattern, replacement, highlighted_html) return highlighted_html - diff --git a/google312c553fd212bb76.html b/google312c553fd212bb76.html deleted file mode 100644 index 07d7339..0000000 --- a/google312c553fd212bb76.html +++ /dev/null @@ -1 +0,0 @@ -google-site-verification: google312c553fd212bb76.html \ No newline at end of file diff --git a/test/test_command.py b/test/test_command.py index d149852..78fdb33 100644 --- a/test/test_command.py +++ b/test/test_command.py @@ -95,7 +95,7 @@ def test_extract_options_wget(self): comment="Download a website with wget", tag_ids=["tag2"] ) - self.assertEqual(command.extract_options(), {'mirror', 'p', 'convert', 'P'}) + self.assertEqual(command.extract_options(), {'mirror', 'p', 'convert-links', 'P'}) def test_extract_options_docker(self): command = Command(