diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04410bb..a84cc0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,51 +7,28 @@ on: branches: [ main ] jobs: - matrix-test: - name: Python ${{ matrix.python-version }} Matrix Test + test: + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for setuptools_scm - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - - name: Install dependencies + - name: Install package run: | python -m pip install --upgrade pip - pip install pytest beautifulsoup4 ruff - - name: Run ruff + pip install -e ".[dev]" + - name: Lint run: | ruff check . ruff format --check . - - name: Install package - run: pip install -e . - - name: Run tests + - name: Test run: pytest -v - - tox-test: - name: Tox Test with UV - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Required for setuptools_scm - - uses: actions/setup-python@v5 - with: - python-version: '3.13' - cache: 'pip' - - name: Install uv and tox - run: | - python -m pip install --upgrade pip - pip install uv - uv tool install tox --with tox-uv - - name: Run tox with UV - run: | - echo "Current directory: $(pwd)" - ls -la - echo "Content of pyproject.toml:" - cat pyproject.toml - PYTHONPATH=/home/runner/work/mdtk/mdtk tox r --verbose diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index da8b158..1164f31 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,22 +5,46 @@ on: types: [published] jobs: - publish: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 # Required for setuptools_scm - uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.x' cache: 'pip' + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint and test + run: | + ruff check . + ruff format --check . + pytest + + publish: + needs: test + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/mdtk + permissions: + id-token: write # Required for PyPI trusted publishing (OIDC) + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for setuptools_scm + - uses: actions/setup-python@v5 + with: + python-version: '3.x' - name: Build package run: | python -m pip install --upgrade pip - pip install build + pip install build twine python -m build + twine check dist/* - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..138010a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tom McDermott + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index af477e9..a61f120 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,84 @@ Tools for working with markdown files. +[](https://pypi.org/project/mdtk/) +[](https://github.com/tommcd/mdtk/actions/workflows/ci.yml) +[](https://tommcd.github.io/mdtk) +[](https://github.com/tommcd/mdtk/blob/main/LICENSE) + ## Installation ```bash pip install mdtk ``` -## Features +Requires Python 3.10+. + +## Usage + +### Chrome bookmarks to markdown + +1. In Chrome, open the bookmark manager (`chrome://bookmarks`), put the links + you want to export into one folder, and use **Export bookmarks** to save an + HTML file. +2. Convert that folder to a markdown link list: + +```bash +mdtk-bookmarks bookmarks.html links.md --folder "Reading List" +``` + +```console +$ cat links.md +- [GitHub - Where the world builds software](https://github.com) +- [Welcome to Python.org](https://python.org) +``` + +Options: + +- `--folder NAME` - the bookmark folder to extract (default: `EXPORT_FOLDER`) +- `--version` - print the mdtk version and exit + +Behavior notes: + +- Bookmarks inside nested subfolders are included, flattened in document order. +- If several folders share the same name, the first one in the file is used. +- Titles and URLs are escaped so the generated markdown links stay valid. +- If the folder is not found, the error message lists the folders the export + does contain. + +### Python API + +```python +from mdtk import BookmarkError, convert_bookmarks + +count = convert_bookmarks("bookmarks.html", "links.md", folder_name="Reading List") +print(f"exported {count} bookmarks") +``` + +`convert_bookmarks` returns the number of bookmarks written and raises +`BookmarkError` for anything that goes wrong (missing file, unknown folder, +input that is not a bookmarks export, ...). + +## Development + +```bash +git clone https://github.com/tommcd/mdtk.git +cd mdtk +pip install -e ".[dev]" + +pytest # run the tests +ruff check . # lint +ruff format --check . # formatting +tox # everything, on all supported Python versions +``` + +## Links + +- [Documentation](https://tommcd.github.io/mdtk) +- [PyPI package](https://pypi.org/project/mdtk/) +- [Issue tracker](https://github.com/tommcd/mdtk/issues) +- [Changelog](https://github.com/tommcd/mdtk/releases) + +## License -- Chrome bookmarks to markdown converter +[MIT](https://github.com/tommcd/mdtk/blob/main/LICENSE) diff --git a/Setup.md b/Setup.md deleted file mode 100644 index d430ed2..0000000 --- a/Setup.md +++ /dev/null @@ -1,831 +0,0 @@ -# Setup - -Here are the commands to create the basic structure: - -```bash -mkdir -p mdtk/src/mdtk mdtk/tests -touch mdtk/src/mdtk/__init__.py -touch mdtk/src/mdtk/bookmarks.py -touch mdtk/tests/__init__.py -touch mdtk/pyproject.toml -touch mdtk/README.md -touch mdtk/.gitignore -``` - -Set GitHub username as environment variable - -```sh -export GITHUB_USER=tommcd -``` - -Move to project directory if not already there - -```sh -cd mdtk -``` - -Initialize git and create repo - -```sh -git init -gh repo create $GITHUB_USER/mdtk --public --description "Markdown Toolkit - Tools for working with markdown files" -``` - -First, let's create a Python-specific `.gitignore`: - -```bash -cat > .gitignore << 'EOF' -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -.env -.venv -venv/ -ENV/ -.idea/ -.vscode/ -EOF -``` - -Now let's commit and tag: - -```bash -git add . -git commit -m "Initial commit: Basic project structure" -git tag -a v0.1.0 -m "Initial release" -``` - -```bash -# Create and switch to main branch -git branch -M main - -# Add the remote repository (if not already added) -git remote add origin https://github.com/$GITHUB_USER/mdtk.git - -# Now try pushing again -git push -u origin main --tags -``` - -````sh -cat > README.md << 'EOF' -# mdtk (Markdown Toolkit) - -Tools for working with markdown files. - -## Installation - -```bash -pip install mdtk -``` - -## Features - -- Chrome bookmarks to markdown converter -EOF -```` - - - -Let's start with the most minimal GitHub Action that just checks if the package builds. We'll create a simple workflow file: - -```bash -mkdir -p .github/workflows -cat > .github/workflows/ci.yml << 'EOF' -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - name: Build package - run: pip install build && python -m build -EOF -``` - -This is the simplest possible CI setup. Would you like me to explain what each part does before we commit and push it? - - - -```bash -mkdir -p .github/workflows -cat > .github/workflows/ci.yml << 'EOF' -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Build package - run: pip install build && python -m build -EOF -``` - -Would you like to commit and test this version now? - -```sh -git add .github/workflows/ci.yml -git commit -m "Add GitHub Actions CI workflow" -git push origin main -``` - -Let's start with updating the code to work as a proper module. Here's the first step - I'll show you the basic structure for bookmarks.py: - -```bash -cat > src/mdtk/bookmarks.py << 'EOF' -"""Convert Chrome bookmarks to markdown format.""" - -from bs4 import BeautifulSoup -from pathlib import Path -from typing import Optional - -def convert_bookmarks(input_file: Path, output_file: Path, folder_name: Optional[str] = "EXPORT_FOLDER") -> None: - """Convert Chrome bookmarks HTML file to markdown format. - - Args: - input_file: Path to the Chrome bookmarks HTML file - output_file: Path where to save the markdown file - folder_name: Name of the folder to extract (defaults to "EXPORT_FOLDER") - """ - pass # We'll implement this next - -def main(): - """Command line interface.""" - pass # We'll implement this next -EOF -``` - -This gives us the basic structure. Would you like me to explain any part before we implement the functions? - - -Great! Let's implement the `convert_bookmarks` function first. Here's the updated code: - -```bash -cat > src/mdtk/bookmarks.py << 'EOF' -"""Convert Chrome bookmarks to markdown format.""" - -from bs4 import BeautifulSoup -from pathlib import Path -from typing import Optional - -def convert_bookmarks(input_file: Path, output_file: Path, folder_name: Optional[str] = "EXPORT_FOLDER") -> None: - """Convert Chrome bookmarks HTML file to markdown format.""" - with open(input_file, 'r', encoding='utf-8') as f: - soup = BeautifulSoup(f, 'html.parser') - - folders = soup.find_all('h3') - target_folder = None - for folder in folders: - if folder.string == folder_name: - target_folder = folder - break - - if not target_folder: - raise ValueError(f"Folder '{folder_name}' not found!") - - bookmarks_dl = target_folder.find_next('dl') - bookmarks = bookmarks_dl.find_all('a') - - with open(output_file, 'w', encoding='utf-8') as f: - for bookmark in bookmarks: - f.write(f"- [{bookmark.string}]({bookmark['href']})\n") - -def main(): - """Command line interface.""" - pass # We'll implement this next - -if __name__ == '__main__': - main() -EOF -``` - -Would you like to: -1. Add the command-line interface next, or -2. Add some error handling and input validation first? - - -Let's add error handling and input validation. Here's the updated version: - -```bash -cat > src/mdtk/bookmarks.py << 'EOF' -"""Convert Chrome bookmarks to markdown format.""" - -from bs4 import BeautifulSoup -from pathlib import Path -from typing import Optional - -class BookmarkError(Exception): - """Base exception for bookmark conversion errors.""" - pass - -def convert_bookmarks(input_file: Path, output_file: Path, folder_name: Optional[str] = "EXPORT_FOLDER") -> None: - """Convert Chrome bookmarks HTML file to markdown format.""" - # Validate inputs - if not isinstance(input_file, Path): - input_file = Path(input_file) - if not isinstance(output_file, Path): - output_file = Path(output_file) - - # Check if input file exists and is readable - if not input_file.exists(): - raise BookmarkError(f"Input file not found: {input_file}") - if not input_file.is_file(): - raise BookmarkError(f"Not a file: {input_file}") - - # Check if output directory exists and is writable - if not output_file.parent.exists(): - raise BookmarkError(f"Output directory does not exist: {output_file.parent}") - - try: - with open(input_file, 'r', encoding='utf-8') as f: - soup = BeautifulSoup(f, 'html.parser') - except Exception as e: - raise BookmarkError(f"Failed to parse HTML file: {e}") - - # Find target folder - folders = soup.find_all('h3') - target_folder = None - for folder in folders: - if folder.string == folder_name: - target_folder = folder - break - - if not target_folder: - raise BookmarkError(f"Folder '{folder_name}' not found!") - - try: - bookmarks_dl = target_folder.find_next('dl') - if not bookmarks_dl: - raise BookmarkError(f"No bookmarks found in folder '{folder_name}'") - bookmarks = bookmarks_dl.find_all('a') - - with open(output_file, 'w', encoding='utf-8') as f: - for bookmark in bookmarks: - title = bookmark.string or "Untitled" - url = bookmark.get('href', '') - f.write(f"- [{title}]({url})\n") - except Exception as e: - raise BookmarkError(f"Failed to process bookmarks: {e}") - -def main(): - """Command line interface.""" - pass # We'll implement this next - -if __name__ == '__main__': - main() -EOF -``` - -Let's add the command-line interface using argparse. Here's the updated version: - -```bash -cat > src/mdtk/bookmarks.py << 'EOF' -"""Convert Chrome bookmarks to markdown format.""" - -from bs4 import BeautifulSoup -from pathlib import Path -from typing import Optional -import argparse -import sys - -class BookmarkError(Exception): - """Base exception for bookmark conversion errors.""" - pass - -def convert_bookmarks(input_file: Path, output_file: Path, folder_name: Optional[str] = "EXPORT_FOLDER") -> None: - """Convert Chrome bookmarks HTML file to markdown format.""" - # [Previous implementation remains the same] - # Validate inputs - if not isinstance(input_file, Path): - input_file = Path(input_file) - if not isinstance(output_file, Path): - output_file = Path(output_file) - - # Check if input file exists and is readable - if not input_file.exists(): - raise BookmarkError(f"Input file not found: {input_file}") - if not input_file.is_file(): - raise BookmarkError(f"Not a file: {input_file}") - - # Check if output directory exists and is writable - if not output_file.parent.exists(): - raise BookmarkError(f"Output directory does not exist: {output_file.parent}") - - try: - with open(input_file, 'r', encoding='utf-8') as f: - soup = BeautifulSoup(f, 'html.parser') - except Exception as e: - raise BookmarkError(f"Failed to parse HTML file: {e}") - - # Find target folder - folders = soup.find_all('h3') - target_folder = None - for folder in folders: - if folder.string == folder_name: - target_folder = folder - break - - if not target_folder: - raise BookmarkError(f"Folder '{folder_name}' not found!") - - try: - bookmarks_dl = target_folder.find_next('dl') - if not bookmarks_dl: - raise BookmarkError(f"No bookmarks found in folder '{folder_name}'") - bookmarks = bookmarks_dl.find_all('a') - - with open(output_file, 'w', encoding='utf-8') as f: - for bookmark in bookmarks: - title = bookmark.string or "Untitled" - url = bookmark.get('href', '') - f.write(f"- [{title}]({url})\n") - except Exception as e: - raise BookmarkError(f"Failed to process bookmarks: {e}") - -def main(): - """Command line interface.""" - parser = argparse.ArgumentParser( - description="Convert Chrome bookmarks to markdown format" - ) - parser.add_argument( - 'input_file', - help="Chrome bookmarks HTML file" - ) - parser.add_argument( - 'output_file', - help="Output markdown file" - ) - parser.add_argument( - '--folder', - default="EXPORT_FOLDER", - help="Folder name to extract (default: EXPORT_FOLDER)" - ) - - args = parser.parse_args() - - try: - convert_bookmarks(args.input_file, args.output_file, args.folder) - except BookmarkError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == '__main__': - main() -EOF -``` - -Now you can use it from the command line like: -```bash -mdtk-bookmarks bookmarks.html output.md --folder "My Folder" -``` - -I'll show you how to create a minimal test bookmarks file with a known structure: - -```bash -cat > test_bookmarks.html << 'EOF' - - -
-
-
-
-
-
-EOF -``` - -Yes, using a venv is a good practice! And uv is indeed faster. Let's set it up: - -```bash -# Install uv if not already installed -#pip install uv - -# Create and activate a venv using uv -uv venv -source .venv/Scripts/activate - - -# Install package in editable mode -uv pip install -e . -``` - -Would you like me to explain any of these steps before we proceed with testing the package? - - - -Yes, you're absolutely right! Pytest is more modern and provides better features. Let's update our approach: - -First, let's add pytest to our project dependencies in pyproject.toml: - -```bash -cat > pyproject.toml << 'EOF' -[build-system] -requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] -build-backend = "setuptools.build_meta" - -[project] -name = "mdtk" -dynamic = ["version"] -description = "Markdown Toolkit - Tools for working with markdown files" -readme = "README.md" -requires-python = ">=3.8" -license = {text = "MIT"} -dependencies = [ - "beautifulsoup4", -] - -[project.optional-dependencies] -test = [ - "pytest", -] - -[tool.setuptools_scm] -write_to = "src/mdtk/_version.py" - -[project.scripts] -mdtk-bookmarks = "mdtk.bookmarks:main" - -[tool.pytest.ini_options] -testpaths = ["tests"] -EOF -``` - -Now let's create a simpler pytest-style test file: - -```bash -cat > tests/test_bookmarks.py << 'EOF' -from pathlib import Path -import pytest -from mdtk.bookmarks import convert_bookmarks, BookmarkError - -def test_basic_conversion(tmp_path): - """Test basic bookmark conversion with default folder""" - test_html = Path('test_bookmarks.html') - test_md = tmp_path / "output.md" - - convert_bookmarks(test_html, test_md) - - content = test_md.read_text().strip().split('\n') - assert len(content) == 2 - assert '- [GitHub - Where the world builds software](https://github.com)' in content - assert '- [Welcome to Python.org](https://python.org)' in content - -def test_nonexistent_folder(tmp_path): - """Test error handling for non-existent folder""" - test_html = Path('test_bookmarks.html') - test_md = tmp_path / "output.md" - - with pytest.raises(BookmarkError, match="Folder 'NonExistent' not found"): - convert_bookmarks(test_html, test_md, "NonExistent") -EOF -``` - -Would you like me to: -1. Explain the pytest-specific features being used -2. Show how to install and run the tests -3. Add more test cases? - - -Since we're using uv and already have our venv activated, let's install the package with test dependencies and run the tests: - -```bash -# Install package with test dependencies -uv pip install -e ".[test]" - -# Run pytest -pytest -``` - -Ah, I see the issue. The test is failing because the test_bookmarks.html file isn't present in the GitHub Actions environment. We need to create the test file as part of the test setup. Let's modify our test file to create its own test data: - -```bash -cat > tests/test_bookmarks.py << 'EOF' -from pathlib import Path -import pytest -from mdtk.bookmarks import convert_bookmarks, BookmarkError - -@pytest.fixture -def test_bookmarks_file(tmp_path): - """Create a test bookmarks file.""" - bookmarks_content = """ - -
-
-
-
-
-
""" - - test_file = tmp_path / "test_bookmarks.html" - test_file.write_text(bookmarks_content) - return test_file - -def test_basic_conversion(test_bookmarks_file, tmp_path): - """Test basic bookmark conversion with default folder""" - test_md = tmp_path / "output.md" - - convert_bookmarks(test_bookmarks_file, test_md) - - content = test_md.read_text().strip().split('\n') - assert len(content) == 2 - assert '- [GitHub - Where the world builds software](https://github.com)' in content - assert '- [Welcome to Python.org](https://python.org)' in content - -def test_nonexistent_folder(test_bookmarks_file, tmp_path): - """Test error handling for non-existent folder""" - test_md = tmp_path / "output.md" - - with pytest.raises(BookmarkError, match="Folder 'NonExistent' not found"): - convert_bookmarks(test_bookmarks_file, test_md, "NonExistent") -EOF -``` - -Key changes: -1. Added a pytest fixture to create the test file -2. Using tmp_path for all file operations -3. Tests now use the fixture instead of expecting an external file - -Would you like me to explain any part before we commit and try again? - - -Let's update our GitHub Actions workflow to include running the tests. We'll modify the ci.yml file: - -```bash -cat > .github/workflows/ci.yml << 'EOF' -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install package with test dependencies - run: pip install -e ".[test]" - - name: Run tests - run: pytest -v -EOF -``` - -Key changes: -1. Renamed job from 'build' to 'test' -2. Added installation of test dependencies -3. Added pytest run with verbose output - -Would you like to commit and push this to see it in action? - -Here are the commands to commit and push the changes: - -```bash -git add .github/workflows/ci.yml -git commit -m "Update CI workflow to run tests" -git push origin main -``` - -After pushing, we can check the Actions tab on GitHub to see the tests running across all Python versions. Would you like me to explain what to look for in the test results? - -Ah yes, we need to install beautifulsoup4 as well since it's a dependency. Let's update the workflow file to explicitly install all dependencies: - -```bash -cat > .github/workflows/ci.yml << 'EOF' -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest beautifulsoup4 - - name: Install package - run: pip install -e . - - name: Run tests - run: pytest -v -EOF -``` - -The key change is adding the explicit installation of pytest and beautifulsoup4 before installing our package. Would you like to commit and try again? - -```sh -git add tests/test_bookmarks.py .github/workflows/ci.yml -git commit -m "Fix CI: Add test fixtures and explicit dependency installation" -git push origin main -``` - -Let's check both files to make sure they're properly synchronized. First, let's verify the module exports the function correctly. We need to make sure the function name and imports match exactly. - -```bash -cat > src/mdtk/bookmarks.py << 'EOF' -"""Convert Chrome bookmarks to markdown format.""" - -from bs4 import BeautifulSoup -from pathlib import Path -from typing import Optional - -class BookmarkError(Exception): - """Base exception for bookmark conversion errors.""" - pass - -def convert_bookmarks(input_file: Path, output_file: Path, folder_name: Optional[str] = "EXPORT_FOLDER") -> None: - """Convert Chrome bookmarks HTML file to markdown format.""" - # Validate inputs - if not isinstance(input_file, Path): - input_file = Path(input_file) - if not isinstance(output_file, Path): - output_file = Path(output_file) - - # Check if input file exists and is readable - if not input_file.exists(): - raise BookmarkError(f"Input file not found: {input_file}") - if not input_file.is_file(): - raise BookmarkError(f"Not a file: {input_file}") - - # Check if output directory exists and is writable - if not output_file.parent.exists(): - raise BookmarkError(f"Output directory does not exist: {output_file.parent}") - - try: - with open(input_file, 'r', encoding='utf-8') as f: - soup = BeautifulSoup(f, 'html.parser') - except Exception as e: - raise BookmarkError(f"Failed to parse HTML file: {e}") - - # Find target folder - folders = soup.find_all('h3') - target_folder = None - for folder in folders: - if folder.string == folder_name: - target_folder = folder - break - - if not target_folder: - raise BookmarkError(f"Folder '{folder_name}' not found!") - - try: - bookmarks_dl = target_folder.find_next('dl') - if not bookmarks_dl: - raise BookmarkError(f"No bookmarks found in folder '{folder_name}'") - bookmarks = bookmarks_dl.find_all('a') - - with open(output_file, 'w', encoding='utf-8') as f: - for bookmark in bookmarks: - title = bookmark.string or "Untitled" - url = bookmark.get('href', '') - f.write(f"- [{title}]({url})\n") - except Exception as e: - raise BookmarkError(f"Failed to process bookmarks: {e}") - -def main(): - """Command line interface.""" - import argparse - import sys - - parser = argparse.ArgumentParser( - description="Convert Chrome bookmarks to markdown format" - ) - parser.add_argument( - 'input_file', - help="Chrome bookmarks HTML file" - ) - parser.add_argument( - 'output_file', - help="Output markdown file" - ) - parser.add_argument( - '--folder', - default="EXPORT_FOLDER", - help="Folder name to extract (default: EXPORT_FOLDER)" - ) - - args = parser.parse_args() - - try: - convert_bookmarks(args.input_file, args.output_file, args.folder) - except BookmarkError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == '__main__': - main() -EOF -``` - -Would you like me to show the test file again to make sure they match exactly? Or shall we commit this and see if it fixes the issue? - - - -Let's handle these changes systematically: - -```bash -# First, let's add the important files -git add pyproject.toml src/mdtk/bookmarks.py - -# Ignore files that shouldn't be in git -cat >> .gitignore << 'EOF' -output.md -test_bookmarks.html -src/mdtk/_version.py -EOF - -git add .gitignore - -# Add README and Setup if they contain meaningful changes -git add README.md Setup.md - -# Commit and push -git commit -m "Update package structure and dependencies" -git push origin main -``` - -After this, would you like to: -1. Review what's in the commit before pushing -2. Push directly and check the CI results -3. See what changes are in README.md and Setup.md first? diff --git a/docs/index.md b/docs/index.md index 7821afa..612c7a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,30 +1 @@ -# mdtk (Markdown Toolkit) - -A collection of tools for working with markdown files. - -[](https://pypi.org/project/mdtk/) -[](https://tommcd.github.io/mdtk) -[](https://github.com/tommcd/mdtk) - -## Installation - -```bash -pip install mdtk -``` - -## Features - -### Chrome Bookmarks to Markdown - -Convert Chrome bookmarks to markdown format: - -```bash -mdtk-bookmarks bookmarks.html output.md --folder "My Folder" -``` - -## Links - -- [GitHub Repository](https://github.com/tommcd/mdtk) -- [PyPI Package](https://pypi.org/project/mdtk/) -- [Issue Tracker](https://github.com/tommcd/mdtk/issues) -- [Changelog](https://github.com/tommcd/mdtk/releases) +--8<-- "README.md" diff --git a/mkdocs.yml b/mkdocs.yml index 436436b..f05a506 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,11 @@ theme: icon: repo: fontawesome/brands/github +markdown_extensions: + - pymdownx.snippets: + check_paths: true + base_path: !relative $config_dir + extra: social: - icon: fontawesome/brands/github diff --git a/pyproject.toml b/pyproject.toml index 394af4f..86cae04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +requires = ["setuptools>=77", "setuptools_scm[toml]>=8"] build-backend = "setuptools.build_meta" [project] @@ -7,8 +7,27 @@ name = "mdtk" dynamic = ["version"] description = "Markdown Toolkit - Tools for working with markdown files" readme = "README.md" -requires-python = ">=3.8" -license = {text = "MIT"} +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +authors = [ + {name = "Tom McDermott", email = "tcmcdermott@gmail.com"}, +] +keywords = ["markdown", "bookmarks", "chrome", "converter"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Text Processing :: Markup :: Markdown", +] dependencies = [ "beautifulsoup4", ] @@ -20,13 +39,13 @@ Changelog = "https://github.com/tommcd/mdtk/releases" Issues = "https://github.com/tommcd/mdtk/issues" [project.optional-dependencies] -test = [ +dev = [ "pytest", "ruff", ] [tool.setuptools_scm] -write_to = "src/mdtk/_version.py" +version_file = "src/mdtk/_version.py" [project.scripts] mdtk-bookmarks = "mdtk.bookmarks:main" @@ -36,11 +55,11 @@ testpaths = ["tests"] [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" src = ["src"] [tool.ruff.lint] -select = ["E", "F", "I"] +select = ["E", "F", "I", "UP", "B"] fixable = ["ALL"] [tool.ruff.format] @@ -52,14 +71,11 @@ line-ending = "auto" [tool.tox] legacy_tox_ini = """ [tox] -envlist = py38,py39,py310,py311,py312,py313 +envlist = py310,py311,py312,py313,py314 isolated_build = True [testenv] -deps = - pytest - beautifulsoup4 - ruff +extras = dev commands = ruff check . ruff format --check . diff --git a/src/mdtk/__init__.py b/src/mdtk/__init__.py index e69de29..0909bb5 100644 --- a/src/mdtk/__init__.py +++ b/src/mdtk/__init__.py @@ -0,0 +1,16 @@ +"""mdtk (Markdown Toolkit) - Tools for working with markdown files.""" + +from mdtk.bookmarks import BookmarkError, convert_bookmarks + +try: + from mdtk._version import version as __version__ +except ImportError: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _pkg_version + + try: + __version__ = _pkg_version("mdtk") + except PackageNotFoundError: + __version__ = "0+unknown" + +__all__ = ["BookmarkError", "__version__", "convert_bookmarks"] diff --git a/src/mdtk/bookmarks.py b/src/mdtk/bookmarks.py index 800ebcf..7b96c90 100644 --- a/src/mdtk/bookmarks.py +++ b/src/mdtk/bookmarks.py @@ -2,91 +2,165 @@ import argparse import sys +from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Optional +from urllib.parse import urlsplit, urlunsplit -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag class BookmarkError(Exception): """Base exception for bookmark conversion errors.""" +def _clean_text(text: str) -> str: + """Collapse whitespace runs so titles stay on one markdown line.""" + return " ".join(text.split()) + + +def _escape_title(text: str) -> str: + """Escape characters that would break out of a markdown link label.""" + return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + + +def _escape_url(url: str) -> str: + """Percent-encode characters that would break a markdown link target. + + Square brackets are encoded only outside the authority, where they + delimit IPv6 literal hosts (e.g. http://[2001:db8::1]:8080/). + """ + for char, quoted in ( + (" ", "%20"), + ("(", "%28"), + (")", "%29"), + ("<", "%3C"), + (">", "%3E"), + ): + url = url.replace(char, quoted) + try: + parts = urlsplit(url) + except ValueError: + return url + path, query, fragment = ( + part.replace("[", "%5B").replace("]", "%5D") + for part in (parts.path, parts.query, parts.fragment) + ) + return urlunsplit((parts.scheme, parts.netloc, path, query, fragment)) + + +def _find_folder(soup: BeautifulSoup, folder_name: str) -> Tag: + """Return the
-
-
""" - test_file = tmp_path / "test_bookmarks.html" - test_file.write_text(bookmarks_content) + +@pytest.fixture +def bookmarks_file(tmp_path): + """Create a realistic Chrome bookmarks export.""" + test_file = tmp_path / "bookmarks.html" + test_file.write_text(CHROME_EXPORT, encoding="utf-8") + return test_file + + +def write_export(tmp_path, body): + test_file = tmp_path / "bookmarks.html" + test_file.write_text(body, encoding="utf-8") return test_file -def test_basic_conversion(test_bookmarks_file, tmp_path): - """Test basic bookmark conversion with default folder.""" +def test_basic_conversion(bookmarks_file, tmp_path): + """Convert the default folder of a realistic export.""" test_md = tmp_path / "output.md" - convert_bookmarks(test_bookmarks_file, test_md) + count = convert_bookmarks(bookmarks_file, test_md) - content = test_md.read_text().strip().split("\n") + content = test_md.read_text(encoding="utf-8").strip().split("\n") + assert count == 2 assert len(content) == 2 assert "- [GitHub - Where the world builds software](https://github.com)" in content assert "- [Welcome to Python.org](https://python.org)" in content -def test_nonexistent_folder(test_bookmarks_file, tmp_path): - """Test error handling for non-existent folder.""" +def test_selects_named_folder(bookmarks_file, tmp_path): + """--folder selects a folder other than the default.""" test_md = tmp_path / "output.md" - with pytest.raises(BookmarkError, match="Folder 'NonExistent' not found"): - convert_bookmarks(test_bookmarks_file, test_md, "NonExistent") + count = convert_bookmarks(bookmarks_file, test_md, "Other Folder") + + assert count == 1 + assert ( + test_md.read_text(encoding="utf-8") + == "- [Example Domain](https://example.com)\n" + ) + + +def test_nonexistent_folder_lists_available(bookmarks_file, tmp_path): + """A missing folder reports which folders the export does contain.""" + with pytest.raises(BookmarkError, match="Folder 'NonExistent' not found") as exc: + convert_bookmarks(bookmarks_file, tmp_path / "output.md", "NonExistent") + + assert "Available folders: 'EXPORT_FOLDER', 'Other Folder'" in str(exc.value) + + +def test_input_without_folders_is_reported(tmp_path): + """Non-export input (e.g. Chrome's JSON bookmarks file) gets a clear error.""" + src = write_export(tmp_path, '{"roots": {"bookmark_bar": {"children": []}}}') + + with pytest.raises(BookmarkError, match="No bookmark folders found"): + convert_bookmarks(src, tmp_path / "output.md") + + +def test_folder_without_list_does_not_leak_next_folder(tmp_path): + """A folder with no
+
+
+
""", + ) + out = tmp_path / "output.md" + + with pytest.raises(BookmarkError, match="has no bookmark list"): + convert_bookmarks(src, out, "EXPORT_FOLDER") + + assert not out.exists() + + +def test_empty_folder_writes_empty_file(tmp_path): + """An empty folder converts to an empty file and reports zero bookmarks.""" + src = write_export( + tmp_path, + """
+
+
+
""", + ) + out = tmp_path / "output.md" + + count = convert_bookmarks(src, out) + + assert count == 0 + assert out.read_text(encoding="utf-8") == "" + + +def test_nested_subfolders_are_flattened(tmp_path): + """Bookmarks inside subfolders are included, flattened in document order.""" + src = write_export( + tmp_path, + """
+
+
+
+
+
""", + ) + out = tmp_path / "output.md" + + count = convert_bookmarks(src, out) + + assert count == 2 + assert out.read_text(encoding="utf-8") == ( + "- [Top level](https://a.example)\n- [Inside subfolder](https://b.example)\n" + ) + + +def test_title_with_markup_is_preserved(tmp_path): + """Inline markup in a title must not collapse it to 'Untitled'.""" + src = write_export( + tmp_path, + """
+
+
+
""", + ) + out = tmp_path / "output.md" + + convert_bookmarks(src, out) + + assert out.read_text(encoding="utf-8") == ( + "- [Title with bold markup](https://x.example)\n" + ) + + +def test_markdown_special_characters_are_escaped(tmp_path): + """Brackets in titles and parentheses in URLs must not break the link.""" + src = write_export( + tmp_path, + """
+
+
+
""", + ) + out = tmp_path / "output.md" + + convert_bookmarks(src, out) + + assert out.read_text(encoding="utf-8") == ( + r"- [Bracket (mathematics) \[Wikipedia\]]" + "(https://en.wikipedia.org/wiki/Bracket_%28mathematics%29)\n" + ) + + +def test_url_brackets_encoded_outside_ipv6_host(tmp_path): + """Brackets in path/query are encoded; IPv6 host brackets are preserved.""" + src = write_export( + tmp_path, + """
+
+
+
""", + ) + out = tmp_path / "output.md" + + convert_bookmarks(src, out) + + assert out.read_text(encoding="utf-8") == ( + "- [PHP-style query](https://x.example/api?tags%5B%5D=python)\n" + "- [Router status](http://[2001:db8::1]:8080/status%5B1%5D)\n" + ) + + +def test_bookmark_without_text_is_untitled(tmp_path): + src = write_export( + tmp_path, + """
+
+
+
""", + ) + out = tmp_path / "output.md" + + convert_bookmarks(src, out) + + assert out.read_text(encoding="utf-8") == "- [Untitled](https://x.example)\n" + + +def test_duplicate_folder_names_first_wins(tmp_path): + src = write_export( + tmp_path, + """
+
+
+
+
+
""", + ) + out = tmp_path / "output.md" + + convert_bookmarks(src, out, "Work") + + assert out.read_text(encoding="utf-8") == "- [First](https://first.example)\n" + + +def test_missing_input_file(tmp_path): + with pytest.raises(BookmarkError, match="Input file not found"): + convert_bookmarks(tmp_path / "missing.html", tmp_path / "output.md") + + +def test_missing_output_directory(bookmarks_file, tmp_path): + with pytest.raises(BookmarkError, match="Output directory does not exist"): + convert_bookmarks(bookmarks_file, tmp_path / "no_such_dir" / "output.md") + + +def test_cli_success(bookmarks_file, tmp_path, monkeypatch, capsys): + out = tmp_path / "output.md" + monkeypatch.setattr(sys, "argv", ["mdtk-bookmarks", str(bookmarks_file), str(out)]) + + main() + + assert "Wrote 2 bookmarks" in capsys.readouterr().out + assert out.read_text(encoding="utf-8").count("- [") == 2 + + +def test_cli_error_exits_nonzero(tmp_path, monkeypatch, capsys): + monkeypatch.setattr( + sys, + "argv", + ["mdtk-bookmarks", str(tmp_path / "missing.html"), str(tmp_path / "out.md")], + ) + + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + assert "Error: Input file not found" in capsys.readouterr().err + + +def test_cli_version(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["mdtk-bookmarks", "--version"]) + + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 0 + assert capsys.readouterr().out.startswith("mdtk-bookmarks ")