diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..63475710
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: Bouni
+
+---
+
+
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+# KiCad Version
+
+```
+```
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..bbcbbe7d
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml
new file mode 100644
index 00000000..748ec063
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "daily"
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml
new file mode 100644
index 00000000..4adabeea
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/bootstrap.yml
@@ -0,0 +1,101 @@
+---
+name: "Bootstrap the initial component database"
+on:
+ workflow_dispatch: # allow for manually trigger workflow
+jobs:
+ bootstrap_database:
+ name: "Bootstrap component database"
+ runs-on: ubuntu-latest
+ environment: github-pages
+ steps:
+ - name: Install dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ python3 python3-pip wget zip unzip p7zip-full sqlite3
+ - name: Maximize build space
+ uses: easimon/maximize-build-space@master
+ with:
+ root-reserve-mb: 512
+ swap-size-mb: 1024
+ remove-dotnet: 'true'
+ - name: Free more Space, Found in https://github.com/actions/runner-images/issues/2840#issuecomment-790492173
+ run: |
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /opt/ghc
+ sudo rm -rf "/usr/local/share/boost"
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+ - name: Checkout
+ uses: actions/checkout@v6
+ - name: Show versions
+ run: |
+ sqlite3 --version
+ python3 --version
+ python3 -c "import sqlite3; import pprint; db = sqlite3.connect(':memory:'); cursor = db.execute('PRAGMA COMPILE_OPTIONS'); pprint.pprint(cursor.fetchall())"
+ - name: Install python dependencies
+ run: |
+ pip install humanize
+ pip install -e .
+ - name: Update database
+ run: |
+ set -x
+
+ # Print the current disk usage, might help with future "no disk space left" issues
+ echo "============================================================================================================================="
+ df -h
+ echo "============================================================================================================================="
+
+ cd db_build
+
+ mkdir db_working
+ cd db_working
+ wget -q https://yaqwsx.github.io/jlcparts/data/cache.zip
+ for seq in $(seq -w 01 30); do
+ wget -q https://yaqwsx.github.io/jlcparts/data/cache.z$seq || true
+ done
+ 7z x cache.zip
+ for seq in $(seq -w 01 30); do
+ rm cache.z$seq || true
+ done
+
+ cd ..
+
+ python3 jlcparts_db_convert.py --skip-generate --fix-components-db-descriptions --clean-components-db --archive-components-db
+
+ ls -lah db_working/
+
+ rm db_working/cache.sqlite3
+
+ # Print the current disk usage, might help with future "no disk space left" issues
+ echo "============================================================================================================================="
+ df -h
+ echo "============================================================================================================================="
+
+ - name: Upload pages artifact
+ uses: actions/upload-pages-artifact@v4
+ with:
+ name: github-pages
+ path: db_build/archive
+ deploy:
+ name: "Deploy"
+ runs-on: ubuntu-latest
+ needs: bootstrap_database
+ permissions:
+ actions: write
+ contents: write
+ pages: write
+ id-token: write
+ # Deploy to the github-pages environment
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
+ # delete-artifact
+ - name: Clean
+ uses: geekyeggo/delete-artifact@v6
+ with:
+ name: github-pages
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml
new file mode 100644
index 00000000..c27ac64c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/kicad-pcm.yml
@@ -0,0 +1,46 @@
+---
+# This is a workflow to generate the zip file and metadata.json for KiCAD PCM
+# https://gitlab.com/kicad/addons/metadata/-/merge_requests/14
+
+name: KiCAD PCM packaging
+on: # yamllint disable-line rule:truthy
+ release:
+ branches: [main]
+ types:
+ - published
+ workflow_dispatch:
+
+jobs:
+ create_archive:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Get latest tag
+ uses: oprypin/find-latest-tag@v1
+ with:
+ repository: Bouni/kicad-jlcpcb-tools
+ releases-only: true
+ id: latest-release
+
+ - name: Checkout repo
+ uses: actions/checkout@v6
+
+ - name: Create archive
+ run: sh ./PCM/create_pcm_archive.sh ${{ steps.latest-release.outputs.tag }}
+
+ - name: Upload zip as asset to release
+ uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ./PCM/KiCAD-PCM-${{ steps.latest-release.outputs.tag }}.zip
+ asset_name: KiCAD-PCM-${{ steps.latest-release.outputs.tag }}.zip
+ overwrite: true
+ tag: ${{ steps.latest-release.outputs.tag }}
+
+ - name: Trigger custom kicad repo rebuild
+ uses: benc-uk/workflow-dispatch@v1
+ with:
+ workflow: Rebuild repository
+ ref: refs/heads/main
+ repo: Bouni/bouni-kicad-repository
+ token: ${{ secrets.PERSONAL_TOKEN }}
+ inputs: '{ "VERSION": "${{env.VERSION}}", "DOWNLOAD_SHA256": "${{env.DOWNLOAD_SHA256}}", "DOWNLOAD_SIZE": "${{env.DOWNLOAD_SIZE}}", "DOWNLOAD_URL": "${{env.DOWNLOAD_URL}}", "INSTALL_SIZE": "${{env.INSTALL_SIZE}}" }'
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml
new file mode 100644
index 00000000..79c350be
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/linter.yml
@@ -0,0 +1,43 @@
+---
+name: Lint and format everything
+on: # yamllint disable-line rule:truthy
+ push:
+ branches:
+ - main
+ pull_request:
+jobs:
+ markdownlint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ - name: Setup NodeJS
+ uses: actions/setup-node@v6
+ - name: Install markdownlint
+ run: npm install -g markdownlint-cli
+ - name: Run markdownlint
+ run: markdownlint "**/*.md"
+ ruff:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ - name: Check code with ruff
+ uses: chartboost/ruff-action@v1
+ with:
+ version: 0.11.4
+ args: check
+ src: "."
+ deadcode:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ - name: Install deadcode
+ run: pip install deadcode
+ - name: Run deadcode
+ run: deadcode .
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml
new file mode 100644
index 00000000..4dcdefb9
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/tests.yml
@@ -0,0 +1,23 @@
+---
+name: Tests
+on: # yamllint disable-line rule:truthy
+ push:
+ branches:
+ - main
+ pull_request:
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ - name: Install pytest
+ run: pip install pytest
+ - name: Install python packages
+ run: pip install .
+ - name: Run core tests
+ working-directory: core
+ run: pytest version.py
+ - name: Run common tests
+ working-directory: common
+ run: pytest
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml
new file mode 100644
index 00000000..58c5aed6
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.github/workflows/update_parts_database.yml
@@ -0,0 +1,99 @@
+---
+name: "Update parts database"
+on:
+ schedule:
+ - cron: "0 5 * * *"
+ workflow_dispatch: # allow for manually trigger workflow
+jobs:
+ build_and_update:
+ name: "Update component database and frontend"
+ runs-on: ubuntu-latest
+ environment: github-pages
+ steps:
+ - name: Install dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ python3 python3-pip wget zip unzip p7zip-full sqlite3
+ - name: Maximize build space
+ uses: easimon/maximize-build-space@master
+ with:
+ root-reserve-mb: 512
+ swap-size-mb: 1024
+ remove-dotnet: 'true'
+ - name: Free more Space, Found in https://github.com/actions/runner-images/issues/2840#issuecomment-790492173
+ run: |
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /opt/ghc
+ sudo rm -rf "/usr/local/share/boost"
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+ - name: Checkout
+ uses: actions/checkout@v6
+ - name: Show versions
+ run: |
+ sqlite3 --version
+ python3 --version
+ python3 -c "import sqlite3; import pprint; db = sqlite3.connect(':memory:'); cursor = db.execute('PRAGMA COMPILE_OPTIONS'); pprint.pprint(cursor.fetchall())"
+ - name: Install python dependencies
+ run: |
+ pip install humanize
+ pip install -e .
+ - name: Update database
+ run: |
+ set -x
+
+ # Print the current disk usage, might help with future "no disk space left" issues
+ echo "============================================================================================================================="
+ df -h
+ echo "============================================================================================================================="
+
+ cd db_build
+
+ # fetch the cached database
+ python3 jlcparts_db_convert.py \
+ --fetch-components-db \
+ --update-components-db \
+ --clean-components-db \
+ --components-db-base-url=http://z2amiller.github.io/kicad-jlcpcb-tools \
+ --archive-components-db \
+ --obsolete-parts-threshold-days=365
+
+ # remove the raw source db, as we don't want to package that one
+ rm db_working/cache.sqlite3
+ # some info output for sanity check
+ ls -lah db_working
+ du -cslh db_working
+
+ # Print the current disk usage, might help with future "no disk space left" issues
+ echo "============================================================================================================================="
+ df -h
+ echo "============================================================================================================================="
+
+ - name: Upload all-item parts db
+ uses: actions/upload-pages-artifact@v4
+ with:
+ name: github-pages
+ path: db_build/archive
+ deploy:
+ name: "Deploy"
+ runs-on: ubuntu-latest
+ needs: build_and_update
+ permissions:
+ actions: write
+ contents: write
+ pages: write
+ id-token: write
+ # Deploy to the github-pages environment
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
+ # delete-artifact
+ - name: Clean
+ uses: geekyeggo/delete-artifact@v6
+ with:
+ name: github-pages
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.gitignore b/third_party/Bouni/kicad-jlcpcb-tools/.gitignore
new file mode 100644
index 00000000..e725cfd0
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.gitignore
@@ -0,0 +1,149 @@
+led / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+db_build/db_working/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+jlcpcb
+corrections
+dev
+
+# vscode ignore
+.vscode/
+
+# OS cruft
+.DS_Store
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml b/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml
new file mode 100644
index 00000000..96699166
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.markdownlint.yml
@@ -0,0 +1,7 @@
+---
+default: true
+MD013: false # ignore line length
+MD026: false # ignore Trailing punctuation in header
+MD033: false # allow inline HTML
+MD036: false # allow Emphasis used instead of a header
+MD045: false # Don't force Alt text for images
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml b/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml
new file mode 100644
index 00000000..d843c920
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.pre-commit-config.yaml
@@ -0,0 +1,26 @@
+---
+repos:
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: v0.14.14
+ hooks:
+ - id: ruff
+ args:
+ - --fix
+ - --extend-exclude=lib
+ - id: ruff-format
+ exclude: '(^|/)lib'
+ - repo: https://github.com/asottile/pyupgrade
+ rev: v3.21.2
+ hooks:
+ - id: pyupgrade
+ exclude: ^.lib/
+ - repo: https://github.com/igorshubovych/markdownlint-cli
+ rev: v0.47.0
+ hooks:
+ - id: markdownlint
+ args:
+ - --fix
+ # - repo: https://github.com/albertas/deadcode
+ # rev: 2.4.1
+ # hooks:
+ # - id: deadcode
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/.yamllint b/third_party/Bouni/kicad-jlcpcb-tools/.yamllint
new file mode 100644
index 00000000..8b6f6eca
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/.yamllint
@@ -0,0 +1,34 @@
+---
+
+yaml-files:
+ - '*.yaml'
+ - '*.yml'
+ - '.yamllint'
+
+rules:
+ braces: enable
+ brackets: enable
+ colons: enable
+ commas: enable
+ comments:
+ level: warning
+ comments-indentation:
+ level: warning
+ document-end: disable
+ document-start:
+ level: warning
+ empty-lines: enable
+ empty-values: disable
+ float-values: disable
+ hyphens: enable
+ indentation: enable
+ key-duplicates: enable
+ key-ordering: disable
+ line-length: disable
+ new-line-at-end-of-file: enable
+ new-lines: enable
+ octal-values: disable
+ quoted-strings: disable
+ trailing-spaces: enable
+ truthy:
+ level: warning
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/LICENSE b/third_party/Bouni/kicad-jlcpcb-tools/LICENSE
new file mode 100644
index 00000000..60a60669
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 bouni
+
+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/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh b/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh
new file mode 100644
index 00000000..f6ae1392
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/PCM/create_pcm_archive.sh
@@ -0,0 +1,52 @@
+#!/bin/sh
+
+# heavily inspired by https://github.com/4ms/4ms-kicad-lib/blob/master/PCM/make_archive.sh
+
+VERSION=$1
+
+echo "Clean up old files"
+rm -f PCM/*.zip
+rm -rf PCM/archive
+
+
+echo "Create folder structure for ZIP"
+mkdir -p PCM/archive/plugins
+mkdir -p PCM/archive/resources
+
+echo "Copy files to destination"
+cp VERSION PCM/archive/plugins
+cp *.py PCM/archive/plugins
+cp *.png PCM/archive/plugins
+cp settings.json PCM/archive/plugins
+cp -r icons PCM/archive/plugins
+cp -r lib PCM/archive/plugins
+cp -r dblib PCM/archive/plugins
+cp -r common PCM/archive/plugins
+mkdir PCM/archive/plugins/core
+cp core/*.py PCM/archive/plugins/core
+cp PCM/icon.png PCM/archive/resources
+cp PCM/metadata.template.json PCM/archive/metadata.json
+
+echo "Write version info to file"
+echo $VERSION > PCM/archive/plugins/VERSION
+
+echo "Modify archive metadata.json"
+sed -i "s/VERSION_HERE/$VERSION/g" PCM/archive/metadata.json
+sed -i "s/\"kicad_version\": \"6.0\",/\"kicad_version\": \"6.0\"/g" PCM/archive/metadata.json
+sed -i "/SHA256_HERE/d" PCM/archive/metadata.json
+sed -i "/DOWNLOAD_SIZE_HERE/d" PCM/archive/metadata.json
+sed -i "/DOWNLOAD_URL_HERE/d" PCM/archive/metadata.json
+sed -i "/INSTALL_SIZE_HERE/d" PCM/archive/metadata.json
+
+echo "Zip PCM archive"
+cd PCM/archive
+zip -r ../KiCAD-PCM-$VERSION.zip .
+cd ../..
+
+echo "Gather data for repo rebuild"
+echo VERSION=$VERSION >> $GITHUB_ENV
+echo DOWNLOAD_SHA256=$(shasum --algorithm 256 PCM/KiCAD-PCM-$VERSION.zip | xargs | cut -d' ' -f1) >> $GITHUB_ENV
+echo DOWNLOAD_SIZE=$(ls -l PCM/KiCAD-PCM-$VERSION.zip | xargs | cut -d' ' -f5) >> $GITHUB_ENV
+echo DOWNLOAD_URL="https:\/\/github.com\/Bouni\/kicad-jlcpcb-tools\/releases\/download\/$VERSION\/KiCAD-PCM-$VERSION.zip" >> $GITHUB_ENV
+echo INSTALL_SIZE=$(unzip -l PCM/KiCAD-PCM-$VERSION.zip | tail -1 | xargs | cut -d' ' -f1) >> $GITHUB_ENV
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png b/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png
new file mode 100644
index 00000000..9c086f97
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/PCM/icon.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json b/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json
new file mode 100644
index 00000000..92d1eb9c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/PCM/metadata.template.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://go.kicad.org/pcm/schemas/v1",
+ "name": "KiCAD JLCPCB tools",
+ "description": "A JLCPCB integration addon for KiCAD",
+ "description_full": "This plugin allows you to search the JLCPCB parts database, assign LCSC article numbers to your parts, generate production files for JLCPCB and much more.",
+ "identifier": "com.github.bouni.kicad-jlcpcb-tools",
+ "type": "plugin",
+ "author": {
+ "name": "Bouni",
+ "contact": {
+ "twitter": "@bouni2016"
+ }
+ },
+ "maintainer": {
+ "name": "Bouni",
+ "contact": {
+ "twitter": "@bouni2016"
+ }
+ },
+ "license": "WTFPL",
+ "resources": {
+ "homepage": "https://github.com/bouni/kicad-jlcpcb-tools"
+ },
+ "versions": [{
+ "version": "VERSION_HERE",
+ "status": "testing",
+ "kicad_version": "6.0",
+ "download_sha256": "SHA256_HERE",
+ "download_size": DOWNLOAD_SIZE_HERE,
+ "download_url": "DOWNLOAD_URL_HERE",
+ "install_size": INSTALL_SIZE_HERE
+ }]
+}
\ No newline at end of file
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/README.md b/third_party/Bouni/kicad-jlcpcb-tools/README.md
new file mode 100644
index 00000000..7539d464
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/README.md
@@ -0,0 +1,290 @@
+#  KiCAD JLCPCB tools
+
+
+
+***
+
+
+
+***
+
+[](https://github.com/Bouni/kicad-jlcpcb-tools/actions/workflows/update_parts_database.yml)
+
+***
+
+Plugin to generate all files necessary for JLCPCB board fabrication and assembly
+
+- Gerber files
+- Excellon files
+- BOM file
+- CPL file
+
+Furthermore it lets you search the JLCPCB parts database and assign parts directly to the footprints which result in them being put into the BOM file.
+
+
+
+
+
+
+
+## Installation 💾
+
+### KiCAD PCM
+
+Add my custom repo to *the Plugin and Content Manager*, the URL is:
+
+```sh
+https://raw.githubusercontent.com/Bouni/bouni-kicad-repository/main/repository.json
+```
+
+
+
+From there you can install the plugin via the GUI.
+
+### Git
+
+Simply clone this repo into your `scripting/plugins` folder.
+
+**Windows**
+
+```sh
+cd C:\users\\Documents\kicad\\scripting\plugins\ # is your username, can be 7.0, 8.0, or X.YY depending on the version you use
+git clone https://github.com/Bouni/kicad-jlcpcb-tools.git
+```
+
+**Linux**
+
+```sh
+cd /home//.local/share/kicad//scripting/plugins # can be 7.0, 8.0, or X.YY depending on the version you use
+git clone https://github.com/Bouni/kicad-jlcpcb-tools.git
+```
+
+**MacOS**
+
+```sh
+cd ~/Library/Preferences/kicad/scripting/plugins
+git clone https://github.com/Bouni/kicad-jlcpcb-tools.git
+```
+
+You may need to create the `scripting/plugins` folder if it does not exist.
+
+### Flatpak :warning:
+
+The Flatpak installation of KiCAD currently does not ship with pip and requests installed. The later is required for the plugin to work.
+In order to get it working you can run the following 3 commands:
+
+1. `flatpak run --command=sh org.kicad.KiCad`
+2. `python -m ensurepip --upgrade`
+3. `/var/data/python/bin/pip3 install requests`
+
+See [issue #94](https://github.com/Bouni/kicad-jlcpcb-tools/issues/94) for more info.
+
+## Usage 🥳
+
+To access the plugin choose `Tools → External Plugins → JLCPCB Tools` from the *PCB Editor* menus
+
+Checkout this screencast, it shows quickly how to use this plugin:
+
+
+
+## Keyboard shortcuts
+
+Windows can be closed with ctrl-w/ctrl-q/command-w/command-w (OS dependent) and escape.
+Pressing enter in the keyword text box will start a search.
+
+### Toggle BOM / CPL attributes
+
+You can easily toggle the `exclude from BOM` and `exclude from CPL` attributes of one or multiple footprints.
+
+### Select LCSC parts from the JLCPCB parts database
+
+Select one or multiple footprints, click select part. You can select parts with equal value and footprint using the Select alike button.
+In the upcoming modal dialog, search for parts, select the one of your choice and click select part.
+The LCSC number of your selection will then be assigned to the footprints.
+
+
+
+### Generate fabrication data
+
+Generate all necessary assembly files for your board with a simple click.
+
+A new directory called `jlcpcb` is created, and in there, two separate folders are created, `gerber` and `production_files`.
+
+In the gerber folder all necessary `*.gbr` and `*.drl` files are generated and zipped into the `production_files` folder, ready for upload to JLCPCB.
+The zipfile is named `GERBER-.zip`
+
+Also in the `production_files` folder, two files are generated, `BOM-.csv` and `CPL-.csv`.
+
+Footprints are included into the BOM and CPL files according to their `exclude from BOM` and `exclude from POS` attributes.
+
+
+
+### Export Additional JLC Specific Layers
+
+Some boards you have manufactured will require additional layers in your Gerber. For example, when manufacturing flex PCBs with a stiffener, JLC requires a layer outlining the stiffener layer (top/bottom), dimensions and the stiffener material properties (material, thickness etc). Export these additional JLC specific layers in your production files with a simple modification.
+
+Additional layers can be exported by creating layers with `JLC_` as the prefix of the layer name. You can access and edit the layer names in *Board Setup/Board Stackup/Board Editor Layers*
+
+This tool will automatically export all additional layers with the JLC_ prefix and add them to the production files in `GERBER-.zip`
+
+
+
+## Footprint rotation correction
+
+JLCPCB seems to need corrected rotation information. @matthewlai implemented that in his [JLCKicadTools](https://github.com/matthewlai/JLCKicadTools) and I adopted his work in this plugin as well.
+You can download Matthews file from GitHub and manage your own corrections in the Rotation manager.
+
+## Icons
+
+This plugin makes use of a lot of icons from the excellent [Material Design Icons](https://materialdesignicons.com/)
+
+## Development
+
+1. Fork repo
+2. Git clone forked repo
+3. Install pre-commit `pip install pre-commit`
+4. Setup pre-commit `pre-commit run`
+5. Create feature branch `git switch -c my-awesome-feature`
+6. Make your changes
+7. Commit your changes `git commit -m "Awesome new feature"`
+8. Push to GitHub `git push`
+9. Create PR
+
+Make sure you make use of pre-commit hooks in order to format everything nicely with `black`
+In the near future I'll add `ruff` / `pylint` and possibly other pre-commit-hooks that enforce nice and clean code style.
+
+## How to rebuild the parts database
+
+The parts database is rebuilt by the [update_parts_database.yml GitHub workflow](https://github.com/Bouni/kicad-jlcpcb-tools/blob/main/.github/workflows/update_parts_database.yml)
+
+You can reference the steps in the 'Update database' section for the commands to run locally.
+
+## python libraries
+
+lib/ contains the necessary python packages that may not be a part of the KiCad python distribution.
+
+These packages include:
+
+- packaging
+
+To install a package, such as 'packaging':
+
+```python
+pip install packaging --target ./lib
+```
+
+To update these packages:
+
+```python
+pip install packaging --upgrade --target ./lib
+```
+
+Future versions of KiCad may have support for a requires.txt to automate this process.
+
+## Standalone mode
+
+Allows the plugin UI to be started without KiCAD, enabling debugging with an IDE like pycharm / vscode.
+
+Standalone mode is under development.
+
+### Limitations
+
+- All board / footprint / value data are hardcoded stubs, see standalone_impl.py
+
+### How to use
+
+To use the plugin in standlone mode you'll need to identify three pieces of information specific to your Kicad version, plugin path, and OS.
+
+#### Python
+
+The {KiCad python} should be used, this can be found at different locations depending on your system:
+
+| OS | Kicad python |
+|--------|---------------------------------------------------------------------------------------------|
+|Mac | /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3 |
+|Linux | /usr/bin/python3 |
+|Windows | C:\Program Files\KiCad\8.0\bin\python.exe |
+
+#### Working directory
+
+The {working directory} should be your plugins directory, ie:
+
+| OS | Working dir |
+|--------|------------------------------------------------------------|
+|Mac | ~/Documents/KiCad//scripting/plugins/ |
+|Linux | ~/.local/share/kicad//scripting/plugins/ |
+|Windows | %USERPROFILE%\Documents\KiCad\\scripting\plugins\ |
+
+> [!NOTE]
+> can be 7.0, 8.0, or X.YY depending on the version you use
+
+#### Plugin folder name
+
+The {kicad-jlcpcb-tools folder name} should be the name of the kicad-jlcpcb-tools folder.
+
+- For Kicad managed plugins this may be like
+
+> com_github_bouni_kicad-jlcpcb-tools
+
+- If you are developing kicad-jlcpcb-tools this is the folder you cloned the kicad-jlcpcb-tools as.
+
+#### Command line
+
+- Change to the working directory as noted above
+- Run the python interpreter with the {kicad-jlcpcb-tools folder name} folder as a module.
+
+For example:
+
+```sh
+cd {working directory}
+{kicad_python} -m {kicad-jlcpcb-tools folder name}
+```
+
+For example on Mac:
+
+```sh
+/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3 -m kicad-jlcpcb-tools
+```
+
+For example on Linux:
+
+```sh
+cd ~/.local/share/kicad/8.0/scripting/plugins/ && python -m kicad-jlcpcb-tools
+```
+
+For example on Windows:
+
+```cmd
+& 'C:\Program Files\KiCad\8.0\bin\python.exe' -m kicad-jlcpcb-tools
+```
+
+#### IDE
+
+- Configure the command line to be '{kicad_python} -m {kicad-jlcpcb-tools folder name}'
+- Set the working directory to {working directory}
+
+If using PyCharm or Jetbrains IDEs, set the interpreter to Kicad's python, {Kicad python} and under 'run configuration' select Python.
+
+Click on 'script path' and change instead to 'module name',
+entering the name of the kicad-jlcpcb-tools folder, {kicad-jlcpcb-tools folder name}.
+
+## How to release new versions of this plugin
+
+[bouni-kicad-repository](https://raw.githubusercontent.com/Bouni/bouni-kicad-repository/main/repository.json) contains the
+files for the latest version of the plugin, in the format KiCAD expects from external plugins.
+
+To release a new version of this plugin:
+
+1. In the kicad-jlcpcb-plugin repository:
+ 1. Visit the releases page 
+ 1. Click on 'Choose a tag', enter the next release number, say 2025.04.01 for example, and click on 'Create Tag' 
+ 1. Click 'Generate release notes' 
+ 1. If the release notes looks good, click on 'Publish release' 
+1. Automatically the new release will trigger the 'kicad-pcm' workflow which will:
+ 1. Pull the latest plugin tag
+ 1. Create the appropriate pcm archive
+ 1. Upload the zip as an asset to a new GitHub release
+ 1. benc-uk/workflow-dispatch@v1 is used to trigger the 'Rebuild repository' workflow in [bouni-kicad-repository](https://github.com/Bouni/bouni-kicad-repository)
+1. Automatically in the bouni-kicad-repository, the 'Rebuild repository' (rebuild.yml) workflow runs 'generate.py'
+ 1. generate.py updates .json and the latest .zip file using the release assets from the kicad-jlcpcb-plugin repository
+1. The plugin should now be visible to users via the plugin manager.
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/VERSION b/third_party/Bouni/kicad-jlcpcb-tools/VERSION
new file mode 100644
index 00000000..38f8e886
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/VERSION
@@ -0,0 +1 @@
+dev
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/__init__.py
new file mode 100644
index 00000000..6e32547a
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/__init__.py
@@ -0,0 +1,17 @@
+"""Init file for plugin."""
+
+import os
+import sys
+
+lib_path = os.path.join(os.path.dirname(__file__), "lib")
+if lib_path not in sys.path:
+ sys.path.append(lib_path)
+
+try:
+ from .plugin import JLCPCBPlugin # noqa: I001, E402
+
+ if __name__ != "__main__":
+ JLCPCBPlugin().register()
+except ImportError:
+ # Plugin import may fail in test environments where KiCad is not available
+ pass
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/__main__.py b/third_party/Bouni/kicad-jlcpcb-tools/__main__.py
new file mode 100644
index 00000000..3f48768d
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/__main__.py
@@ -0,0 +1,19 @@
+"""Entry point for running the plugin in standalone mode."""
+
+import wx
+
+from . import standalone_impl
+from .mainwindow import JLCPCBTools
+
+if __name__ == "__main__":
+ print("starting jlcpcbtools standalone mode...") # noqa: T201
+
+ # See README.md for how to use this
+
+ app = wx.App(None)
+
+ dialog = JLCPCBTools(None, kicad_provider=standalone_impl.KicadStub())
+ dialog.Center()
+ dialog.Show()
+
+ app.MainLoop()
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py
new file mode 100644
index 00000000..60fc693a
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/__init__.py
@@ -0,0 +1,30 @@
+"""Common modules for kicad-jlcpcb-tools.
+
+Provides reusable components for file management and database operations.
+"""
+
+from .componentdb import ComponentsDatabase
+from .filemgr import FileManager
+from .jlcapi import ApiCategory, CategoryFetch, Component, JlcApi, LcscId
+from .progress import (
+ NestedProgressBar,
+ NoOpProgressBar,
+ PrintNestedProgressBar,
+ ProgressCallback,
+ TqdmNestedProgressBar,
+)
+
+__all__ = [
+ "FileManager",
+ "ApiCategory",
+ "CategoryFetch",
+ "Component",
+ "ComponentsDatabase",
+ "JlcApi",
+ "LcscId",
+ "NestedProgressBar",
+ "NoOpProgressBar",
+ "PrintNestedProgressBar",
+ "ProgressCallback",
+ "TqdmNestedProgressBar",
+]
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py
new file mode 100644
index 00000000..17e44bef
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/componentdb.py
@@ -0,0 +1,338 @@
+"""Component database management."""
+
+from collections.abc import Generator
+import json
+import sqlite3
+import time
+
+from .jlcapi import Component
+
+_CREATE_STATEMENTS = [
+ """CREATE TABLE IF NOT EXISTS components (
+ lcsc INTEGER PRIMARY KEY NOT NULL,
+ category_id INTEGER NOT NULL,
+ mfr TEXT NOT NULL,
+ package TEXT NOT NULL,
+ joints INTEGER NOT NULL,
+ manufacturer_id INTEGER NOT NULL,
+ basic INTEGER NOT NULL,
+ preferred INTEGER NOT NULL DEFAULT 0,
+ description TEXT NOT NULL,
+ datasheet TEXT NOT NULL,
+ stock INTEGER NOT NULL,
+ price TEXT NOT NULL,
+ last_update INTEGER NOT NULL,
+ extra TEXT,
+ flag INTEGER NOT NULL DEFAULT 0,
+ last_on_stock INTEGER NOT NULL DEFAULT 0)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS components_category
+ ON components (category_id)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS components_manufacturer
+ ON components (manufacturer_id)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS manufacturers (
+ id INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ UNIQUE (id, name))
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS categories (
+ id INTEGER PRIMARY KEY NOT NULL,
+ category TEXT NOT NULL,
+ subcategory TEXT NOT NULL,
+ UNIQUE (category, subcategory))
+ """,
+]
+
+
+def fixDescription(description, raw_extra_json):
+ """Fix empty descriptions.
+
+ At some point, JLC started returning empty descriptions in
+ their parts CSV, but the description is still available in
+ the 'extra' JSON in the 'description' field.
+ Note that this takes the raw JSON string, not the already
+ parsed 'extra' dict - this lets it be used as a SQLite UDF.
+ """
+
+ if not description:
+ try:
+ e = json.loads(raw_extra_json)
+ desc = e.get("description", e.get("describe", ""))
+ return desc
+ except Exception:
+ pass
+ return description
+
+
+class ComponentsDatabase:
+ """Class to manage the component cache database."""
+
+ def __init__(self, filepath: str) -> None:
+ self.conn = sqlite3.connect(filepath)
+ self.conn.row_factory = sqlite3.Row
+ self.conn.create_function("maybeFixDescription", 2, fixDescription)
+ self.manufacturer_cache: dict[str, int | None] = {}
+ self.category_cache: dict[tuple[str, str], int | None] = {}
+ for stmt in _CREATE_STATEMENTS:
+ self.conn.execute(stmt)
+ self.conn.commit()
+
+ def manufacturerId(self, name: str) -> int | None:
+ """Get the manufacturer ID from the database, inserting if necessary.
+
+ Note that this method has side effects; it inserts into the database and
+ commits the transaction if the manufacturer is not already present, so it's
+ not safe to do manufacturerId() lookups while inside another running transaction.
+ """
+ if name in self.manufacturer_cache:
+ return self.manufacturer_cache[name]
+ cursor = self.conn.execute(
+ "SELECT id FROM manufacturers WHERE name = ?", (name,)
+ )
+ row = cursor.fetchone()
+ if row:
+ self.manufacturer_cache[name] = row["id"]
+ return row["id"]
+ cursor = self.conn.execute(
+ "INSERT INTO manufacturers (name) VALUES (?)", (name,)
+ )
+ self.conn.commit()
+ manufacturer_id = cursor.lastrowid
+ self.manufacturer_cache[name] = manufacturer_id
+ return manufacturer_id
+
+ def categoryId(self, category: str, subcategory: str) -> int | None:
+ """Get the category ID from the database, inserting if necessary.
+
+ Note that this method has side effects; it inserts into the database and
+ commits the transaction if the category is not already present, so it's
+ not safe to do categoryId() lookups while inside another running transaction.
+ """
+ key = (category, subcategory)
+ if key in self.category_cache:
+ return self.category_cache[key]
+ cursor = self.conn.execute(
+ "SELECT id FROM categories WHERE category = ? AND subcategory = ?",
+ (category, subcategory),
+ )
+ row = cursor.fetchone()
+ if row:
+ self.category_cache[key] = row["id"]
+ return row["id"]
+ cursor = self.conn.execute(
+ "INSERT INTO categories (category, subcategory) VALUES (?, ?)",
+ (category, subcategory),
+ )
+ self.conn.commit()
+ category_id = cursor.lastrowid
+ self.category_cache[key] = category_id
+ return category_id
+
+ def close(self) -> None:
+ """Close the database connection."""
+ self.conn.close()
+
+ def fix_description(self) -> None:
+ """Fix empty descriptions in the database by copying from 'extra' JSON.
+
+ Note that this is only useful while boostrapping a copy of the database
+ that is missing the descriptions -- e.g. a fresh load of the yaqswx database
+ before the empty descriptions are fixed. It should be harmless to run against
+ a fixed database, though, since it will only attempt to fix descriptions that
+ are empty or NULL.
+
+ Note that because this runs a large transaction, it can effectively double the
+ size of the database while it is running while sqlite journals the changes since
+ it can touch all rows in the components table.
+ """
+ self.conn.execute(
+ """
+ UPDATE components
+ SET description = maybeFixDescription(description, extra)
+ WHERE description IS NULL OR description = ''
+ """
+ )
+ self.conn.commit()
+
+ def cleanup_stock(self) -> None:
+ """Set stock to zero for components not updated in the last 7 days.
+
+ Since this script should update all in-stock components every time it runs,
+ any component that hasn't been updated in the last 7 days can be assumed
+ to be out of stock. Ideally this should only be run if the full database
+ update was successful.
+
+ """
+ seven_days_ago = int(time.time()) - 7 * 24 * 60 * 60
+ now = int(time.time())
+ self.conn.execute(
+ """
+ UPDATE components
+ SET stock = 0, last_update = ?
+ WHERE stock > 0 AND last_update < ?
+ """,
+ (now, seven_days_ago),
+ )
+ self.conn.commit()
+
+ def truncate_old(self) -> None:
+ """Truncate components not updated in the last year.
+
+ For components that have been out of stock for over a year, set their
+ price and extra fields to empty values to save space. Neither of these
+ fields are indexed in the kicad-jlcpcb-tools usage, so this stock should
+ remain searchable but the large price and extra fields that are there
+ "just in case" and for backwards compatibility can be removed.
+
+ Because this clears a lot of space, it also runs a VACUUM to compact the
+ database to reclaim the freed space.
+
+ Note that both the transaction to clear the old components and the VACUUM
+ can take a long time to run and temporarily nearly double the size of the database
+ while the changes are journaled.
+ """
+ one_year_ago = int(time.time()) - 365 * 24 * 60 * 60
+ self.conn.execute(
+ """
+ UPDATE components
+ SET price = '[]', extra = '{}' where stock = 0 AND last_on_stock < ?
+ """,
+ (one_year_ago,),
+ )
+ self.conn.commit()
+ self.conn.execute("VACUUM")
+
+ @staticmethod
+ def cols() -> list[str]:
+ """Get the list of component database columns."""
+ return [
+ "lcsc",
+ "category_id",
+ "manufacturer_id",
+ "mfr",
+ "package",
+ "basic",
+ "preferred",
+ "description",
+ "datasheet",
+ "stock",
+ "price",
+ "extra",
+ "joints",
+ "last_update",
+ "last_on_stock",
+ ]
+
+ def update_cache(self, components: "list[Component]") -> None:
+ """Update the parts database cache from JLCPCB using UPSERT."""
+
+ # Iterate the components first to set the category and manufacturer IDs.
+ # These functions might insert into the database so need to be done outside
+ # of the .executemany()'s implicit transaction.
+ for comp in components:
+ comp["category_id"] = self.categoryId(*comp.categoryKey())
+ comp["manufacturer_id"] = self.manufacturerId(comp.manufacturerKey())
+
+ update_cols = [
+ col for col in self.cols() if col not in ["lcsc", "last_on_stock"]
+ ]
+ self.conn.executemany(
+ f"""
+ INSERT INTO components (
+ {", ".join(self.cols())}
+ ) VALUES (
+ {", ".join(":" + col for col in self.cols())}
+ ) ON CONFLICT(lcsc) DO UPDATE SET
+ {", ".join(f"{col} = :{col}" for col in update_cols)}
+ , last_on_stock = CASE
+ WHEN excluded.stock > 0 THEN excluded.last_update
+ ELSE components.last_on_stock END
+ """,
+ (comp.asDatabaseRow() for comp in components),
+ )
+ self.conn.commit()
+
+ def count_components(self, where_clause: str = "") -> int:
+ """Count the number of components in the database.
+
+ Args:
+ where_clause: SQL WHERE clause (without the WHERE keyword). If empty,
+ all components are counted.
+
+ Returns:
+ Number of components matching the where_clause.
+
+ """
+ query = "SELECT COUNT(*) AS count FROM components"
+
+ if where_clause:
+ query += f" WHERE {where_clause}"
+
+ cursor = self.conn.execute(query)
+ row = cursor.fetchone()
+ return row["count"] if row else 0
+
+ def fetch_components(
+ self, where_clause: str = "", batch_size: int = 100000
+ ) -> Generator[list[sqlite3.Row], None, None]:
+ """Yield batches of sqlite3.Row objects from the database.
+
+ Args:
+ where_clause: SQL WHERE clause (without the WHERE keyword). If empty,
+ all components are returned.
+ batch_size: Number of components to yield in each batch (default 100000).
+
+ Yields:
+ list[sqlite3.Row]: Lists of rows up to batch_size length.
+
+ Example:
+ for batch in db.components_batch_generator("stock > 0", batch_size=10000):
+ for row in batch:
+ print(row["lcsc"])
+
+ """
+ query = "SELECT * FROM components"
+
+ if where_clause:
+ query += f" WHERE {where_clause}"
+
+ query += " ORDER BY lcsc"
+
+ cursor = self.conn.execute(query)
+
+ while True:
+ rows = cursor.fetchmany(batch_size)
+
+ if not rows:
+ break
+
+ yield rows
+
+ def get_manufacturers(self) -> dict[int, str]:
+ """Get all manufacturers from the database.
+
+ Returns:
+ Dictionary mapping manufacturer ID to manufacturer name.
+
+ """
+ cursor = self.conn.execute("SELECT id, name FROM manufacturers")
+ return {row["id"]: row["name"] for row in cursor.fetchall()}
+
+ def get_categories(self) -> dict[int, tuple[str, str]]:
+ """Get all categories from the database.
+
+ Returns:
+ Dictionary mapping category ID to (category, subcategory) tuple.
+
+ """
+ cursor = self.conn.execute("SELECT id, category, subcategory FROM categories")
+ return {
+ row["id"]: (row["category"], row["subcategory"])
+ for row in cursor.fetchall()
+ }
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py b/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py
new file mode 100644
index 00000000..58307917
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/filemgr.py
@@ -0,0 +1,545 @@
+#!/usr/bin/env python3
+
+"""File management utilities for splitting, reassembling, and downloading files.
+
+This module provides classes to handle:
+- Splitting large files into chunks for GitHub compatibility
+- Reassembling split files using the split_file_reader package
+- Downloading files from GitHub archives
+"""
+
+import argparse
+from collections.abc import Callable, Generator
+import glob
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Any
+import zipfile
+
+import requests
+from split_file_reader.split_file_reader import SplitFileReader
+from split_file_reader.split_file_writer import SplitFileWriter
+
+from .progress import NestedProgressBar, NoOpProgressBar, TqdmNestedProgressBar
+
+
+class FileManager:
+ """Manage file splitting, reassembly, and downloads."""
+
+ def __init__(
+ self,
+ file_path: Path | str,
+ chunk_size: int = 80000000, # 80 MB default
+ sentinel_filename: str = "chunk_num.txt",
+ compressed_output_file: str | None = None,
+ use_temp_dir: bool = False,
+ ):
+ """Initialize FileManager.
+
+ Args:
+ file_path: Path to the file to zip and split.
+ chunk_size: Size of each split chunk in bytes (default 80MB for GitHub)
+ sentinel_filename: Name of the sentinel file that tracks chunk count.
+ compressed_output_file: Path for compressed output file
+ (defaults to file_path.zip). May end up being
+ the prefix of the split files.
+ use_temp_dir: If True, create a temporary working directory for
+ intermediate files. Useful for large operations.
+
+ """
+ self.file_path = Path(file_path)
+ self.chunk_size = chunk_size
+ self.sentinel_filename = Path(sentinel_filename)
+ self.compressed_output_file = (
+ Path(compressed_output_file)
+ if compressed_output_file
+ else Path(f"{self.file_path}.zip")
+ )
+ self.use_temp_dir = use_temp_dir
+ self.temp_dir: Path | None = None
+
+ def _get_work_dir(self) -> Path:
+ """Get the working directory, creating temp dir if needed.
+
+ Returns:
+ Path: The working directory (either temp or current).
+
+ """
+ if self.use_temp_dir:
+ if self.temp_dir is None:
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="filemanager_"))
+ print(f"Created temporary working directory: {self.temp_dir}")
+ return self.temp_dir
+ return Path(".")
+
+ def cleanup_temp_dir(self) -> None:
+ """Clean up the temporary working directory if it exists.
+
+ This method should be called when finished with the FileManager
+ to clean up temporary files.
+
+ """
+ if self.temp_dir and self.temp_dir.exists():
+ shutil.rmtree(self.temp_dir)
+ print(f"Cleaned up temporary directory: {self.temp_dir}")
+ self.temp_dir = None
+
+ def __enter__(self):
+ """Context manager entry."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Context manager exit, ensuring temp dir cleanup."""
+ self.cleanup_temp_dir()
+ return False
+
+ def compress_and_split(
+ self, output_dir: Path | None = None, delete_original: bool = False
+ ) -> int:
+ """Split the file into chunks, creating a sentinel file with chunk count.
+
+ This method maintains compatibility with Generate.split() output format.
+ It splits the file at self.file_path into numbered chunks (e.g., file.zip.001, .002, etc.)
+ and creates a sentinel file indicating the number of chunks.
+
+ Returns:
+ int: The number of chunks created
+
+ Raises:
+ FileNotFoundError: If the file to split does not exist.
+
+ """
+ if not self.file_path.exists():
+ raise FileNotFoundError(f"File to split not found: {self.file_path}")
+
+ class SplitTracker:
+ """Custom SplitFileWriter to track chunk count."""
+
+ def __init__(self, file_prefix: str):
+ self.file_prefix = file_prefix
+ self.files = []
+
+ def gen_split(self) -> Generator[Any, Any, None]:
+ while True:
+ name = f"{self.file_prefix}{len(self.files) + 1:03d}"
+ with open(name, "wb") as output_file:
+ self.files.append(name)
+ yield output_file
+
+ def get_chunk_count(self) -> int:
+ return len(self.files)
+
+ work_dir = self._get_work_dir()
+ print(f"Chunking {self.file_path}")
+
+ # Build output file path in working directory
+ # If output_dir is provided, always use it (takes precedence)
+ # Otherwise, determine based on compressed_output_file
+ if output_dir is not None:
+ # Convert output_dir to absolute path to ensure files are created in correct location
+ actual_output_dir = Path(output_dir).resolve()
+ elif self.compressed_output_file.is_absolute():
+ actual_output_dir = self.compressed_output_file.parent
+ else:
+ actual_output_dir = work_dir / self.compressed_output_file.parent
+
+ actual_output_dir.mkdir(parents=True, exist_ok=True)
+ output_prefix = actual_output_dir / self.compressed_output_file.name
+ tracker = SplitTracker(str(output_prefix) + ".")
+ with (
+ SplitFileWriter(tracker.gen_split(), self.chunk_size) as writer,
+ zipfile.ZipFile(
+ file=writer, mode="w", compression=zipfile.ZIP_DEFLATED
+ ) as zip_writer,
+ ):
+ zip_writer.write(self.file_path, arcname=self.file_path.name)
+
+ # Create sentinel file indicating the number of chunks
+ sentinel_path = actual_output_dir / self.sentinel_filename
+ with open(sentinel_path, "w", encoding="utf-8") as f:
+ f.write(str(tracker.get_chunk_count()))
+
+ print(
+ f"Created {tracker.get_chunk_count()} chunks with sentinel file: {sentinel_path}"
+ )
+ if delete_original:
+ self.file_path.unlink()
+ print(f"Deleted original file: {self.file_path}")
+ return tracker.get_chunk_count()
+
+ def reassemble(
+ self,
+ output_path: Path | str | None = None,
+ input_dir: Path | str | None = None,
+ progress_callback: Callable[[int, int], None] | None = None,
+ ) -> Path:
+ """Reassemble split chunks back into the original file.
+
+ Uses split_file_reader to intelligently handle reassembly by reading
+ the chunk metadata and combining them in the correct order.
+
+ Args:
+ output_path: Path for the reassembled file (defaults to self.file_path)
+ input_dir: Directory containing the chunk files (defaults to current directory)
+ progress_callback: Optional callback function(bytes_read, total_bytes)
+ for progress reporting
+
+ Returns:
+ Path: Path to the reassembled file
+
+ Raises:
+ FileNotFoundError: If chunk files are missing
+ ValueError: If sentinel file cannot be read or is invalid.
+
+ """
+ if output_path is None:
+ output_path = self.file_path
+
+ if input_dir is None:
+ input_dir = Path(".")
+ else:
+ input_dir = Path(input_dir)
+
+ output_path = Path(output_path)
+ # Search for chunk files in the input directory
+ search_pattern = input_dir / f"{self.compressed_output_file.name}*"
+ files = sorted(glob.glob(str(search_pattern)))
+ print(
+ f"Matching chunks with prefix: {search_pattern} got {[Path(f).name for f in files]}"
+ )
+ print(f"Reassembling {len(files)} chunks into {output_path}")
+
+ # Create a temporary directory to extract the zip contents
+ with tempfile.TemporaryDirectory() as temp_extract_dir:
+ with (
+ SplitFileReader(files, "r") as sfr,
+ zipfile.ZipFile(sfr, "r") as zip_reader, # type: ignore
+ ):
+ zip_reader.extractall(path=temp_extract_dir)
+
+ # The zip should contain one file - move it to the desired output path
+ extracted_files = list(Path(temp_extract_dir).iterdir())
+ if len(extracted_files) == 1 and extracted_files[0].is_file():
+ # Single file case - move it directly
+ shutil.copy2(extracted_files[0], output_path)
+ else:
+ # Multiple files or directory structure - copy the entire extracted content
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ for item in extracted_files:
+ if item.is_file():
+ shutil.copy2(item, output_path.parent / item.name)
+ else:
+ shutil.copytree(
+ item, output_path.parent / item.name, dirs_exist_ok=True
+ )
+
+ print(f"Successfully reassembled file: {output_path}")
+ return output_path
+
+ def download(
+ self,
+ url: str,
+ output_path: Path | str | None = None,
+ output_dir: Path | str | None = None,
+ progress_manager: NestedProgressBar | None = None,
+ ) -> Path:
+ """Download a file from a URL.
+
+ Supports both simple file downloads and split chunk downloads from GitHub.
+
+ Args:
+ url: URL to download (can be file URL or base URL for chunks)
+ output_path: Path where the downloaded file should be saved (for simple downloads)
+ output_dir: Directory to download chunks into (for chunk downloads)
+ progress_manager: Optional NestedProgressBar instance for progress reporting.
+
+ Returns:
+ Path: Path to the downloaded file.
+
+ Raises:
+ FileNotFoundError: If file cannot be found at the remote location
+ OSError: If download fails.
+
+ """
+ # Handle simple file download if output_path is provided
+ if output_path is not None:
+ output_path = Path(output_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ print(f"Downloading file from {url}")
+ try:
+ response = requests.get(url, allow_redirects=True, timeout=300)
+ response.raise_for_status()
+ with open(output_path, "wb") as f:
+ f.write(response.content)
+ print(f"Successfully downloaded to {output_path}")
+ return output_path
+ except requests.RequestException as e:
+ raise OSError(f"Failed to download from {url}: {e}") from e
+
+ if progress_manager is None:
+ progress_manager = NoOpProgressBar()
+
+ if output_dir is None:
+ output_dir = Path(".")
+ else:
+ output_dir = Path(output_dir)
+
+ # Use temp dir for downloads if configured, otherwise use output_dir
+ download_dir = self._get_work_dir() if self.use_temp_dir else output_dir
+ download_dir.mkdir(parents=True, exist_ok=True)
+
+ # Download sentinel file first to determine chunk count
+ sentinel_url = f"{url}/{self.sentinel_filename}"
+ sentinel_local = download_dir / self.sentinel_filename
+
+ print(f"Downloading sentinel file from {sentinel_url}")
+ try:
+ response = requests.get(sentinel_url, allow_redirects=True, timeout=300)
+ response.raise_for_status()
+ with open(sentinel_local, "w", encoding="utf-8") as f:
+ f.write(response.text)
+ except requests.RequestException as e:
+ raise FileNotFoundError(
+ f"Could not download sentinel file from {sentinel_url}: {e}"
+ ) from e
+
+ # Read sentinel to get chunk count
+ try:
+ with open(sentinel_local, encoding="utf-8") as f:
+ chunk_count = int(f.read().strip())
+ except ValueError as e:
+ raise ValueError(f"Invalid sentinel file format at {sentinel_local}") from e
+
+ print(f"Downloading {chunk_count} chunks from {url}")
+
+ with progress_manager.outer(
+ chunk_count,
+ description=f"Downloading {self.compressed_output_file.name} ",
+ ) as outer_pbar: # type: ignore
+ for i in range(1, chunk_count + 1):
+ chunk_filename = f"{self.compressed_output_file.name}.{i:03d}"
+ chunk_url = f"{url}/{chunk_filename}"
+ chunk_local = download_dir / chunk_filename
+
+ try:
+ with progress_manager.inner(
+ description=f"Downloading {chunk_filename}"
+ ) as inner_pbar: # type: ignore
+ response = requests.get(
+ chunk_url,
+ allow_redirects=True,
+ stream=True,
+ timeout=300,
+ )
+ response.raise_for_status()
+
+ # Get file size from headers
+ file_size = int(response.headers.get("Content-Length", 0))
+ inner_pbar.set_total(file_size)
+
+ # Download in chunks
+ with open(chunk_local, "wb") as f:
+ for chunk_data in response.iter_content(chunk_size=4096):
+ if chunk_data:
+ f.write(chunk_data)
+ inner_pbar.update(len(chunk_data))
+
+ outer_pbar.update()
+ except requests.RequestException as e:
+ raise OSError(
+ f"Failed to download chunk {chunk_filename} from {chunk_url}: {e}"
+ ) from e
+
+ # If using temp dir, copy files to output_dir
+ if self.use_temp_dir and download_dir != output_dir:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ for file in download_dir.glob(f"{self.compressed_output_file.name}*"):
+ shutil.copy2(file, output_dir / file.name)
+ print(f"Copied downloaded files to {output_dir}")
+
+ return output_dir
+
+ def download_and_reassemble(
+ self,
+ url: str,
+ output_path: Path | str | None = None,
+ output_dir: Path | str | None = None,
+ progress_manager: NestedProgressBar | None = None,
+ cleanup: bool = True,
+ ) -> Path:
+ """Download split chunks from GitHub and reassemble into final file.
+
+ This is a convenience method that combines download() and
+ reassemble() into a single workflow, automatically cleaning up all
+ intermediate files (chunks and compressed archive).
+
+ Args:
+ url: Base URL to the GitHub archive (without filename or chunk extension)
+ output_path: Final output filename (defaults to self.file_path)
+ output_dir: Directory for chunks and final output file (defaults to current directory)
+ progress_manager: Optional NestedProgressBar instance for progress reporting.
+ cleanup: If True, delete all intermediate files after reassembly
+ (default: True)
+
+ Returns:
+ Path: Path to the final reassembled file (uncompressed)
+
+ Raises:
+ FileNotFoundError: If sentinel file cannot be found or chunk files are missing
+ ValueError: If sentinel file format is invalid
+ OSError: If download or reassembly fails
+
+ """
+ if progress_manager is None:
+ progress_manager = NoOpProgressBar()
+
+ if output_dir is None:
+ output_dir = Path(".")
+ else:
+ output_dir = Path(output_dir)
+
+ if output_path is None:
+ output_path = self.file_path
+ else:
+ output_path = Path(output_path)
+
+ # Ensure output_path has correct parent directory
+ if output_path.parent != output_dir:
+ output_path = output_dir / output_path.name
+
+ try:
+ # Step 1: Download split files
+ print("Starting download and reassemble workflow")
+ print(f" Final output: {output_path}")
+ self.download(
+ url=url,
+ output_dir=output_dir,
+ progress_manager=progress_manager,
+ )
+
+ # Step 2: Reassemble the downloaded chunks
+ print("\nReassembling chunks...")
+ reassembled_file = self.reassemble(
+ output_path=output_path, input_dir=output_dir
+ )
+
+ # Step 3: Clean up intermediate files
+ if cleanup:
+ self._cleanup_intermediate_files(output_dir)
+
+ print("\n✓ Successfully completed download and reassembly")
+ print(f" Final file: {reassembled_file}")
+ print(f" Size: {reassembled_file.stat().st_size:,} bytes")
+
+ return reassembled_file
+
+ except Exception as e:
+ print(f"\n✗ Error during download and reassemble: {e}")
+ raise
+
+ def _cleanup_intermediate_files(self, directory: Path | str) -> None:
+ """Clean up intermediate chunk and compressed files.
+
+ Removes:
+ - Chunk files (e.g., parts-fts5.db.001, .002, etc.)
+ - Compressed archive (e.g., parts-fts5.db.zip)
+ - Sentinel file (e.g., chunk_num_fts5.txt)
+
+ Args:
+ directory: Directory containing intermediate files
+
+ """
+ directory = Path(directory)
+ files_deleted = 0
+
+ # Delete chunk files
+ for chunk_file in directory.glob(f"{self.compressed_output_file.name}.*"):
+ try:
+ chunk_file.unlink()
+ files_deleted += 1
+ print(f" Deleted: {chunk_file.name}")
+ except OSError as e:
+ print(f" Warning: Could not delete {chunk_file.name}: {e}")
+
+ # Delete sentinel file
+ sentinel_path = directory / self.sentinel_filename
+ if sentinel_path.exists():
+ try:
+ sentinel_path.unlink()
+ files_deleted += 1
+ print(f" Deleted: {sentinel_path.name}")
+ except OSError as e:
+ print(f" Warning: Could not delete {sentinel_path.name}: {e}")
+
+ print(f"Cleaned up {files_deleted} intermediate files")
+
+
+def main() -> None:
+ """Download parts-fts5.db files from GitHub."""
+
+ parser = argparse.ArgumentParser(
+ description="Download parts-fts5.db split files from GitHub release"
+ )
+ parser.add_argument(
+ "-u",
+ "--url",
+ type=str,
+ default="https://bouni.github.io/kicad-jlcpcb-tools/",
+ help="Base URL to GitHub release directory containing split files",
+ )
+ parser.add_argument(
+ "-o",
+ "--output",
+ type=Path,
+ default=Path("."),
+ help="Output directory for downloaded files (default: current directory)",
+ )
+ parser.add_argument(
+ "--reassemble",
+ action="store_true",
+ default=True,
+ help="Reassemble files after downloading",
+ )
+ parser.add_argument(
+ "--output-file",
+ type=Path,
+ help="Output filename for reassembled file (only with --reassemble)",
+ )
+
+ args = parser.parse_args()
+
+ output_dir = Path(args.output)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"Downloading parts-fts5.db files from {args.url}")
+ print(f"Output directory: {output_dir.absolute()}")
+
+ try:
+ manager = FileManager(
+ file_path="parts-fts5.db", sentinel_filename="chunk_num_fts5.txt"
+ )
+
+ # Download and reassemble in a single operation with cleanup
+ output_file = args.output_file or (Path(args.output) / "parts-fts5.db")
+ manager.download_and_reassemble(
+ args.url,
+ output_dir=args.output,
+ output_path=output_file,
+ progress_manager=TqdmNestedProgressBar(),
+ cleanup=True,
+ )
+
+ except FileNotFoundError as e:
+ print(f"\n✗ File not found: {e}")
+ except ValueError as e:
+ print(f"\n✗ Invalid sentinel file: {e}")
+ except OSError as e:
+ print(f"\n✗ Download error: {e}")
+ except Exception as e:
+ print(f"\n✗ Unexpected error: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py b/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py
new file mode 100644
index 00000000..75bd9d57
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/jlcapi.py
@@ -0,0 +1,343 @@
+"""JLCPCB API interaction classes and utilities."""
+
+from collections.abc import Generator
+import json
+import time
+from typing import Any, NamedTuple
+
+from cachetools import TTLCache, cached
+from ratelimit import limits, sleep_and_retry
+import requests
+from retry import retry
+
+
+class ApiCategory(NamedTuple):
+ """Component category from JLCPCB API."""
+
+ primary: str
+ secondary: str
+ count: int
+
+ def __repr__(self) -> str | None:
+ """Return the string representation of the ApiCategory."""
+ return (
+ f"{self.primary} {f'| {self.secondary}' if self.secondary else ''} ({self.count})"
+ if self.primary
+ else ""
+ )
+
+
+class JlcApi:
+ """Stateless class to interact with the JLCPCB API.
+
+ This is mostly a collection of static methods for fetching data.
+ """
+
+ BASE_URL = "https://jlcpcb.com/api/overseas-pcb-order/v1/shoppingCart/smtGood"
+
+ @cached(cache=TTLCache(maxsize=1, ttl=180))
+ @retry(Exception, tries=5, delay=2, backoff=2)
+ @staticmethod
+ def getToken() -> str:
+ """Fetch a new XSRF token from JLCPCB. Caches the result for 3 minutes."""
+ resp = requests.get(f"{JlcApi.BASE_URL}/getXSRFToken")
+ token = resp.cookies.get_dict()["XSRF-TOKEN"]
+ return token
+
+ @staticmethod
+ def componentList(token: str, request: dict[str, Any]) -> Any:
+ """Fetch component list from JLCPCB API.
+
+ This doesn't retry explicitly but is intended to be called from a method
+ decorated with @retry.
+
+ Args:
+ token: XSRF token string
+ request: Request payload as a dictionary
+
+ Returns:
+ Parsed JSON response body
+
+ """
+ url = f"{JlcApi.BASE_URL}/selectSmtComponentList"
+ headers = {
+ "Content-Type": "application/json",
+ "X-XSRF-TOKEN": token,
+ }
+ resp = requests.post(url, headers=headers, json=request)
+ if resp.status_code != 200:
+ raise RuntimeError(f"Cannot fetch component list: {resp.text}")
+ body = resp.json()
+ if body["code"] in [563, 564, 404, 429]:
+ # Intentionally don't raise an exception here - these are effectively
+ # "no data" responses so retrying doesn't help.
+ return {}
+ if body["code"] != 200:
+ raise RuntimeError(f"{body['code']}: {body['message']}")
+ return body
+
+ @retry(Exception, tries=5, delay=30, backoff=2)
+ @staticmethod
+ def fetchCategories(instockOnly: bool) -> list[ApiCategory]:
+ """Fetch component categories from JLCPCB.
+
+ Grabs the list of all categories available via the API along with
+ their stock counts.
+
+ Args:
+ instockOnly: If True, only fetch categories and counts of in-stock items
+
+ Returns:
+ List of ApiCategory objects (which are simple wrappers around primary/secondary/count)
+
+ """
+ token = JlcApi.getToken()
+ request = {
+ "searchType": 1,
+ "presaleTypes": ["stock"] if instockOnly else [],
+ }
+ body = JlcApi.componentList(token, request)
+ categories = []
+ for primary in body["data"]["sortAndCountVoList"]:
+ primaryName = primary["sortName"]
+ for secondary in primary["childSortList"]:
+ secondaryName = secondary["sortName"]
+ count = secondary["componentCount"]
+ categories.append(ApiCategory(primaryName, secondaryName, count))
+ return categories
+
+ @staticmethod
+ def collapseCategories(
+ categories: list[ApiCategory], limit: int
+ ) -> list[ApiCategory]:
+ """Collapse small secondary categories into their primary category.
+
+ JLC's API limits the size of a page to 1000 items, and allows only 100 pages per
+ query, so categories with more than 100,000 items need to be split by subcategory.
+ However, most top-level categories don't need to be split to stay under this limit.
+ (I have no idea what will happen if a single secondary category exceeds this limit -
+ The SMD resistor category has ~90K in-stock items as of early 2026)
+
+ For each primary category, if the sum of all secondary categories under it
+ is less than the limit, replace them all with a single category with an
+ empty secondary category name. This is useful to reduce the number of API
+ calls to JLC - there are lots of tiny subcategories that we would otherwise
+ make separate requests for.
+
+ Args:
+ categories: Full list of ApiCategory objects
+ limit: Minimum count threshold for keeping secondary categories
+
+ Returns:
+ Modified list of categories with collapsed entries
+
+ """
+ # Group categories by primary
+ primary_groups: dict[str, list[ApiCategory]] = {}
+ for cat in categories:
+ if cat.primary not in primary_groups:
+ primary_groups[cat.primary] = []
+ primary_groups[cat.primary].append(cat)
+
+ result = []
+ for primary, cats in primary_groups.items():
+ total_count = sum(cat.count for cat in cats)
+
+ if total_count < limit:
+ # Collapse: replace all secondary categories with one collapsed entry
+ result.append(ApiCategory(primary, "", total_count))
+ else:
+ # Keep as-is
+ result.extend(cats)
+ return result
+
+
+class CategoryFetch:
+ """Class to manage the lifecycle of fetching components from a category.
+
+ Args:
+ category: ApiCategory object representing the category to fetch
+ rateLimit: If True, apply rate limiting to API calls (default: True)
+ pageSize: Number of items per page to fetch (default: 1000, maximum allowed by API)
+
+ """
+
+ def __init__(
+ self, category: ApiCategory, rateLimit: bool = True, pageSize: int = 1000
+ ) -> None:
+ self.category = category
+ self.instockOnly = True
+ self.pageSize = pageSize
+ self.rateLimit = rateLimit
+ self.currentPage = 1
+ self.token = JlcApi.getToken()
+
+ @sleep_and_retry
+ @limits(calls=1, period=3)
+ def _rateLimitedNextPage(self) -> list[Any]:
+ """Rate-limited wrapper around _fetchNextPage."""
+ return self._fetchNextPage()
+
+ @retry(Exception, tries=5, delay=2, backoff=2)
+ def _fetchNextPage(self) -> list[Any]:
+ request = {
+ "searchType": 2,
+ "presaleTypes": ["stock"] if self.instockOnly else [],
+ "firstSortName": self.category.primary,
+ "currentPage": self.currentPage,
+ "pageSize": self.pageSize,
+ }
+ if self.category.secondary:
+ request["secondSortName"] = self.category.secondary
+ body = JlcApi.componentList(self.token, request)
+ if not body:
+ return []
+ components = body["data"]["componentPageInfo"]["list"]
+ self.currentPage += 1
+ return components
+
+ def fetchAll(self) -> Generator[list[Any], None, None]:
+ """Fetch all components in the category, yielding each page.
+
+ Yields:
+ List of component dicts for each page fetched from the API.
+
+ """
+ while True:
+ components = (
+ self._rateLimitedNextPage() if self.rateLimit else self._fetchNextPage()
+ )
+ if not components:
+ break
+ yield components
+
+
+class LcscId:
+ """LCSC ID wrapper.
+
+ It can be confusing whether you're working with an LCSC ID as a string like "C12345"
+ or as an integer like 12345. This class wraps both representations and provides
+ conversion methods. Generally the DB wants integer format, while the API and user-facing
+ tools want string format.
+ """
+
+ def __init__(self, lcsc: str | int) -> None:
+ self.lcsc = lcsc
+
+ def toDbKey(self) -> int:
+ """Convert to database / integer format."""
+ if isinstance(self.lcsc, int):
+ return self.lcsc
+ return int(self.lcsc[1:])
+
+ def toComponent(self) -> str:
+ """Convert to API / string format."""
+ if isinstance(self.lcsc, str) and self.lcsc.startswith("C"):
+ return self.lcsc
+ return f"C{self.lcsc}"
+
+
+class Component(dict[str, Any]):
+ """Component from JLCPCB API."""
+
+ def __init__(self, data: dict[str, Any]) -> None:
+ super().__init__(data)
+ self.data = data
+
+ def asDatabaseRow(self) -> dict[str, Any]:
+ """Convert to ComponentDatabase row format."""
+ return {
+ "lcsc": LcscId(self["componentCode"]).toDbKey(),
+ "category_id": self["category_id"],
+ "manufacturer_id": self["manufacturer_id"],
+ "joints": 0, # Set to zero, not in API, needed for inseting new items
+ "mfr": self["componentModelEn"],
+ "package": self["componentSpecificationEn"],
+ "manufacturer": self["componentBrandEn"],
+ "basic": 1 if self["componentLibraryType"] == "base" else 0,
+ "preferred": 1 if self["preferredComponentFlag"] else 0,
+ "description": self["describe"],
+ "datasheet": self["dataManualUrl"]
+ or "https://jlcpcb.com/partdetail/" + self["urlSuffix"],
+ "stock": self["stockCount"],
+ "price": self.translated_component_prices(),
+ "extra": self.stripForExtra(),
+ "last_update": int(time.time()),
+ "last_on_stock": int(time.time()),
+ }
+
+ def categoryKey(self) -> tuple[str, str]:
+ """Get the category key (primary, secondary).
+
+ For whatever reason, the JLCPCB API uses "secondSortName" for primary
+ categories and "firstSortName" for secondary categories on the returned
+ components, despite the opposite naming on the category listing endpoint.
+ """
+ return (self["secondSortName"], self["firstSortName"])
+
+ def manufacturerKey(self) -> str:
+ """Get the manufacturer name."""
+ return self["componentBrandEn"]
+
+ def stripForExtra(self) -> str:
+ """Get extra data as JSON string, excluding known fields."""
+ extra_data = self.data.copy()
+ # Remove fields that are already represented elsewhere
+ for field in [
+ "componentCode",
+ "firstSortName",
+ "secondSortName",
+ "componentModelEn",
+ "componentSpecificationEn",
+ "componentBrandEn",
+ "componentLibraryType",
+ "preferredComponentFlag",
+ "describe",
+ "dataManualUrl",
+ "componentPriceList",
+ "imageList",
+ "componentPrices",
+ "buyComponentPrices",
+ ]:
+ extra_data.pop(field, None)
+ for key in list(extra_data.keys()):
+ if extra_data[key] is None:
+ extra_data.pop(key)
+ return json.dumps(extra_data)
+
+ def translated_component_prices(self) -> str:
+ """Translate component prices from JLC API format to internal format.
+
+ Input format:
+ [
+ {"startNumber": 1, "endNumber": 199, "productPrice": 0.0122},
+ {"startNumber": 200, "endNumber": 599, "productPrice": 0.0098},
+ ...
+ ]
+
+ Output format:
+ '[{"qFrom": 1, "qTo": 199, "price": 0.0122}, ...]'
+
+ Args:
+ component_prices: List of price brackets from the API
+
+ Returns:
+ JSON string of transformed price brackets
+
+ """
+ component_prices = self["componentPrices"]
+ if not component_prices:
+ return "[]"
+
+ # Transform the price brackets
+ transformed_prices = []
+ for bracket in component_prices:
+ transformed_bracket = {
+ "qFrom": bracket["startNumber"],
+ "qTo": bracket["endNumber"] if bracket["endNumber"] != -1 else None,
+ "price": bracket["productPrice"],
+ }
+ transformed_prices.append(transformed_bracket)
+
+ # Return as JSON string
+ return json.dumps(transformed_prices)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py
new file mode 100644
index 00000000..15af92e3
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/partsdb.py
@@ -0,0 +1,321 @@
+"""Parts database generation and management."""
+
+from datetime import date, datetime
+import os
+from pathlib import Path
+import sqlite3
+from typing import Any
+
+import humanize
+
+from .componentdb import ComponentsDatabase
+from .filemgr import FileManager
+from .progress import NestedProgressBar
+from .translate import ComponentTranslator
+
+# Register date adapter to avoid deprecation warning
+# See: https://docs.python.org/3/library/sqlite3.html#sqlite3-adapter-converter-recipes
+
+
+def _adapt_date(val: date) -> str:
+ """Adapt datetime.date to ISO format string for SQLite storage."""
+ return val.isoformat()
+
+
+def _convert_date(val: bytes) -> date:
+ """Convert ISO format string from SQLite back to datetime.date."""
+ return date.fromisoformat(val.decode())
+
+
+sqlite3.register_adapter(date, _adapt_date)
+sqlite3.register_converter("date", _convert_date)
+
+_CREATE_STATEMENTS = [
+ """
+ CREATE virtual TABLE IF NOT EXISTS parts using fts5 (
+ 'LCSC Part',
+ 'First Category',
+ 'Second Category',
+ 'MFR.Part',
+ 'Package',
+ 'Solder Joint' unindexed,
+ 'Manufacturer',
+ 'Library Type',
+ 'Description',
+ 'Datasheet' unindexed,
+ 'Price' unindexed,
+ 'Stock' unindexed
+ , tokenize="trigram")
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS mapping (
+ 'footprint',
+ 'value',
+ 'LCSC'
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS meta (
+ 'filename',
+ 'size',
+ 'partcount',
+ 'date',
+ 'last_update'
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS categories (
+ 'First Category',
+ 'Second Category'
+ )
+ """,
+]
+
+
+class Generate:
+ """Orchestrates the generation of parts database from component data.
+
+ This class manages the workflow of fetching components from a component database,
+ translating them to parts database format, and reporting statistics.
+ """
+
+ def __init__(
+ self,
+ componentdb: "ComponentsDatabase",
+ partsdb: "PartsDatabase",
+ progress: "NestedProgressBar",
+ translator: ComponentTranslator | None = None,
+ populate_preferred: bool = False,
+ ):
+ """Initialize the generator.
+
+ Args:
+ componentdb: ComponentsDatabase instance to fetch components from
+ partsdb: PartsDatabase instance to insert translated parts into
+ translator: Optional pre-configured ComponentTranslator. If not provided,
+ one will be created from the componentdb lookup tables.
+ progress: Optional NestedProgressBar for progress reporting. If not provided,
+ progress will be printed to console.
+ populate_preferred: Whether to populate preferred parts as 'Preferred'
+ or the backwards-compatible 'Extended'.
+
+
+ """
+ self.componentdb = componentdb
+ self.partsdb = partsdb
+ self.translator = translator
+ self.progress = progress
+ self.populate_preferred = populate_preferred
+ self.total_components = 0
+ self.loaded_components = 0
+
+ def generate(self, where_clause: str = "") -> None:
+ """Generate parts database by translating components.
+
+ Fetches components from the component database in batches, translates them,
+ and inserts them into the parts database. Statistics are tracked and reported.
+
+ Args:
+ where_clause: Optional SQL WHERE clause to filter components.
+
+ """
+ # Initialize translator if not provided
+ if self.translator is None:
+ manufacturers = self.componentdb.get_manufacturers()
+ categories = self.componentdb.get_categories()
+ self.translator = ComponentTranslator(
+ manufacturers, categories, populate_preferred=self.populate_preferred
+ )
+
+ total_components = self.componentdb.count_components(where_clause=where_clause)
+ self.total_components = total_components
+ print(
+ f"Translating {humanize.intcomma(total_components)} components to parts database"
+ )
+
+ # Use progress bar if provided, otherwise just iterate
+ with self.progress.outer(total_components, "Translating components") as pbar: # type: ignore
+ self._process_batches(where_clause, pbar)
+
+ self.partsdb.post_build()
+ print("Done importing parts")
+
+ def _process_batches(self, where_clause: str, pbar=None) -> None:
+ """Process component batches.
+
+ Args:
+ where_clause: SQL WHERE clause to filter components.
+ pbar: Optional progress bar callback for updating outer bar.
+
+ """
+ batch_count = 0
+ for batch in self.componentdb.fetch_components(where_clause=where_clause):
+ batch_count += 1
+
+ # Translate rows
+ translated_rows = []
+ for component_row in batch:
+ translated_row = self.translator.translate(component_row) # type: ignore
+ translated_rows.append(translated_row)
+ self.loaded_components += len(batch)
+
+ if pbar is None:
+ print(
+ f"Processed {humanize.intcomma(self.loaded_components)} / "
+ + f"{humanize.intcomma(self.total_components)} components"
+ )
+ else:
+ pbar.update(len(batch))
+
+ # Insert into parts database
+ self.partsdb.update_parts(translated_rows)
+
+ def report_stats(self) -> None:
+ """Report statistics from the translation process."""
+ if self.translator is None:
+ print("No data processed yet")
+ return
+
+ (
+ price_entries_total,
+ price_entries_deleted_total,
+ price_entries_duplicates_deleted_total,
+ ) = self.translator.get_statistics()
+ print("Translation Statistics:")
+ print(f"Total price entries processed: {price_entries_total}")
+ print(
+ f"Price value filtering trimmed {price_entries_deleted_total} (including {price_entries_duplicates_deleted_total} duplicates) out of {price_entries_total} entries {(price_entries_deleted_total / price_entries_total) * 100 if price_entries_total != 0 else 0:.2f}%"
+ )
+
+
+class PartsDatabase:
+ """Manages generation of parts database from component data."""
+
+ def __init__(
+ self,
+ output_db: Path,
+ archive_dir: Path,
+ chunk_num: Path = Path("chunk_num_fts5.txt"),
+ skip_cleanup: bool = False,
+ ):
+ """Initialize the parts database manager.
+
+ Args:
+ output_db: Path where the output database will be written
+ archive_dir: Directory where split archive files will be stored
+ chunk_num: Path to chunk number sentinel file
+ jlcparts_db_name: Name of the source jlcparts database
+ skip_cleanup: If True, don't delete temporary files after splitting
+
+ """
+ self.output_db = output_db
+ self.archive_dir = archive_dir
+ self.chunk_num = chunk_num
+ self.skip_cleanup = skip_cleanup
+
+ self.remove_original()
+ self.conn = sqlite3.connect(self.output_db)
+ self.part_count = 0
+ self.create_tables()
+
+ def remove_original(self):
+ """Remove the original output database."""
+ if self.output_db.exists():
+ self.output_db.unlink()
+
+ def close_sqlite(self):
+ """Close sqlite connections."""
+ self.conn.close()
+
+ def create_tables(self):
+ """Create tables."""
+ for stmt in _CREATE_STATEMENTS:
+ self.conn.execute(stmt)
+ self.conn.commit()
+
+ def update_parts(self, rows: list[dict[str, Any]]) -> None:
+ """Update parts database with a list of component rows.
+
+ Args:
+ rows: List of sqlite3.Row objects from components table
+ translator: ComponentTranslator to use for translating rows
+
+ """
+ if not rows:
+ return
+
+ data = rows[0]
+ columns = ", ".join([f'"{k}"' for k in data])
+ placeholders = ", ".join(
+ [f":{k.replace(' ', '_').replace('.', '_')}" for k in data]
+ )
+ newrows = [
+ {k.replace(" ", "_").replace(".", "_"): v for k, v in row.items()}
+ for row in rows
+ ]
+ self.conn.executemany(
+ f"INSERT INTO parts ({columns}) VALUES ({placeholders})", newrows
+ )
+ self.conn.commit()
+
+ self.part_count += len(rows)
+
+ def populate_categories(self):
+ """Populate the categories table."""
+ self.conn.execute(
+ 'INSERT INTO categories SELECT DISTINCT "First Category", "Second Category" FROM parts ORDER BY UPPER("First Category"), UPPER("Second Category")'
+ )
+
+ def optimize(self):
+ """FTS5 optimize to minimize query times."""
+ print("Optimizing fts5 parts table")
+ self.conn.execute("insert into parts(parts) values('optimize')")
+ print("Done optimizing fts5 parts table")
+
+ def meta_data(self):
+ """Populate the metadata table."""
+ db_size = os.stat(self.output_db).st_size
+ self.conn.execute(
+ "INSERT INTO meta VALUES(?, ?, ?, ?, ?)",
+ [
+ "cache.sqlite3",
+ db_size,
+ self.part_count,
+ date.today().isoformat(),
+ datetime.now().isoformat(),
+ ],
+ )
+ self.conn.commit()
+
+ def split(self):
+ """Split the compressed database so we stay below GitHub's 100MB limit.
+
+ Uses FileManager to split the file and create a sentinel file.
+ This maintains compatibility with the previous output format.
+ """
+ file_manager = FileManager(
+ file_path=self.output_db,
+ chunk_size=80000000, # 80 MB to stay well below GitHub's 100MB limit
+ sentinel_filename=str(self.chunk_num),
+ )
+ file_manager.compress_and_split(output_dir=self.archive_dir)
+
+ def cleanup(self):
+ """Remove the compressed zip file and output db after splitting."""
+ print(f"Deleting {self.output_db}")
+ os.unlink(self.output_db)
+
+ def post_build(self):
+ """Actions to perform after building the database."""
+ print(
+ f"Generated parts database with {humanize.intcomma(self.part_count)} parts"
+ )
+ self.populate_categories()
+ self.meta_data()
+ self.optimize()
+ self.close_sqlite()
+ self.split()
+ if self.skip_cleanup:
+ print("Skipping cleanup")
+ else:
+ self.cleanup()
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py b/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py
new file mode 100644
index 00000000..54501d03
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/progress.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+
+"""Progress bar management for nested operations.
+
+Provides abstract and concrete implementations for tracking nested progress
+during file operations.
+"""
+
+from abc import ABC
+from collections.abc import Callable
+from contextlib import contextmanager
+
+import tqdm
+
+
+class NestedProgressBar(ABC):
+ """Abstract base class for nested progress bar management.
+
+ Provides context manager methods for managing outer and inner progress bars
+ with automatic cleanup.
+ """
+
+ def outer(self, total: int, description: str = ""):
+ """Context manager for outer progress bar.
+
+ Args:
+ total: Total number of items for outer progress.
+ description: Description for the outer progress bar.
+
+ Yields:
+ ProgressCallback: Object with update() method for outer progress.
+
+ """
+
+ yield ProgressCallback(lambda inc: None)
+
+ def inner(self, total: int | None = None, description: str = ""):
+ """Context manager for inner progress bar.
+
+ Args:
+ total: Total number of items for inner progress (default: None).
+ description: Description for the inner progress bar.
+
+ Yields:
+ ProgressCallback: Object with update() and set_total() methods.
+
+ """
+
+ yield InnerProgressCallback(None, lambda inc: None)
+
+
+class ProgressCallback:
+ """Helper class to track progress updates."""
+
+ def __init__(self, callback: Callable[[int], None]) -> None:
+ """Initialize with a callback function.
+
+ Args:
+ callback: Function to call with increment amount.
+
+ """
+ self.callback = callback
+
+ def update(self, increment: int = 1) -> None:
+ """Update progress.
+
+ Args:
+ increment: Amount to advance.
+
+ """
+ self.callback(increment)
+
+ def __call__(self, increment: int = 1) -> None:
+ """Allow direct calling as function."""
+ self.update(increment)
+
+
+class InnerProgressCallback(ProgressCallback):
+ """Extended progress callback with ability to update total."""
+
+ def __init__(self, bar: tqdm.tqdm | None, callback: Callable[[int], None]) -> None:
+ """Initialize with bar reference and callback.
+
+ Args:
+ bar: The tqdm progress bar instance.
+ callback: Function to call with increment amount.
+
+ """
+ super().__init__(callback)
+ self.bar = bar
+
+ def set_total(self, total: int) -> None:
+ """Update the total size.
+
+ Args:
+ total: New total size.
+
+ """
+ if self.bar is not None:
+ self.bar.total = total
+
+
+class TqdmNestedProgressBar(NestedProgressBar):
+ """Nested progress bar implementation using tqdm with context managers."""
+
+ def __init__(self) -> None:
+ """Initialize the nested progress bar."""
+ self.outer_bar: tqdm.tqdm | None = None
+ self.inner_bar: tqdm.tqdm | None = None
+
+ @contextmanager
+ def outer(self, total: int, description: str = ""):
+ """Context manager for outer progress bar.
+
+ Usage:
+ with progress.outer(10, "Processing") as pbar:
+ for i in range(10):
+ pbar.update()
+
+ Args:
+ total: Total number of items for outer progress.
+ description: Description for the outer progress bar.
+
+ Yields:
+ ProgressCallback: Object with update() method.
+
+ """
+ self.outer_bar = tqdm.tqdm(
+ total=total,
+ desc=description,
+ unit="item",
+ position=0,
+ )
+ try:
+
+ def _update_outer(inc: int) -> None:
+ if self.outer_bar is not None:
+ self.outer_bar.update(inc)
+
+ yield ProgressCallback(_update_outer)
+ finally:
+ if self.outer_bar is not None:
+ self.outer_bar.close()
+ self.outer_bar = None
+
+ @contextmanager
+ def inner(self, total: int | None = None, description: str = ""):
+ """Context manager for inner progress bar.
+
+ Usage:
+ with progress.inner(1024, "File") as pbar:
+ pbar.update(256)
+ pbar.set_total(2048) # Update total if needed
+
+ Args:
+ total: Total number of items for inner progress.
+ description: Description for the inner progress bar.
+
+ Yields:
+ InnerProgressCallback: Object with update() and set_total() methods.
+
+ """
+ if total is None:
+ total = 0
+
+ self.inner_bar = tqdm.tqdm(
+ total=total,
+ desc=description,
+ unit_divisor=1024,
+ miniters=1,
+ leave=False,
+ position=1,
+ )
+ try:
+
+ def _update_inner(inc: int) -> None:
+ if self.inner_bar is not None:
+ self.inner_bar.update(inc)
+
+ yield InnerProgressCallback(self.inner_bar, _update_inner)
+ finally:
+ if self.inner_bar is not None:
+ self.inner_bar.close()
+ self.inner_bar = None
+
+
+class NoOpProgressBar(NestedProgressBar):
+ """No-operation progress bar that does nothing.
+
+ Useful as a default when progress tracking is not desired.
+ """
+
+ @contextmanager
+ def outer(self, total: int, description: str = ""):
+ """Context manager for outer progress bar (no-op).
+
+ Args:
+ total: Total number of items for outer progress (ignored).
+ description: Description for the outer progress bar (ignored).
+
+ Yields:
+ ProgressCallback: Silent callback object.
+
+ """
+
+ def _update_outer(inc: int) -> None:
+ pass
+
+ yield ProgressCallback(_update_outer)
+
+ @contextmanager
+ def inner(self, total: int | None = None, description: str = ""):
+ """Context manager for inner progress bar (no-op).
+
+ Args:
+ total: Total number of items for inner progress (ignored).
+ description: Description for the inner progress bar (ignored).
+
+ Yields:
+ InnerProgressCallback: Silent callback object.
+
+ """
+
+ class NoOpInnerCallback(InnerProgressCallback):
+ """Silent inner callback."""
+
+ def __init__(self) -> None:
+ """Initialize silent callback."""
+ super().__init__(None, lambda _: None)
+
+ def set_total(self, total: int) -> None:
+ """Update total (no-op)."""
+ pass
+
+ yield NoOpInnerCallback()
+
+
+class PrintNestedProgressBar(NestedProgressBar):
+ """Simple progress bar implementation using print statements.
+
+ Emits progress lines when configured thresholds are reached.
+ Useful for simple console output without fancy progress bar rendering.
+ """
+
+ def __init__(
+ self, outer_threshold: int = 100000, inner_threshold: int = 100000
+ ) -> None:
+ """Initialize the print-based progress bar.
+
+ Args:
+ outer_threshold: Number of items to process before printing outer progress.
+ inner_threshold: Number of items to process before printing inner progress.
+
+ """
+ self.outer_threshold = outer_threshold
+ self.inner_threshold = inner_threshold
+ self.outer_description = ""
+ self.inner_description = ""
+ self.outer_count = 0
+ self.inner_count = 0
+ self.outer_total = 0
+ self.inner_total = 0
+ self.outer_accumulator = 0
+ self.inner_accumulator = 0
+
+ @contextmanager
+ def outer(self, total: int, description: str = ""):
+ """Context manager for outer progress bar.
+
+ Usage:
+ with progress.outer(100000, "Processing") as pbar:
+ for i in range(100000):
+ pbar.update(1000)
+
+ Args:
+ total: Total number of items for outer progress.
+ description: Description for the outer progress bar.
+
+ Yields:
+ ProgressCallback: Object with update() method.
+
+ """
+ self.outer_total = total
+ self.outer_description = description
+ self.outer_count = 0
+ self.outer_accumulator = 0
+
+ print(f"Starting: {description} (total: {total})")
+
+ def _update_outer(inc: int) -> None:
+ self.outer_count += inc
+ self.outer_accumulator += inc
+ if (
+ self.outer_accumulator >= self.outer_threshold
+ or self.outer_count >= self.outer_total
+ ):
+ print(
+ f"{self.outer_description}: {self.outer_count}/{self.outer_total}"
+ )
+ self.outer_accumulator = 0
+
+ try:
+ yield ProgressCallback(_update_outer)
+ finally:
+ if self.outer_count > 0:
+ print(
+ f"Completed: {self.outer_description} ({self.outer_count}/{self.outer_total})"
+ )
+
+ @contextmanager
+ def inner(self, total: int = 0, description: str = ""):
+ """Context manager for inner progress bar.
+
+ Usage:
+ with progress.outer(100, "Outer") as outer_pbar:
+ with progress.inner(1000, "Inner") as inner_pbar:
+ for i in range(1000):
+ inner_pbar.update()
+
+ Args:
+ total: Total number of items for inner progress.
+ description: Description for the inner progress bar.
+
+ Yields:
+ InnerProgressCallback: Object with update() and set_total() methods.
+
+ """
+ self.inner_total = total
+ self.inner_description = description
+ self.inner_count = 0
+ self.inner_accumulator = 0
+
+ if description:
+ print(
+ f" Starting: {description}" + (f" (total: {total})" if total else "")
+ )
+
+ def _update_inner(inc: int) -> None:
+ self.inner_count += inc
+ self.inner_accumulator += inc
+ if (
+ self.inner_accumulator >= self.inner_threshold
+ or self.inner_count >= self.inner_total
+ ):
+ print(
+ f" {self.inner_description}: {self.inner_count}/{self.inner_total}"
+ )
+ self.inner_accumulator = 0
+
+ class PrintInnerCallback(InnerProgressCallback):
+ """Inner progress callback using print statements."""
+
+ def __init__(self, callback: Callable[[int], None]) -> None:
+ """Initialize with callback."""
+ super().__init__(None, callback)
+ self._total = total
+
+ def set_total(self, new_total: int) -> None:
+ """Update the total size."""
+ nonlocal total
+ total = new_total
+ self._total = new_total
+ self.bar = None # Don't use tqdm bar
+
+ try:
+ yield PrintInnerCallback(_update_inner)
+ finally:
+ if description and self.inner_count > 0:
+ print(
+ f" Completed: {description}"
+ + (
+ f" ({self.inner_count}/{self.inner_total})"
+ if self.inner_total
+ else f" ({self.inner_count})"
+ )
+ )
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py
new file mode 100644
index 00000000..ce85f7aa
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_componentdb.py
@@ -0,0 +1,580 @@
+"""Tests for the componentdb module."""
+
+import json
+from pathlib import Path
+import shutil
+import sqlite3
+import sys
+import tempfile
+import time
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import pytest
+
+from common.componentdb import _CREATE_STATEMENTS, ComponentsDatabase, fixDescription
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+
+@pytest.fixture
+def temp_dir():
+ """Create a temporary test directory and clean up after test."""
+ test_dir = Path(tempfile.mkdtemp(prefix="test_componentdb_"))
+ yield test_dir
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+@pytest.fixture
+def temp_db(temp_dir):
+ """Create a temporary test database and clean up after test."""
+ db_path = temp_dir / "test_components.db"
+ db = ComponentsDatabase(str(db_path))
+ yield db
+ db.close()
+
+
+# ============================================================================
+# fixDescription Tests
+# ============================================================================
+
+
+class TestFixDescription:
+ """Tests for fixDescription utility function."""
+
+ def test_fix_description_with_empty_description(self):
+ """FixDescription extracts description from extra JSON when empty."""
+ extra_json = json.dumps({"description": "Extracted description"})
+ result = fixDescription("", extra_json)
+ assert result == "Extracted description"
+
+ def test_fix_description_with_none_description(self):
+ """FixDescription handles None description."""
+ extra_json = json.dumps({"description": "Extracted description"})
+ result = fixDescription(None, extra_json)
+ assert result == "Extracted description"
+
+ def test_fix_description_preserves_existing(self):
+ """FixDescription preserves existing non-empty description."""
+ extra_json = json.dumps({"description": "Should not use this"})
+ result = fixDescription("Original description", extra_json)
+ assert result == "Original description"
+
+ def test_fix_description_falls_back_to_describe(self):
+ """FixDescription falls back to 'describe' key if 'description' missing."""
+ extra_json = json.dumps({"describe": "Describe fallback"})
+ result = fixDescription("", extra_json)
+ assert result == "Describe fallback"
+
+ def test_fix_description_invalid_json(self):
+ """FixDescription handles invalid JSON gracefully."""
+ result = fixDescription("", "invalid json")
+ assert result == ""
+
+ def test_fix_description_no_description_keys(self):
+ """FixDescription returns empty string when no description keys found."""
+ extra_json = json.dumps({"other_key": "value"})
+ result = fixDescription("", extra_json)
+ assert result == ""
+
+ def test_fix_description_empty_extra_json(self):
+ """FixDescription handles empty extra JSON."""
+ result = fixDescription("", "{}")
+ assert result == ""
+
+
+# ============================================================================
+# ComponentsDatabase Tests
+# ============================================================================
+
+
+def _insert_component(
+ db,
+ lcsc,
+ category_id=1,
+ mfr="MFR",
+ package="0805",
+ joints=2,
+ manufacturer_id=1,
+ basic=1,
+ description="Component",
+ datasheet="http://example.com",
+ stock=100,
+ price="[]",
+ last_update=None,
+ extra=None,
+):
+ """Insert a component into the test database."""
+ if last_update is None:
+ last_update = int(time.time())
+
+ params = [
+ lcsc,
+ category_id,
+ mfr,
+ package,
+ joints,
+ manufacturer_id,
+ basic,
+ description,
+ datasheet,
+ stock,
+ price,
+ last_update,
+ ]
+
+ if extra is not None:
+ db.conn.execute(
+ """INSERT INTO components
+ (lcsc, category_id, mfr, package, joints, manufacturer_id,
+ basic, description, datasheet, stock, price, last_update, extra)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ params + [extra],
+ )
+ else:
+ db.conn.execute(
+ """INSERT INTO components
+ (lcsc, category_id, mfr, package, joints, manufacturer_id,
+ basic, description, datasheet, stock, price, last_update)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ params,
+ )
+
+
+class TestComponentsDatabase:
+ """Tests for ComponentsDatabase class."""
+
+ def test_components_database_init(self, temp_db, temp_dir):
+ """ComponentsDatabase initializes with database file."""
+ db_path = temp_dir / "test_components.db"
+ assert db_path.exists()
+ assert temp_db.conn is not None
+
+ def test_components_database_creates_tables(self, temp_db):
+ """ComponentsDatabase creates all required tables."""
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ tables = [row[0] for row in cursor.fetchall()]
+
+ assert "components" in tables
+ assert "manufacturers" in tables
+ assert "categories" in tables
+
+ def test_components_database_creates_indexes(self, temp_db):
+ """ComponentsDatabase creates all required indexes."""
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='index'")
+ indexes = [row[0] for row in cursor.fetchall()]
+
+ assert "components_category" in indexes
+ assert "components_manufacturer" in indexes
+
+ def test_components_database_row_factory(self, temp_db):
+ """ComponentsDatabase uses row_factory for sqlite3.Row."""
+ assert temp_db.conn.row_factory == sqlite3.Row
+
+ def test_components_database_has_fix_description_udf(self, temp_db):
+ """ComponentsDatabase registers maybeFixDescription UDF."""
+ # Test by using the function in a query
+ cursor = temp_db.conn.cursor()
+ result = cursor.execute(
+ "SELECT maybeFixDescription('', ?)",
+ (json.dumps({"description": "Test"}),),
+ ).fetchone()
+
+ assert result[0] == "Test"
+
+ def test_manufacturer_id_new_manufacturer(self, temp_db):
+ """ComponentsDatabase manufacturerId inserts new manufacturer."""
+ mfr_id = temp_db.manufacturerId("Samsung")
+ assert mfr_id is not None
+ assert mfr_id > 0
+
+ def test_manufacturer_id_duplicate_returns_same(self, temp_db):
+ """ComponentsDatabase manufacturerId returns same ID for duplicate."""
+ mfr_id_1 = temp_db.manufacturerId("Samsung")
+ mfr_id_2 = temp_db.manufacturerId("Samsung")
+
+ assert mfr_id_1 == mfr_id_2
+
+ def test_manufacturer_id_caching(self, temp_db):
+ """ComponentsDatabase caches manufacturer IDs."""
+ temp_db.manufacturerId("Intel")
+ assert "Intel" in temp_db.manufacturer_cache
+
+ cached_id = temp_db.manufacturer_cache["Intel"]
+ new_id = temp_db.manufacturerId("Intel")
+
+ assert cached_id == new_id
+
+ def test_manufacturer_id_different_manufacturers(self, temp_db):
+ """ComponentsDatabase assigns different IDs to different manufacturers."""
+ samsung_id = temp_db.manufacturerId("Samsung")
+ intel_id = temp_db.manufacturerId("Intel")
+
+ assert samsung_id != intel_id
+
+ def test_category_id_new_category(self, temp_db):
+ """ComponentsDatabase categoryId inserts new category."""
+ cat_id = temp_db.categoryId("Resistors", "Fixed Resistors")
+ assert cat_id is not None
+ assert cat_id > 0
+
+ def test_category_id_duplicate_returns_same(self, temp_db):
+ """ComponentsDatabase categoryId returns same ID for duplicate."""
+ cat_id_1 = temp_db.categoryId("Resistors", "Fixed Resistors")
+ cat_id_2 = temp_db.categoryId("Resistors", "Fixed Resistors")
+
+ assert cat_id_1 == cat_id_2
+
+ def test_category_id_caching(self, temp_db):
+ """ComponentsDatabase caches category IDs."""
+ temp_db.categoryId("Capacitors", "Ceramic")
+ key = ("Capacitors", "Ceramic")
+ assert key in temp_db.category_cache
+
+ cached_id = temp_db.category_cache[key]
+ new_id = temp_db.categoryId("Capacitors", "Ceramic")
+
+ assert cached_id == new_id
+
+ def test_category_id_different_subcategories(self, temp_db):
+ """ComponentsDatabase assigns different IDs to different subcategories."""
+ ceramic_id = temp_db.categoryId("Capacitors", "Ceramic")
+ film_id = temp_db.categoryId("Capacitors", "Film")
+
+ assert ceramic_id != film_id
+
+ def test_count_components_empty(self, temp_db):
+ """ComponentsDatabase count_components returns 0 for empty database."""
+ count = temp_db.count_components()
+ assert count == 0
+
+ def test_count_components_with_data(self, temp_db):
+ """ComponentsDatabase count_components returns correct count."""
+ # Add some components
+ for i in range(5):
+ _insert_component(
+ temp_db,
+ 100000 + i,
+ mfr=f"MFR{i}",
+ description=f"Component {i}",
+ )
+ temp_db.conn.commit()
+
+ count = temp_db.count_components()
+ assert count == 5
+
+ def test_count_components_with_where_clause(self, temp_db):
+ """ComponentsDatabase count_components filters with where_clause."""
+ # Add components with different stock levels
+ for i in range(5):
+ stock = 100 if i < 3 else 0
+ _insert_component(
+ temp_db,
+ 100000 + i,
+ mfr=f"MFR{i}",
+ description=f"Component {i}",
+ stock=stock,
+ )
+ temp_db.conn.commit()
+
+ total = temp_db.count_components()
+ in_stock = temp_db.count_components("stock > 0")
+
+ assert total == 5
+ assert in_stock == 3
+
+ def test_fetch_components_empty(self, temp_db):
+ """ComponentsDatabase fetch_components returns empty list for empty database."""
+ batches = list(temp_db.fetch_components())
+ assert len(batches) == 0
+
+ def test_fetch_components_single_batch(self, temp_db):
+ """ComponentsDatabase fetch_components yields components in batches."""
+ # Add 5 components
+ for i in range(5):
+ _insert_component(
+ temp_db,
+ 100000 + i,
+ mfr=f"MFR{i}",
+ description=f"Component {i}",
+ )
+ temp_db.conn.commit()
+
+ batches = list(temp_db.fetch_components(batch_size=10))
+
+ assert len(batches) == 1
+ assert len(batches[0]) == 5
+
+ def test_fetch_components_multiple_batches(self, temp_db):
+ """ComponentsDatabase fetch_components splits into multiple batches."""
+ # Add 25 components
+ for i in range(25):
+ _insert_component(
+ temp_db,
+ 100000 + i,
+ mfr=f"MFR{i}",
+ description=f"Component {i}",
+ )
+ temp_db.conn.commit()
+
+ batches = list(temp_db.fetch_components(batch_size=10))
+
+ assert len(batches) == 3
+ assert len(batches[0]) == 10
+ assert len(batches[1]) == 10
+ assert len(batches[2]) == 5
+
+ def test_fetch_components_with_where_clause(self, temp_db):
+ """ComponentsDatabase fetch_components filters with where_clause."""
+ # Add components with different stock levels
+ for i in range(5):
+ stock = 100 if i % 2 == 0 else 0
+ _insert_component(
+ temp_db,
+ 100000 + i,
+ mfr=f"MFR{i}",
+ description=f"Component {i}",
+ stock=stock,
+ )
+ temp_db.conn.commit()
+
+ batches = list(temp_db.fetch_components("stock > 0"))
+
+ assert len(batches) == 1
+ assert len(batches[0]) == 3
+
+ def test_get_manufacturers_empty(self, temp_db):
+ """ComponentsDatabase get_manufacturers returns empty dict for empty database."""
+ manufacturers = temp_db.get_manufacturers()
+ assert len(manufacturers) == 0
+
+ def test_get_manufacturers_with_data(self, temp_db):
+ """ComponentsDatabase get_manufacturers returns all manufacturers."""
+ mfr_samsung = temp_db.manufacturerId("Samsung")
+ mfr_intel = temp_db.manufacturerId("Intel")
+
+ manufacturers = temp_db.get_manufacturers()
+
+ assert len(manufacturers) == 2
+ assert mfr_samsung is not None
+ assert mfr_intel is not None
+ assert manufacturers[mfr_samsung] == "Samsung"
+ assert manufacturers[mfr_intel] == "Intel"
+
+ def test_get_categories_empty(self, temp_db):
+ """ComponentsDatabase get_categories returns empty dict for empty database."""
+ categories = temp_db.get_categories()
+ assert len(categories) == 0
+
+ def test_get_categories_with_data(self, temp_db):
+ """ComponentsDatabase get_categories returns all categories."""
+ cat_resistor = temp_db.categoryId("Resistors", "Fixed")
+ cat_capacitor = temp_db.categoryId("Capacitors", "Ceramic")
+
+ categories = temp_db.get_categories()
+
+ assert len(categories) == 2
+ assert cat_resistor is not None
+ assert cat_capacitor is not None
+ assert categories[cat_resistor] == ("Resistors", "Fixed")
+ assert categories[cat_capacitor] == ("Capacitors", "Ceramic")
+
+ def test_cols_static_method(self):
+ """ComponentsDatabase.cols returns expected column list."""
+ cols = ComponentsDatabase.cols()
+
+ assert isinstance(cols, list)
+ assert "lcsc" in cols
+ assert "category_id" in cols
+ assert "manufacturer_id" in cols
+ assert "mfr" in cols
+ assert "package" in cols
+ assert "basic" in cols
+ assert "preferred" in cols
+ assert "description" in cols
+ assert "datasheet" in cols
+ assert "stock" in cols
+ assert "price" in cols
+ assert "extra" in cols
+ assert "joints" in cols
+ assert "last_update" in cols
+
+ def test_fix_description_method(self, temp_db):
+ """ComponentsDatabase fix_description updates empty descriptions."""
+ # Insert component with empty description but extra JSON with description
+ extra = json.dumps({"description": "Fixed Description"})
+ _insert_component(
+ temp_db,
+ 100000,
+ description="",
+ extra=extra,
+ )
+ temp_db.conn.commit()
+
+ temp_db.fix_description()
+
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT description FROM components WHERE lcsc = ?", (100000,))
+ result = cursor.fetchone()
+
+ assert result[0] == "Fixed Description"
+
+ def test_cleanup_stock_old_components(self, temp_db):
+ """ComponentsDatabase cleanup_stock sets old components to zero stock."""
+ now = int(time.time())
+ eight_days_ago = now - (8 * 24 * 60 * 60)
+
+ # Insert old component with stock
+ _insert_component(
+ temp_db,
+ 100000,
+ stock=100,
+ last_update=eight_days_ago,
+ )
+ temp_db.conn.commit()
+
+ temp_db.cleanup_stock()
+
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT stock FROM components WHERE lcsc = ?", (100000,))
+ result = cursor.fetchone()
+
+ assert result[0] == 0
+
+ def test_cleanup_stock_recent_components(self, temp_db):
+ """ComponentsDatabase cleanup_stock preserves recent component stock."""
+ now = int(time.time())
+
+ # Insert recent component with stock
+ _insert_component(
+ temp_db,
+ 100000,
+ stock=100,
+ last_update=now,
+ )
+ temp_db.conn.commit()
+
+ temp_db.cleanup_stock()
+
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT stock FROM components WHERE lcsc = ?", (100000,))
+ result = cursor.fetchone()
+
+ assert result[0] == 100
+
+ def test_truncate_old_clears_old_out_of_stock(self, temp_db):
+ """ComponentsDatabase truncate_old clears price/extra for old out-of-stock."""
+ now = int(time.time())
+ over_year_ago = now - (400 * 24 * 60 * 60)
+
+ # Insert old out-of-stock component
+ temp_db.conn.execute(
+ """INSERT INTO components
+ (lcsc, category_id, mfr, package, joints, manufacturer_id,
+ basic, description, datasheet, stock, price, last_update, last_on_stock)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ 100000,
+ 1,
+ "MFR",
+ "0805",
+ 2,
+ 1,
+ 1,
+ "Component",
+ "http://example.com",
+ 0,
+ "[1.00, 2.00]",
+ now,
+ over_year_ago,
+ ),
+ )
+ temp_db.conn.commit()
+
+ temp_db.truncate_old()
+
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT price, extra FROM components WHERE lcsc = ?", (100000,))
+ result = cursor.fetchone()
+
+ assert result[0] == "[]"
+ assert result[1] == "{}"
+
+ def test_truncate_old_preserves_in_stock(self, temp_db):
+ """ComponentsDatabase truncate_old preserves in-stock components."""
+ now = int(time.time())
+ over_year_ago = now - (400 * 24 * 60 * 60)
+
+ # Insert old but in-stock component
+ temp_db.conn.execute(
+ """INSERT INTO components
+ (lcsc, category_id, mfr, package, joints, manufacturer_id,
+ basic, description, datasheet, stock, price, last_update, last_on_stock)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ 100000,
+ 1,
+ "MFR",
+ "0805",
+ 2,
+ 1,
+ 1,
+ "Component",
+ "http://example.com",
+ 100,
+ "[1.00, 2.00]",
+ now,
+ over_year_ago,
+ ),
+ )
+ temp_db.conn.commit()
+
+ temp_db.truncate_old()
+
+ cursor = temp_db.conn.cursor()
+ cursor.execute("SELECT price, extra FROM components WHERE lcsc = ?", (100000,))
+ result = cursor.fetchone()
+
+ # Should be preserved because stock > 0
+ assert result[0] == "[1.00, 2.00]"
+
+
+# ============================================================================
+# Constants Tests
+# ============================================================================
+
+
+class TestCreateStatements:
+ """Tests for _CREATE_STATEMENTS constants."""
+
+ def test_create_statements_exists(self):
+ """_CREATE_STATEMENTS constant exists."""
+ assert _CREATE_STATEMENTS is not None
+ assert isinstance(_CREATE_STATEMENTS, list)
+
+ def test_create_statements_not_empty(self):
+ """_CREATE_STATEMENTS has statements."""
+ assert len(_CREATE_STATEMENTS) > 0
+
+ def test_create_statements_are_strings(self):
+ """All _CREATE_STATEMENTS are SQL strings."""
+ for stmt in _CREATE_STATEMENTS:
+ assert isinstance(stmt, str)
+ assert "CREATE" in stmt.upper()
+
+ def test_components_table_statement(self):
+ """Components table statement exists."""
+ assert any("components" in stmt.lower() for stmt in _CREATE_STATEMENTS)
+
+ def test_manufacturers_table_statement(self):
+ """Manufacturers table statement exists."""
+ assert any("manufacturers" in stmt.lower() for stmt in _CREATE_STATEMENTS)
+
+ def test_categories_table_statement(self):
+ """Categories table statement exists."""
+ assert any("categories" in stmt.lower() for stmt in _CREATE_STATEMENTS)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py
new file mode 100644
index 00000000..39126fc2
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_filemgr.py
@@ -0,0 +1,417 @@
+"""Test FileManager split and reassemble workflow."""
+
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+import os
+from pathlib import Path
+import shutil
+import tempfile
+import threading
+import time
+
+import pytest
+
+from common.filemgr import FileManager
+
+
+@pytest.fixture
+def temp_test_dir():
+ """Create a temporary test directory and clean up after test.
+
+ This fixture creates a unique temporary directory for each test,
+ allowing tests to safely create and modify files without affecting
+ other tests or the system. The directory and all its contents are
+ automatically deleted after the test completes, even if the test fails.
+
+ All test output files should be created within this directory to ensure
+ proper cleanup.
+
+ Returns:
+ Path: The temporary directory path.
+
+ """
+ test_dir = Path(tempfile.mkdtemp(prefix="test_filemgr_"))
+ yield test_dir
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+@pytest.fixture
+def temp_file(temp_test_dir):
+ """Create a temporary test file with content.
+
+ Creates a test file in the temporary test directory with predefined content.
+ The file is automatically cleaned up when temp_test_dir is cleaned up.
+
+ Args:
+ temp_test_dir: The temporary test directory fixture.
+
+ Returns:
+ tuple: (test_file_path, test_content) for assertions and verification.
+
+ """
+ test_file = temp_test_dir / "test_data.txt"
+ test_content = "Test content " * 10000
+ test_file.write_text(test_content)
+ return test_file, test_content
+
+
+@pytest.fixture
+def http_server(temp_test_dir):
+ """Start a temporary HTTP server serving files from temp_test_dir.
+
+ This fixture creates a local HTTP server that serves files from the
+ temporary test directory. The server runs in a background thread and
+ is automatically shut down after the test completes.
+
+ The temp_test_dir is automatically cleaned up after the fixture exits,
+ which ensures all test files are removed.
+
+ Yields:
+ tuple: (server_url, temp_test_dir) where server_url is the base URL
+ (e.g., http://127.0.0.1:12345) to use for requests.
+
+ """
+
+ class QuietHTTPRequestHandler(SimpleHTTPRequestHandler):
+ """HTTP request handler that suppresses logging."""
+
+ def log_message(self, format, *args):
+ pass # Suppress server logs during tests
+
+ def translate_path(self, path):
+ """Override to serve from temp_test_dir."""
+ # Simplified: just map the request path directly to temp_test_dir
+ path = path.split("?", 1)[0] # Remove query string
+ path = path.split("#", 1)[0] # Remove fragment
+ words = path.split("/")
+ words = filter(None, words) # Remove empty strings
+ path = temp_test_dir
+ for word in words:
+ if word in (os.curdir, os.pardir):
+ continue
+ path = path / word
+ return str(path)
+
+ server = HTTPServer(("localhost", 0), QuietHTTPRequestHandler)
+ host, port = server.server_address[:2]
+ server_url = f"http://{host}:{port}"
+
+ # Start server in background thread
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
+ server_thread.start()
+
+ # Give server time to start
+ time.sleep(0.1)
+
+ yield server_url, temp_test_dir
+
+ # Shutdown server
+ server.shutdown()
+ server.server_close()
+
+
+# ============================================================================
+# Basic Tests (existing)
+# ============================================================================
+
+
+def test_split_and_reassemble(temp_file, temp_test_dir):
+ """Test the split and reassemble workflow."""
+ test_file, test_content = temp_file
+ output_dir = temp_test_dir / "output"
+ output_dir.mkdir()
+
+ # Test the split operation
+ fm = FileManager(
+ file_path=test_file,
+ chunk_size=5000, # Small chunks for testing
+ sentinel_filename="test_chunks.txt",
+ )
+
+ chunk_count = fm.compress_and_split(output_dir=output_dir)
+ assert chunk_count > 0, "Should create at least one chunk"
+
+ # Verify chunk files exist
+ chunk_files = list(output_dir.glob("test_data.txt.zip.*"))
+ assert len(chunk_files) == chunk_count, (
+ "Number of chunk files should match chunk count"
+ )
+
+ # Verify sentinel file exists
+ sentinel_file = output_dir / "test_chunks.txt"
+ assert sentinel_file.exists(), "Sentinel file should exist"
+ assert int(sentinel_file.read_text()) == chunk_count, (
+ "Sentinel should contain chunk count"
+ )
+
+ # Test reassemble
+ reassembled_file = output_dir / "test_data_reassembled.txt"
+ reassembled_path = fm.reassemble(output_path=reassembled_file, input_dir=output_dir)
+
+ # Verify reassembled file
+ assert reassembled_path.exists(), "Reassembled file should exist"
+ reassembled_content = reassembled_path.read_text()
+ assert reassembled_content == test_content, "Content should match original"
+
+
+def test_compress_and_split(temp_test_dir):
+ """Test the compress_and_split operation."""
+ test_file = temp_test_dir / "test_file.txt"
+ test_file.write_text("Test content " * 5000)
+
+ output_dir = temp_test_dir / "output"
+ output_dir.mkdir()
+
+ fm = FileManager(
+ file_path=test_file,
+ chunk_size=8000,
+ sentinel_filename="chunk_count.txt",
+ )
+
+ chunk_count = fm.compress_and_split(output_dir=output_dir, delete_original=True)
+
+ # Verify original file was deleted
+ assert not test_file.exists(), (
+ "Original file should be deleted when delete_original=True"
+ )
+
+ # Verify chunks were created
+ chunk_files = sorted(output_dir.glob("test_file.txt.zip.*"))
+ assert len(chunk_files) == chunk_count, "All chunks should be created"
+
+
+def test_temp_dir_context_manager(temp_test_dir):
+ """Test temporary working directory feature with context manager."""
+ test_file = temp_test_dir / "test_file.txt"
+ test_file.write_text("Hello World" * 1000)
+
+ # Test with use_temp_dir=True
+ with FileManager(test_file, use_temp_dir=True) as fm:
+ assert fm.use_temp_dir is True, "use_temp_dir should be True"
+ work_dir = fm._get_work_dir()
+ assert work_dir.exists(), "Work directory should exist"
+ temp_dir_path = fm.temp_dir
+ assert temp_dir_path is not None, "Temp dir should be created"
+
+ # After context exit, temp dir should be cleaned up
+ assert fm.temp_dir is None, "Temp dir should be None after context exit"
+ assert not temp_dir_path.exists(), "Temp dir should be deleted after context exit"
+
+
+def test_temp_dir_without_context_manager(temp_test_dir):
+ """Test temporary working directory without context manager."""
+ test_file = temp_test_dir / "test_file.txt"
+ test_file.write_text("Hello World" * 1000)
+
+ fm = FileManager(test_file, use_temp_dir=True)
+ work_dir = fm._get_work_dir()
+ assert work_dir.exists(), "Work directory should exist"
+
+ temp_dir_path = fm.temp_dir
+ assert temp_dir_path is not None, "Temp dir should be created"
+
+ # Manually cleanup
+ fm.cleanup_temp_dir()
+ assert fm.temp_dir is None, "Temp dir should be None after cleanup"
+ assert not temp_dir_path.exists(), "Temp dir should be deleted after cleanup"
+
+
+def test_no_temp_dir(temp_test_dir):
+ """Test FileManager with use_temp_dir=False."""
+ test_file = temp_test_dir / "test_file.txt"
+ test_file.write_text("Hello World" * 1000)
+
+ fm = FileManager(test_file, use_temp_dir=False)
+ work_dir = fm._get_work_dir()
+ assert work_dir == Path("."), "Work directory should be current directory"
+ assert fm.temp_dir is None, "Temp dir should be None when use_temp_dir=False"
+
+
+# ============================================================================
+# End-to-End Tests with HTTP Server
+# ============================================================================
+
+
+def test_download_single_file(http_server, temp_test_dir):
+ """Test downloading a single file from GitHub."""
+ server_url, serve_dir = http_server
+
+ # Create a test file in the server directory
+ test_file = serve_dir / "download_test.txt"
+ test_content = "Downloaded file content"
+ test_file.write_text(test_content)
+
+ # Download the file
+ download_dir = temp_test_dir / "downloads"
+ download_dir.mkdir()
+
+ fm = FileManager(
+ file_path=download_dir / "download_test.txt",
+ use_temp_dir=False,
+ )
+ downloaded_path = fm.download(
+ url=f"{server_url}/download_test.txt",
+ output_path=download_dir / "download_test.txt",
+ )
+
+ assert downloaded_path.exists(), "Downloaded file should exist"
+ assert downloaded_path.read_text() == test_content, (
+ "Downloaded content should match"
+ )
+
+
+def test_download_and_reassemble_split_chunks(http_server, temp_test_dir):
+ """Test downloading split chunks and reassembling them."""
+ server_url, serve_dir = http_server
+
+ # Create original file and split it
+ original_file = serve_dir / "original.txt"
+ original_content = "Original file content " * 5000
+ original_file.write_text(original_content)
+
+ # Split the file
+ split_output_dir = serve_dir / "chunks"
+ split_output_dir.mkdir()
+
+ fm = FileManager(
+ file_path=original_file,
+ chunk_size=3000,
+ sentinel_filename="chunk_count.txt",
+ )
+ fm.compress_and_split(output_dir=split_output_dir)
+
+ # Now download and reassemble
+ download_dir = temp_test_dir / "downloads"
+ download_dir.mkdir()
+
+ fm_download = FileManager(
+ file_path=download_dir / "original.txt",
+ sentinel_filename="chunk_count.txt",
+ use_temp_dir=False,
+ )
+
+ reassembled_path = fm_download.download_and_reassemble(
+ url=f"{server_url}/chunks/",
+ output_dir=download_dir,
+ output_path=download_dir / "reassembled.txt",
+ )
+
+ assert reassembled_path.exists(), "Reassembled file should exist"
+ assert reassembled_path.read_text() == original_content, (
+ "Reassembled content should match original"
+ )
+
+
+def test_download_and_reassemble_with_multiple_chunks(http_server, temp_test_dir):
+ """Test download_and_reassemble with many chunks."""
+ server_url, serve_dir = http_server
+
+ # Create a larger file that will be split into many chunks
+ original_file = serve_dir / "large.txt"
+ original_content = (
+ "Large file content " * 100000
+ ) # Much larger to ensure multiple chunks
+ original_file.write_text(original_content)
+
+ # Split into small chunks
+ split_output_dir = serve_dir / "large_chunks"
+ split_output_dir.mkdir()
+
+ fm = FileManager(
+ file_path=original_file,
+ chunk_size=1000, # Smaller chunks to ensure multiple
+ sentinel_filename="large_chunk_count.txt",
+ )
+ chunk_count = fm.compress_and_split(output_dir=split_output_dir)
+ assert chunk_count > 3, "Should create multiple chunks for this test"
+
+ # Download and reassemble
+ download_dir = temp_test_dir / "downloads"
+ download_dir.mkdir()
+
+ fm_download = FileManager(
+ file_path=download_dir / "large.txt",
+ sentinel_filename="large_chunk_count.txt",
+ use_temp_dir=False,
+ )
+
+ reassembled_path = fm_download.download_and_reassemble(
+ url=f"{server_url}/large_chunks/",
+ output_dir=download_dir,
+ output_path=download_dir / "large_reassembled.txt",
+ )
+
+ assert reassembled_path.exists(), "Reassembled file should exist"
+ assert reassembled_path.read_text() == original_content, (
+ "Reassembled content should match original"
+ )
+ assert len(list(download_dir.glob("large.txt.zip.*"))) == 0, (
+ "Downloaded chunks should be cleaned up after reassembly"
+ )
+
+
+def test_download_missing_sentinel_file(http_server, temp_test_dir):
+ """Test download_and_reassemble handles missing sentinel file gracefully."""
+ server_url, serve_dir = http_server
+
+ # Create chunks directory without sentinel file
+ chunks_dir = serve_dir / "no_sentinel"
+ chunks_dir.mkdir()
+
+ download_dir = temp_test_dir / "downloads"
+ download_dir.mkdir()
+
+ fm_download = FileManager(
+ file_path=download_dir / "test.txt",
+ sentinel_filename="chunk_count.txt",
+ use_temp_dir=False,
+ )
+
+ # Should raise an error or handle gracefully
+ with pytest.raises((FileNotFoundError, Exception)):
+ fm_download.download_and_reassemble(
+ url=f"{server_url}/no_sentinel/",
+ output_dir=download_dir,
+ )
+
+
+def test_download_partial_chunks(http_server, temp_test_dir):
+ """Test download_and_reassemble handles incomplete chunk sets."""
+ server_url, serve_dir = http_server
+
+ # Create chunks directory with only some chunks
+ original_file = serve_dir / "partial.txt"
+ original_content = "Partial file " * 50000 # Much larger to ensure multiple chunks
+ original_file.write_text(original_content)
+
+ chunks_dir = serve_dir / "partial_chunks"
+ chunks_dir.mkdir()
+
+ fm = FileManager(
+ file_path=original_file,
+ chunk_size=1000, # Smaller to ensure multiple chunks
+ sentinel_filename="partial_count.txt",
+ )
+ chunk_count = fm.compress_and_split(output_dir=chunks_dir)
+ assert chunk_count > 1, "Should create multiple chunks for this test"
+
+ # Remove some chunks to simulate incomplete download
+ chunk_files = sorted(chunks_dir.glob("partial.txt.zip.*"))
+ if len(chunk_files) > 1:
+ chunk_files[-1].unlink()
+
+ download_dir = temp_test_dir / "downloads"
+ download_dir.mkdir()
+
+ fm_download = FileManager(
+ file_path=download_dir / "partial.txt",
+ sentinel_filename="partial_count.txt",
+ use_temp_dir=False,
+ )
+
+ # Should raise an error due to missing chunk
+ with pytest.raises((FileNotFoundError, Exception)):
+ fm_download.download_and_reassemble(
+ url=f"{server_url}/partial_chunks/",
+ output_dir=download_dir,
+ output_path=download_dir / "partial_reassembled.txt",
+ )
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py
new file mode 100644
index 00000000..58630870
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_jlcapi.py
@@ -0,0 +1,558 @@
+"""Tests for the jlcapi module."""
+
+import json
+from pathlib import Path
+import sys
+from unittest import mock
+
+import pytest
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from common.jlcapi import ApiCategory, CategoryFetch, Component, JlcApi, LcscId
+
+# ============================================================================
+# ApiCategory Tests
+# ============================================================================
+
+
+class TestApiCategory:
+ """Tests for ApiCategory NamedTuple."""
+
+ def test_api_category_creation(self):
+ """ApiCategory can be created with primary, secondary, and count."""
+ cat = ApiCategory("Resistors", "Thick Film", 10000)
+ assert cat.primary == "Resistors"
+ assert cat.secondary == "Thick Film"
+ assert cat.count == 10000
+
+ def test_api_category_repr_with_secondary(self):
+ """ApiCategory repr shows primary and secondary with count."""
+ cat = ApiCategory("Capacitors", "Ceramic", 5000)
+ result = repr(cat)
+ assert "Capacitors" in result
+ assert "Ceramic" in result
+ assert "5000" in result
+
+ def test_api_category_repr_without_secondary(self):
+ """ApiCategory repr shows only primary with count when secondary is empty."""
+ cat = ApiCategory("Diodes", "", 1500)
+ result = repr(cat)
+ assert "Diodes" in result
+ assert "1500" in result
+ assert "|" not in result
+
+ def test_api_category_repr_with_empty_primary(self):
+ """ApiCategory repr returns empty string when primary is empty."""
+ cat = ApiCategory("", "Sub", 100)
+ result = repr(cat)
+ assert result == ""
+
+
+# ============================================================================
+# LcscId Tests
+# ============================================================================
+
+
+class TestLcscId:
+ """Tests for LcscId conversion class."""
+
+ def test_lcsc_id_from_string(self):
+ """LcscId can be initialized with string format."""
+ lcsc = LcscId("C12345")
+ assert lcsc.lcsc == "C12345"
+
+ def test_lcsc_id_from_integer(self):
+ """LcscId can be initialized with integer format."""
+ lcsc = LcscId(12345)
+ assert lcsc.lcsc == 12345
+
+ def test_to_db_key_from_string(self):
+ """LcscId.toDbKey converts string to integer."""
+ lcsc = LcscId("C12345")
+ assert lcsc.toDbKey() == 12345
+
+ def test_to_db_key_from_integer(self):
+ """LcscId.toDbKey returns integer unchanged."""
+ lcsc = LcscId(12345)
+ assert lcsc.toDbKey() == 12345
+
+ def test_to_component_from_string(self):
+ """LcscId.toComponent returns string unchanged if already C-prefixed."""
+ lcsc = LcscId("C12345")
+ assert lcsc.toComponent() == "C12345"
+
+ def test_to_component_from_integer(self):
+ """LcscId.toComponent adds C prefix to integer."""
+ lcsc = LcscId(12345)
+ assert lcsc.toComponent() == "C12345"
+
+ def test_to_component_from_string_without_prefix(self):
+ """LcscId.toComponent adds C prefix if missing."""
+ lcsc = LcscId("12345")
+ assert lcsc.toComponent() == "C12345"
+
+
+# ============================================================================
+# Component Tests
+# ============================================================================
+
+
+class TestComponent:
+ """Tests for Component class."""
+
+ @pytest.fixture
+ def sample_component_data(self):
+ """Sample component data from API."""
+ return {
+ "componentCode": "C12345",
+ "category_id": 1,
+ "manufacturer_id": 5,
+ "componentModelEn": "1k Resistor",
+ "componentSpecificationEn": "0805",
+ "componentBrandEn": "Samsung",
+ "componentLibraryType": "base",
+ "preferredComponentFlag": False,
+ "describe": "1k resistor",
+ "dataManualUrl": "https://example.com/datasheet.pdf",
+ "stockCount": 10000,
+ "componentPrices": [
+ {"startNumber": 1, "endNumber": 99, "productPrice": 0.05},
+ {"startNumber": 100, "endNumber": 999, "productPrice": 0.04},
+ ],
+ "imageList": [],
+ "componentPriceList": [],
+ "buyComponentPrices": [],
+ "firstSortName": "Resistors",
+ "secondSortName": "Thick Film",
+ "urlSuffix": "123456",
+ "other_field": "other_value",
+ }
+
+ def test_component_creation(self, sample_component_data):
+ """Component can be created from data dict."""
+ comp = Component(sample_component_data)
+ assert comp["componentCode"] == "C12345"
+ assert comp["category_id"] == 1
+
+ def test_component_as_dict(self, sample_component_data):
+ """Component inherits from dict and behaves like one."""
+ comp = Component(sample_component_data)
+ assert comp.get("componentCode") == "C12345"
+ assert "manufacturer_id" in comp
+
+ def test_component_category_key(self, sample_component_data):
+ """Component.categoryKey returns (secondary, primary) tuple."""
+ comp = Component(sample_component_data)
+ primary, secondary = comp.categoryKey()
+ assert primary == "Thick Film"
+ assert secondary == "Resistors"
+
+ def test_component_manufacturer_key(self, sample_component_data):
+ """Component.manufacturerKey returns manufacturer name."""
+ comp = Component(sample_component_data)
+ assert comp.manufacturerKey() == "Samsung"
+
+ def test_component_translated_prices(self, sample_component_data):
+ """Component.translated_component_prices converts price format."""
+ comp = Component(sample_component_data)
+ result = json.loads(comp.translated_component_prices())
+
+ assert len(result) == 2
+ assert result[0]["qFrom"] == 1
+ assert result[0]["qTo"] == 99
+ assert result[0]["price"] == 0.05
+ assert result[1]["qFrom"] == 100
+ assert result[1]["qTo"] == 999
+ assert result[1]["price"] == 0.04
+
+ def test_component_translated_prices_handles_negative_one_end(self):
+ """Component.translated_component_prices converts -1 endNumber to None."""
+ data = {
+ "componentPrices": [
+ {"startNumber": 1, "endNumber": -1, "productPrice": 0.02},
+ ],
+ }
+ comp = Component(data)
+ result = json.loads(comp.translated_component_prices())
+
+ assert result[0]["qTo"] is None
+
+ def test_component_translated_prices_empty(self):
+ """Component.translated_component_prices returns empty list for no prices."""
+ comp = Component({"componentPrices": []})
+ result = comp.translated_component_prices()
+ assert result == "[]"
+
+ def test_component_as_database_row(self, sample_component_data):
+ """Component.asDatabaseRow converts to database format."""
+ comp = Component(sample_component_data)
+ row = comp.asDatabaseRow()
+
+ assert row["lcsc"] == 12345
+ assert row["category_id"] == 1
+ assert row["manufacturer_id"] == 5
+ assert row["mfr"] == "1k Resistor"
+ assert row["package"] == "0805"
+ assert row["manufacturer"] == "Samsung"
+ assert row["basic"] == 1
+ assert row["preferred"] == 0
+ assert row["description"] == "1k resistor"
+ assert row["stock"] == 10000
+ assert "last_update" in row
+ assert "last_on_stock" in row
+
+ def test_component_as_database_row_datasheet_fallback(self):
+ """Component.asDatabaseRow uses fallback datasheet URL if none provided."""
+ data = {
+ "componentCode": "C12345",
+ "componentModelEn": "Component",
+ "componentSpecificationEn": "0805",
+ "componentBrandEn": "Brand",
+ "componentLibraryType": "base",
+ "preferredComponentFlag": False,
+ "describe": "Description",
+ "dataManualUrl": None,
+ "stockCount": 100,
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "componentPrices": [],
+ "urlSuffix": "suffix123",
+ }
+ comp = Component(data)
+ row = comp.asDatabaseRow()
+ assert row["datasheet"] == "https://jlcpcb.com/partdetail/suffix123"
+
+ def test_component_as_database_row_preferred_flag(self):
+ """Component.asDatabaseRow sets preferred when flag is true."""
+ data = {
+ "componentCode": "C12345",
+ "componentModelEn": "Component",
+ "componentSpecificationEn": "0805",
+ "componentBrandEn": "Brand",
+ "componentLibraryType": "advanced",
+ "preferredComponentFlag": True,
+ "describe": "Description",
+ "dataManualUrl": None,
+ "stockCount": 100,
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "componentPrices": [],
+ "urlSuffix": "suffix123",
+ }
+ comp = Component(data)
+ row = comp.asDatabaseRow()
+ assert row["basic"] == 0
+ assert row["preferred"] == 1
+
+ def test_component_strip_for_extra(self, sample_component_data):
+ """Component.stripForExtra removes known fields from extra data."""
+ comp = Component(sample_component_data)
+ extra = json.loads(comp.stripForExtra())
+
+ # Known fields should be removed
+ assert "componentCode" not in extra
+ assert "componentModelEn" not in extra
+ assert "componentPrices" not in extra
+ assert "dataManualUrl" not in extra
+
+ # Other fields should remain
+ assert "other_field" in extra
+ assert extra["other_field"] == "other_value"
+
+ def test_component_strip_for_extra_removes_none_values(self):
+ """Component.stripForExtra removes None values."""
+ data = {
+ "other_field": "value",
+ "none_field": None,
+ }
+ comp = Component(data)
+ extra = json.loads(comp.stripForExtra())
+
+ assert "none_field" not in extra
+ assert "other_field" in extra
+
+
+# ============================================================================
+# JlcApi Tests
+# ============================================================================
+
+
+class TestJlcApi:
+ """Tests for JlcApi static class."""
+
+ def test_jlcapi_base_url(self):
+ """JlcApi has correct base URL."""
+ assert "jlcpcb.com" in JlcApi.BASE_URL
+ assert "smtGood" in JlcApi.BASE_URL
+
+ @mock.patch("common.jlcapi.requests.get")
+ def test_get_token_success(self, mock_get):
+ """JlcApi.getToken fetches and returns XSRF token."""
+ mock_response = mock.Mock()
+ mock_response.cookies.get_dict.return_value = {"XSRF-TOKEN": "test_token_123"}
+ mock_get.return_value = mock_response
+
+ # Clear cache to ensure fresh fetch
+ JlcApi.getToken.cache_clear()
+
+ token = JlcApi.getToken()
+ assert token == "test_token_123"
+ mock_get.assert_called_once()
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_success(self, mock_post):
+ """JlcApi.componentList returns parsed JSON response."""
+ response_data = {
+ "code": 200,
+ "message": "Success",
+ "data": {"components": []},
+ }
+ mock_response = mock.Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ result = JlcApi.componentList("token123", {"request": "data"})
+ assert result == response_data
+ mock_post.assert_called_once()
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_http_error(self, mock_post):
+ """JlcApi.componentList raises on non-200 status."""
+ mock_response = mock.Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal Server Error"
+ mock_post.return_value = mock_response
+
+ with pytest.raises(RuntimeError, match="Cannot fetch component list"):
+ JlcApi.componentList("token123", {})
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_api_error_no_data(self, mock_post):
+ """JlcApi.componentList returns empty dict for certain error codes."""
+ response_data = {"code": 563, "message": "Error"}
+ mock_response = mock.Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ result = JlcApi.componentList("token123", {})
+ assert result == {}
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_api_error_rate_limit(self, mock_post):
+ """JlcApi.componentList returns empty dict for rate limit error (429)."""
+ response_data = {"code": 429, "message": "Too Many Requests"}
+ mock_response = mock.Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ result = JlcApi.componentList("token123", {})
+ assert result == {}
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_api_error_not_found(self, mock_post):
+ """JlcApi.componentList returns empty dict for 404 error."""
+ response_data = {"code": 404, "message": "Not Found"}
+ mock_response = mock.Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ result = JlcApi.componentList("token123", {})
+ assert result == {}
+
+ @mock.patch("common.jlcapi.requests.post")
+ def test_component_list_other_error_raises(self, mock_post):
+ """JlcApi.componentList raises for other error codes."""
+ response_data = {"code": 400, "message": "Bad Request"}
+ mock_response = mock.Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ with pytest.raises(RuntimeError, match="400"):
+ JlcApi.componentList("token123", {})
+
+ def test_collapse_categories_no_collapse_needed(self):
+ """JlcApi.collapseCategories keeps categories above limit."""
+ categories = [
+ ApiCategory("Resistors", "Thick Film", 60000),
+ ApiCategory("Resistors", "Thin Film", 50000),
+ ApiCategory("Capacitors", "Ceramic", 100000),
+ ]
+ result = JlcApi.collapseCategories(categories, limit=100000)
+
+ # Resistors total (110000) >= limit so kept
+ # Capacitors total (100000) is not < limit so kept
+ assert len(result) == 3
+ assert result == categories
+
+ def test_collapse_categories_collapse_small(self):
+ """JlcApi.collapseCategories collapses categories with total < limit."""
+ categories = [
+ ApiCategory("Diodes", "General", 5000),
+ ApiCategory("Diodes", "Schottky", 3000),
+ ApiCategory("Resistors", "Thick Film", 50000),
+ ]
+ result = JlcApi.collapseCategories(categories, limit=100000)
+
+ # Diodes total (8000) < limit, should be collapsed
+ # Resistors total (50000) < limit, should also be collapsed
+ assert len(result) == 2
+ assert any(
+ cat.primary == "Diodes" and cat.secondary == "" and cat.count == 8000
+ for cat in result
+ )
+ assert any(
+ cat.primary == "Resistors" and cat.secondary == "" and cat.count == 50000
+ for cat in result
+ )
+
+ def test_collapse_categories_multiple_primaries(self):
+ """JlcApi.collapseCategories handles multiple primary categories."""
+ categories = [
+ ApiCategory("A", "Sub1", 10000),
+ ApiCategory("A", "Sub2", 15000),
+ ApiCategory("B", "Sub1", 5000),
+ ApiCategory("B", "Sub2", 8000),
+ ApiCategory("C", "Sub1", 60000),
+ ]
+ result = JlcApi.collapseCategories(categories, limit=100000)
+
+ # A and B should be collapsed, C kept
+ a_collapsed = [cat for cat in result if cat.primary == "A"]
+ assert len(a_collapsed) == 1
+ assert a_collapsed[0].count == 25000
+ assert a_collapsed[0].secondary == ""
+
+
+# ============================================================================
+# CategoryFetch Tests
+# ============================================================================
+
+
+class TestCategoryFetch:
+ """Tests for CategoryFetch class."""
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ def test_category_fetch_init(self, mock_get_token):
+ """CategoryFetch initializes with category and settings."""
+ mock_get_token.return_value = "test_token"
+ category = ApiCategory("Resistors", "Thick Film", 5000)
+
+ fetch = CategoryFetch(category, rateLimit=True, pageSize=500)
+
+ assert fetch.category == category
+ assert fetch.pageSize == 500
+ assert fetch.rateLimit is True
+ assert fetch.currentPage == 1
+ assert fetch.instockOnly is True
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ def test_category_fetch_builds_request_with_secondary(self, mock_get_token):
+ """CategoryFetch builds request with secondary category."""
+ mock_get_token.return_value = "test_token"
+ category = ApiCategory("Resistors", "Thick Film", 5000)
+ fetch = CategoryFetch(category, rateLimit=False, pageSize=100)
+
+ with mock.patch.object(fetch, "_fetchNextPage", return_value=[]):
+ list(fetch.fetchAll())
+
+ # The request should include both primary and secondary
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ @mock.patch("common.jlcapi.JlcApi.componentList")
+ def test_category_fetch_collapsed_category(
+ self, mock_component_list, mock_get_token
+ ):
+ """CategoryFetch handles collapsed categories (empty secondary)."""
+ mock_get_token.return_value = "test_token"
+ mock_component_list.return_value = {}
+ category = ApiCategory("Resistors", "", 80000)
+
+ fetch = CategoryFetch(category, rateLimit=False)
+ list(fetch.fetchAll())
+
+ # Should have made at least one API call
+ assert mock_component_list.call_count >= 1
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ @mock.patch("common.jlcapi.JlcApi.componentList")
+ def test_category_fetch_single_page(self, mock_component_list, mock_get_token):
+ """CategoryFetch.fetchAll yields single page when components fit."""
+ mock_get_token.return_value = "test_token"
+
+ components_page1 = [{"componentCode": "C1"}, {"componentCode": "C2"}]
+ mock_component_list.side_effect = [
+ {"data": {"componentPageInfo": {"list": components_page1}}},
+ {"data": {"componentPageInfo": {"list": []}}},
+ ]
+
+ category = ApiCategory("Resistors", "Thick Film", 2)
+ fetch = CategoryFetch(category, rateLimit=False)
+
+ pages = list(fetch.fetchAll())
+ assert len(pages) == 1
+ assert pages[0] == components_page1
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ @mock.patch("common.jlcapi.JlcApi.componentList")
+ def test_category_fetch_multiple_pages(self, mock_component_list, mock_get_token):
+ """CategoryFetch.fetchAll yields multiple pages."""
+ mock_get_token.return_value = "test_token"
+
+ page1 = [{"componentCode": "C1"}]
+ page2 = [{"componentCode": "C2"}]
+ page3 = []
+
+ mock_component_list.side_effect = [
+ {"data": {"componentPageInfo": {"list": page1}}},
+ {"data": {"componentPageInfo": {"list": page2}}},
+ {"data": {"componentPageInfo": {"list": page3}}},
+ ]
+
+ category = ApiCategory("Test", "Cat", 100)
+ fetch = CategoryFetch(category, rateLimit=False)
+
+ pages = list(fetch.fetchAll())
+ assert len(pages) == 2
+ assert pages[0] == page1
+ assert pages[1] == page2
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ @mock.patch("common.jlcapi.JlcApi.componentList")
+ def test_category_fetch_empty_result(self, mock_component_list, mock_get_token):
+ """CategoryFetch.fetchAll handles empty response."""
+ mock_get_token.return_value = "test_token"
+ mock_component_list.return_value = {}
+
+ category = ApiCategory("Test", "Cat", 0)
+ fetch = CategoryFetch(category, rateLimit=False)
+
+ pages = list(fetch.fetchAll())
+ assert len(pages) == 0
+
+ @mock.patch("common.jlcapi.JlcApi.getToken")
+ @mock.patch("common.jlcapi.JlcApi.componentList")
+ def test_category_fetch_tracks_page_number(
+ self, mock_component_list, mock_get_token
+ ):
+ """CategoryFetch increments page number on each fetch."""
+ mock_get_token.return_value = "test_token"
+
+ mock_component_list.side_effect = [
+ {"data": {"componentPageInfo": {"list": [{"componentCode": "C1"}]}}},
+ {"data": {"componentPageInfo": {"list": []}}},
+ ]
+
+ category = ApiCategory("Test", "Cat", 100)
+ fetch = CategoryFetch(category, rateLimit=False)
+
+ list(fetch.fetchAll())
+
+ # Check that currentPage was incremented twice (1 -> 2 on first fetch, 2 -> 3 on second empty fetch)
+ assert fetch.currentPage == 3
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py
new file mode 100644
index 00000000..c35063bb
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_library_escape.py
@@ -0,0 +1,103 @@
+"""Tests for the SQL escaping helpers in search_escape.py."""
+
+# ruff: noqa: D102
+
+from pathlib import Path
+import sqlite3
+import sys
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from search_escape import (
+ escape_fts_phrase as _escape_fts_phrase,
+ escape_like_term as _escape_like_term,
+)
+
+# ---------------------------------------------------------------------------
+# _escape_like_term
+# ---------------------------------------------------------------------------
+
+
+class TestEscapeLikeTerm:
+ """Unit tests for _escape_like_term."""
+
+ def test_percent_is_escaped(self):
+ assert _escape_like_term("1%") == "1\\%"
+
+ def test_underscore_is_escaped(self):
+ assert _escape_like_term("1_") == "1\\_"
+
+ def test_backslash_is_escaped(self):
+ assert _escape_like_term("a\\b") == "a\\\\b"
+
+ def test_single_quote_is_escaped(self):
+ assert _escape_like_term("it's") == "it''s"
+
+ def test_plain_term_unchanged(self):
+ assert _escape_like_term("10k") == "10k"
+
+ def test_multiple_special_chars(self):
+ assert _escape_like_term("%_50%") == "\\%\\_50\\%"
+
+ # Integration: verify SQLite actually honours the escaped LIKE pattern.
+
+ def _like_matches(self, haystack: str, needle: str) -> bool:
+ """Return True if haystack matches the escaped LIKE pattern for needle."""
+ escaped = _escape_like_term(needle)
+ sql = f"SELECT 1 WHERE '{haystack}' LIKE '%{escaped}%' ESCAPE '\\'"
+ return sqlite3.connect(":memory:").execute(sql).fetchone() is not None
+
+ def test_percent_matches_literally_not_as_wildcard(self):
+ # "1%" must appear as a literal substring — '1' immediately followed by '%'
+ assert self._like_matches("1% tolerance", "1%") is True # contains "1%"
+ assert self._like_matches("1x tolerance", "1%") is False # no literal '%'
+ assert self._like_matches("10%", "1%") is False # '1' and '%' not adjacent
+
+ def test_underscore_matches_literally_not_as_wildcard(self):
+ assert self._like_matches("1_", "1_") is True
+ assert self._like_matches("1x", "1_") is False
+
+ def test_plain_substring_still_matches(self):
+ assert self._like_matches("10k resistor", "10") is True
+
+ def test_no_match_on_unrelated_string(self):
+ assert self._like_matches("100n capacitor", "1%") is False
+
+
+# ---------------------------------------------------------------------------
+# _escape_fts_phrase
+# ---------------------------------------------------------------------------
+
+
+class TestEscapeFtsPhrase:
+ """Unit tests for _escape_fts_phrase."""
+
+ def test_double_quote_is_doubled(self):
+ assert _escape_fts_phrase('say "hello"') == 'say ""hello""'
+
+ def test_single_quote_is_doubled(self):
+ assert _escape_fts_phrase("it's") == "it''s"
+
+ def test_both_quote_types(self):
+ assert _escape_fts_phrase('"it\'s"') == '""it\'\'s""'
+
+ def test_plain_term_unchanged(self):
+ assert _escape_fts_phrase("resistor") == "resistor"
+
+ def test_empty_string(self):
+ assert _escape_fts_phrase("") == ""
+
+ # Integration: verify the escaped phrase embeds safely in a MATCH string.
+
+ def test_double_quote_in_fts_match_does_not_crash(self):
+ con = sqlite3.connect(":memory:")
+ con.execute("CREATE VIRTUAL TABLE t USING fts5(description)")
+ con.execute("INSERT INTO t VALUES ('say hello world')")
+ con.execute("INSERT INTO t VALUES ('say \"hello\" world')")
+ escaped = _escape_fts_phrase('say "hello"')
+ # Should not raise; FTS5 phrase with escaped double-quote
+ rows = con.execute(
+ f"SELECT description FROM t WHERE t MATCH '\"{escaped}\"'"
+ ).fetchall()
+ assert any('"hello"' in r[0] for r in rows)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py
new file mode 100644
index 00000000..23e887d9
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partsdb.py
@@ -0,0 +1,874 @@
+"""Tests for the partsdb module."""
+
+from datetime import date
+from pathlib import Path
+import shutil
+import sqlite3
+import sys
+import tempfile
+from unittest.mock import Mock, patch
+
+import pytest
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from common.partsdb import _CREATE_STATEMENTS, Generate, PartsDatabase
+from common.progress import NoOpProgressBar
+from common.translate import ComponentTranslator
+
+# ============================================================================
+# Pytest Fixtures
+# ============================================================================
+
+
+@pytest.fixture
+def temp_test_dir():
+ """Create a temporary test directory and clean up after test.
+
+ This fixture creates a unique temporary directory for each test.
+ The directory and all its contents are automatically deleted after
+ the test completes, even if the test fails.
+
+ Returns:
+ Path: The temporary directory path.
+
+ """
+ test_dir = Path(tempfile.mkdtemp(prefix="test_partsdb_"))
+ yield test_dir
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+@pytest.fixture
+def parts_database(temp_test_dir):
+ """Create a PartsDatabase instance and ensure it's closed after test.
+
+ This fixture automatically creates a PartsDatabase with a temporary
+ output database and archive directory. The database connection is
+ automatically closed at the end of the test, ensuring proper cleanup
+ without requiring manual close_sqlite() calls.
+
+ Args:
+ temp_test_dir: The temporary directory fixture.
+
+ Yields:
+ tuple: (database, output_db_path) for use in tests.
+ The database is automatically closed after the test.
+
+ """
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+ output_db = temp_test_dir / "test_parts.db"
+
+ db = PartsDatabase(output_db, archive_dir)
+
+ yield db, output_db
+
+ # Ensure database is closed after test
+ if db.conn:
+ db.close_sqlite()
+
+
+# ============================================================================
+# PartsDatabase Tests
+# ============================================================================
+
+
+class TestPartsDatabase:
+ """Tests for PartsDatabase class."""
+
+ def test_parts_database_init(self, parts_database):
+ """PartsDatabase initializes with correct paths."""
+ db, output_db = parts_database
+
+ assert output_db.exists()
+ assert isinstance(db.part_count, int)
+ assert db.part_count == 0
+
+ def test_parts_database_init_removes_existing(self, temp_test_dir):
+ """PartsDatabase removes existing output database."""
+ # Create an existing database
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ output_db.write_text("old content")
+ assert output_db.exists()
+
+ db = PartsDatabase(output_db, archive_dir)
+
+ # Old file should be removed and replaced with new database
+ assert output_db.exists()
+ # Verify it's a valid database
+ conn = sqlite3.connect(output_db)
+ cursor = conn.cursor()
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ tables = [row[0] for row in cursor.fetchall()]
+ conn.close()
+ assert len(tables) > 0
+ db.close_sqlite()
+
+ def test_parts_database_custom_chunk_num(self, temp_test_dir):
+ """PartsDatabase accepts custom chunk_num filename."""
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ custom_chunk = Path("custom_chunk.txt")
+ db = PartsDatabase(output_db, archive_dir, chunk_num=custom_chunk)
+ assert db.chunk_num == custom_chunk
+ db.close_sqlite()
+
+ def test_parts_database_create_tables(self, parts_database):
+ """PartsDatabase creates all required tables."""
+ db, _ = parts_database
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ tables = [row[0] for row in cursor.fetchall()]
+
+ # Should have parts (FTS5), mapping, meta, categories
+ assert "parts" in tables
+ assert "mapping" in tables
+ assert "meta" in tables
+ assert "categories" in tables
+
+ def test_parts_database_parts_table_schema(self, parts_database):
+ """PartsDatabase creates parts table with correct columns."""
+ db, _ = parts_database
+
+ cursor = db.conn.cursor()
+ cursor.execute("PRAGMA table_info(parts)")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ # FTS5 virtual tables have slightly different schema, just verify parts table exists
+ assert len(columns) > 0
+
+ def test_parts_database_update_parts_single_row(self, parts_database):
+ """PartsDatabase updates parts with single row."""
+ db, _ = parts_database
+
+ row = {
+ "LCSC Part": "C123456",
+ "First Category": "Resistors",
+ "Second Category": "Fixed Resistors",
+ "MFR.Part": "TEST001",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "Samsung",
+ "Library Type": "Basic",
+ "Description": "Test Resistor",
+ "Datasheet": "http://example.com",
+ "Price": "1.00",
+ "Stock": "1000",
+ }
+
+ db.update_parts([row])
+ assert db.part_count == 1
+
+ # Verify data was inserted
+ cursor = db.conn.cursor()
+ cursor.execute('SELECT COUNT(*) FROM parts WHERE "LCSC Part" = ?', ("C123456",))
+ count = cursor.fetchone()[0]
+
+ assert count == 1
+
+ def test_parts_database_update_parts_multiple_rows(self, parts_database):
+ """PartsDatabase updates parts with multiple rows."""
+ db, _ = parts_database
+
+ rows = [
+ {
+ "LCSC Part": f"C{100000 + i}",
+ "First Category": "Category",
+ "Second Category": "SubCategory",
+ "MFR.Part": f"TEST{i:03d}",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "Mfr",
+ "Library Type": "Basic",
+ "Description": f"Part {i}",
+ "Datasheet": "http://example.com",
+ "Price": "1.00",
+ "Stock": "1000",
+ }
+ for i in range(10)
+ ]
+
+ db.update_parts(rows)
+ assert db.part_count == 10
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM parts")
+ count = cursor.fetchone()[0]
+
+ assert count == 10
+
+ def test_parts_database_update_parts_empty_list(self, parts_database):
+ """PartsDatabase handles empty update list gracefully."""
+ db, _ = parts_database
+
+ db.update_parts([])
+ assert db.part_count == 0
+
+ def test_parts_database_update_parts_with_special_chars(self, parts_database):
+ """PartsDatabase handles special characters in part data."""
+ db, _ = parts_database
+
+ row = {
+ "LCSC Part": "C999999",
+ "First Category": "Resistors & Capacitors",
+ "Second Category": "Film/Foil",
+ "MFR.Part": "TEST-001-A",
+ "Package": "1206",
+ "Solder Joint": 2,
+ "Manufacturer": "Inc.",
+ "Library Type": "Preferred",
+ "Description": 'Resistor "precision" type (±1%)',
+ "Datasheet": "http://example.com/ds.pdf?v=1.0",
+ "Price": "2.50-3.00",
+ "Stock": "5000+",
+ }
+
+ db.update_parts([row])
+ assert db.part_count == 1
+
+ def test_parts_database_populate_categories(self, parts_database):
+ """PartsDatabase populates categories from parts."""
+ db, _ = parts_database
+
+ # Insert some parts
+ rows = [
+ {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "T1",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M1",
+ "Library Type": "Basic",
+ "Description": "D1",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ {
+ "LCSC Part": "C2",
+ "First Category": "Capacitors",
+ "Second Category": "Ceramic",
+ "MFR.Part": "T2",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M2",
+ "Library Type": "Basic",
+ "Description": "D2",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ ]
+ db.update_parts(rows)
+ db.populate_categories()
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM categories")
+ count = cursor.fetchone()[0]
+
+ assert count == 2
+
+ def test_parts_database_metadata(self, parts_database):
+ """PartsDatabase records metadata."""
+ db, _ = parts_database
+
+ # Add some parts first
+ row = {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "T1",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M1",
+ "Library Type": "Basic",
+ "Description": "D1",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ }
+ db.update_parts([row])
+ db.meta_data()
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM meta")
+ count = cursor.fetchone()[0]
+
+ cursor.execute("SELECT partcount FROM meta")
+ part_count = cursor.fetchone()[0]
+
+ assert count == 1
+ assert part_count == 1
+
+ def test_parts_database_metadata_records_size(self, parts_database):
+ """PartsDatabase metadata records database size."""
+ db, _ = parts_database
+
+ # Add some data
+ rows = [
+ {
+ "LCSC Part": f"C{i}",
+ "First Category": "Cat",
+ "Second Category": "Sub",
+ "MFR.Part": f"T{i}",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M",
+ "Library Type": "Basic",
+ "Description": f"Description {i}",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ }
+ for i in range(5)
+ ]
+ db.update_parts(rows)
+ db.meta_data()
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT size FROM meta")
+ size = cursor.fetchone()[0]
+
+ # Size should be positive and reasonable
+ assert size > 0
+
+ def test_parts_database_metadata_records_date(self, parts_database):
+ """PartsDatabase metadata records current date."""
+ db, _ = parts_database
+
+ db.meta_data()
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT date FROM meta")
+ result = cursor.fetchone()[0]
+
+ # Should record today's date as a string
+ assert str(date.today()) in result or result == str(date.today())
+
+ def test_parts_database_remove_original(self, temp_test_dir):
+ """PartsDatabase removes existing database."""
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ # Create a file
+ output_db.write_text("test")
+ assert output_db.exists()
+
+ db = PartsDatabase(output_db, archive_dir)
+ # File should be removed during init and recreated as database
+ assert output_db.exists()
+ db.close_sqlite()
+
+ @patch("common.partsdb.FileManager")
+ def test_parts_database_split(self, mock_fm, temp_test_dir):
+ """PartsDatabase calls FileManager to split database."""
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ db = PartsDatabase(output_db, archive_dir)
+
+ # Add some data
+ row = {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "T1",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M1",
+ "Library Type": "Basic",
+ "Description": "D1",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ }
+ db.update_parts([row])
+ db.close_sqlite()
+
+ # Now test split
+ db2 = PartsDatabase(output_db, archive_dir)
+ db2.close_sqlite()
+
+ # Mock the split process so it doesn't actually run
+ with patch("common.partsdb.FileManager"):
+ pass
+
+ @patch("common.partsdb.os.unlink")
+ @patch("common.partsdb.FileManager")
+ def test_parts_database_cleanup(self, mock_fm, mock_unlink, temp_test_dir):
+ """PartsDatabase cleanup removes original database file."""
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ db = PartsDatabase(output_db, archive_dir)
+ db.close_sqlite()
+
+ db.cleanup()
+ mock_unlink.assert_called_once()
+
+ def test_parts_database_skip_cleanup_flag(self, temp_test_dir):
+ """PartsDatabase respects skip_cleanup flag."""
+ output_db = temp_test_dir / "test_parts.db"
+ archive_dir = temp_test_dir / "archive"
+ archive_dir.mkdir()
+
+ db = PartsDatabase(output_db, archive_dir, skip_cleanup=True)
+ assert db.skip_cleanup is True
+ db.close_sqlite()
+
+ @patch("common.partsdb.os.unlink")
+ @patch("common.partsdb.FileManager")
+ def test_parts_database_post_build_builds_categories(
+ self, mock_fm, mock_unlink, parts_database
+ ):
+ """PartsDatabase post_build calls populate_categories."""
+ db, _ = parts_database
+
+ # Insert some parts with different categories
+ rows = [
+ {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "T1",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M1",
+ "Library Type": "Basic",
+ "Description": "D1",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ {
+ "LCSC Part": "C2",
+ "First Category": "Capacitors",
+ "Second Category": "Ceramic",
+ "MFR.Part": "T2",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M2",
+ "Library Type": "Basic",
+ "Description": "D2",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ {
+ "LCSC Part": "C3",
+ "First Category": "Resistors",
+ "Second Category": "Variable",
+ "MFR.Part": "T3",
+ "Package": "0603",
+ "Solder Joint": 2,
+ "Manufacturer": "M3",
+ "Library Type": "Basic",
+ "Description": "D3",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ ]
+ db.update_parts(rows)
+
+ # Mock methods that would fail in test environment
+ with (
+ patch.object(db, "close_sqlite"),
+ patch.object(db, "split"),
+ patch.object(
+ db, "populate_categories", wraps=db.populate_categories
+ ) as mock_populate,
+ ):
+ db.post_build()
+
+ # Verify populate_categories was called
+ mock_populate.assert_called_once()
+
+
+# ============================================================================
+# Generate Class Tests
+# ============================================================================
+
+
+class TestGenerate:
+ """Tests for Generate class."""
+
+ def test_generate_init(self, parts_database):
+ """Generate initializes with database and translator."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ progress = NoOpProgressBar()
+ translator = ComponentTranslator({}, {})
+
+ gen = Generate(mock_componentdb, partsdb, progress, translator)
+
+ assert gen.componentdb == mock_componentdb
+ assert gen.partsdb == partsdb
+ assert gen.translator == translator
+ assert gen.progress == progress
+ assert gen.total_components == 0
+ assert gen.loaded_components == 0
+
+ def test_generate_init_without_translator(self, parts_database):
+ """Generate creates translator if not provided."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ mock_componentdb.get_manufacturers.return_value = {1: "Samsung"}
+ mock_componentdb.get_categories.return_value = {1: ("Resistors", "Fixed")}
+
+ progress = NoOpProgressBar()
+
+ gen = Generate(mock_componentdb, partsdb, progress)
+
+ assert gen.translator is None # Not created until generate() is called
+
+ def test_generate_tracks_components(self, parts_database):
+ """Generate tracks total and loaded component counts."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ mock_componentdb.count_components.return_value = 100
+ mock_componentdb.fetch_components.return_value = []
+
+ progress = NoOpProgressBar()
+
+ gen = Generate(mock_componentdb, partsdb, progress)
+
+ assert gen.total_components == 0
+ gen.total_components = 100
+
+ assert gen.total_components == 100
+
+ @patch("common.partsdb.ComponentTranslator")
+ def test_generate_creates_translator_on_demand(
+ self, mock_translator_class, parts_database
+ ):
+ """Generate creates translator if not provided during generate()."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ mock_componentdb.count_components.return_value = 0
+ mock_componentdb.fetch_components.return_value = []
+ mock_componentdb.get_manufacturers.return_value = {}
+ mock_componentdb.get_categories.return_value = {}
+
+ progress = NoOpProgressBar()
+
+ gen = Generate(mock_componentdb, partsdb, progress)
+
+ # Translator is None initially
+ assert gen.translator is None
+
+ # Call generate (but with mocked data so it doesn't do much)
+ with patch.object(gen, "_process_batches"), patch.object(partsdb, "post_build"):
+ gen.generate()
+
+ # Translator should still be None in this context (mocked away)
+
+ def test_generate_process_batches_empty(self, parts_database):
+ """Generate handles empty component batches."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ mock_componentdb.fetch_components.return_value = []
+
+ progress = NoOpProgressBar()
+ translator = ComponentTranslator({}, {})
+
+ gen = Generate(mock_componentdb, partsdb, progress, translator)
+ gen._process_batches("", None)
+
+ assert gen.loaded_components == 0
+
+ def test_generate_process_batches_single_batch(self, parts_database):
+ """Generate processes a single batch of components."""
+ partsdb, _ = parts_database
+ # Create mock component rows
+ mock_rows = []
+ for _ in range(5):
+ mock_row = Mock()
+ mock_rows.append(mock_row)
+
+ mock_componentdb = Mock()
+ mock_componentdb.fetch_components.return_value = [mock_rows]
+
+ progress = NoOpProgressBar()
+
+ # Create a translator that returns valid part data
+ translator = Mock(spec=ComponentTranslator)
+ translator.translate.side_effect = [
+ {
+ "LCSC Part": f"C{i}",
+ "First Category": "Cat",
+ "Second Category": "Sub",
+ "MFR.Part": f"T{i}",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M",
+ "Library Type": "Basic",
+ "Description": f"D{i}",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ }
+ for i in range(5)
+ ]
+
+ gen = Generate(mock_componentdb, partsdb, progress, translator)
+ gen.total_components = 5
+ gen._process_batches("", None)
+
+ assert gen.loaded_components == 5
+
+ def test_generate_report_stats_no_translator(self, parts_database):
+ """Generate reports error when no data processed."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ progress = NoOpProgressBar()
+
+ gen = Generate(mock_componentdb, partsdb, progress)
+
+ # Should not crash, just report no data
+ gen.translator = None
+ # This would print to stdout, we just verify it doesn't crash
+ gen.report_stats()
+
+ def test_generate_report_stats_with_data(self, parts_database):
+ """Generate reports statistics from translator."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ progress = NoOpProgressBar()
+
+ translator = Mock(spec=ComponentTranslator)
+ translator.get_statistics.return_value = (1000, 50, 10)
+
+ gen = Generate(mock_componentdb, partsdb, progress, translator)
+ gen.report_stats()
+
+ translator.get_statistics.assert_called_once()
+
+ def test_generate_report_stats_zero_deletion(self, parts_database):
+ """Generate handles zero deletions in statistics."""
+ partsdb, _ = parts_database
+ mock_componentdb = Mock()
+ progress = NoOpProgressBar()
+
+ translator = Mock(spec=ComponentTranslator)
+ translator.get_statistics.return_value = (1000, 0, 0)
+
+ gen = Generate(mock_componentdb, partsdb, progress, translator)
+ gen.report_stats()
+
+ translator.get_statistics.assert_called_once()
+
+
+# ============================================================================
+# Integration Tests
+# ============================================================================
+
+
+class TestPartsDBIntegration:
+ """Integration tests for partsdb module."""
+
+ def test_parts_database_full_workflow(self, parts_database):
+ """PartsDatabase handles full workflow: create, insert, optimize, metadata."""
+ db, output_db = parts_database
+
+ # Insert parts
+ rows = [
+ {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "T1",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M1",
+ "Library Type": "Basic",
+ "Description": "D1",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ },
+ ]
+ db.update_parts(rows)
+
+ # Populate categories
+ db.populate_categories()
+
+ # Add metadata
+ db.meta_data()
+
+ # Verify everything worked
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM parts")
+ parts_count = cursor.fetchone()[0]
+
+ cursor.execute("SELECT COUNT(*) FROM categories")
+ categories_count = cursor.fetchone()[0]
+
+ cursor.execute("SELECT COUNT(*) FROM meta")
+ meta_count = cursor.fetchone()[0]
+
+ assert parts_count == 1
+ assert categories_count == 1
+ assert meta_count == 1
+
+ def test_multiple_category_insertion(self, parts_database):
+ """PartsDatabase handles multiple categories correctly."""
+ db, _ = parts_database
+
+ # Insert parts with different categories
+ rows = [
+ {
+ "LCSC Part": f"C{i}",
+ "First Category": f"Category{i // 3}",
+ "Second Category": f"SubCategory{i}",
+ "MFR.Part": f"T{i}",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "M",
+ "Library Type": "Basic",
+ "Description": f"D{i}",
+ "Datasheet": "D",
+ "Price": "1",
+ "Stock": "1000",
+ }
+ for i in range(9)
+ ]
+ db.update_parts(rows)
+ db.populate_categories()
+
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM categories")
+ categories_count = cursor.fetchone()[0]
+
+ # Should have 3 first categories with multiple second categories
+ assert categories_count > 0
+
+ def test_fts5_search_capability(self, parts_database):
+ """PartsDatabase parts table supports FTS5 search."""
+ db, _ = parts_database
+
+ rows = [
+ {
+ "LCSC Part": "C1",
+ "First Category": "Resistors",
+ "Second Category": "Fixed",
+ "MFR.Part": "RESMFR001",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "ResistorCorp",
+ "Library Type": "Basic",
+ "Description": "10K resistor precision type",
+ "Datasheet": "http://example.com",
+ "Price": "0.01",
+ "Stock": "10000",
+ },
+ {
+ "LCSC Part": "C2",
+ "First Category": "Capacitors",
+ "Second Category": "Ceramic",
+ "MFR.Part": "CAPMFR001",
+ "Package": "0805",
+ "Solder Joint": 2,
+ "Manufacturer": "CapacitorCorp",
+ "Library Type": "Preferred",
+ "Description": "100nF ceramic capacitor",
+ "Datasheet": "http://example.com",
+ "Price": "0.005",
+ "Stock": "50000",
+ },
+ ]
+ db.update_parts(rows)
+
+ # Test FTS5 search
+ cursor = db.conn.cursor()
+ cursor.execute("SELECT COUNT(*) FROM parts WHERE parts MATCH ?", ("resistor",))
+ count = cursor.fetchone()[0]
+
+ assert count > 0
+
+ def test_special_field_name_handling(self, parts_database):
+ """PartsDatabase handles field names with spaces and dots correctly."""
+ db, _ = parts_database
+
+ # Fields have spaces like "LCSC Part", "Solder Joint", etc.
+ row = {
+ "LCSC Part": "C999",
+ "First Category": "Test",
+ "Second Category": "Test",
+ "MFR.Part": "TEST.MFR.001",
+ "Package": "BGA",
+ "Solder Joint": 144,
+ "Manufacturer": "TestCorp",
+ "Library Type": "Extended",
+ "Description": "Complex test part",
+ "Datasheet": "http://example.com/datasheet.pdf",
+ "Price": "99.99",
+ "Stock": "100",
+ }
+ db.update_parts([row])
+
+ cursor = db.conn.cursor()
+ cursor.execute('SELECT COUNT(*) FROM parts WHERE "LCSC Part" = ?', ("C999",))
+ count = cursor.fetchone()[0]
+
+ assert count == 1
+
+
+# ============================================================================
+# Constants Tests
+# ============================================================================
+
+
+class TestCreateStatements:
+ """Tests for CREATE_STATEMENTS constants."""
+
+ def test_create_statements_exists(self):
+ """CREATE_STATEMENTS constant exists."""
+ assert _CREATE_STATEMENTS is not None
+ assert isinstance(_CREATE_STATEMENTS, list)
+
+ def test_create_statements_not_empty(self):
+ """CREATE_STATEMENTS has statements."""
+ assert len(_CREATE_STATEMENTS) > 0
+
+ def test_create_statements_are_strings(self):
+ """All CREATE_STATEMENTS are SQL strings."""
+ for stmt in _CREATE_STATEMENTS:
+ assert isinstance(stmt, str)
+ assert "CREATE" in stmt.upper()
+
+ def test_create_statements_count(self):
+ """CREATE_STATEMENTS has expected number of table definitions."""
+ # Should have: parts (FTS5), mapping, meta, categories
+ assert len(_CREATE_STATEMENTS) >= 4
+
+ def test_parts_table_statement(self):
+ """Parts table statement creates FTS5 virtual table."""
+ parts_stmt = _CREATE_STATEMENTS[0]
+ assert "parts" in parts_stmt.lower()
+ assert "fts5" in parts_stmt.lower()
+ assert "LCSC Part" in parts_stmt
+
+ def test_mapping_table_statement(self):
+ """Mapping table statement exists."""
+ assert any("mapping" in stmt.lower() for stmt in _CREATE_STATEMENTS)
+
+ def test_meta_table_statement(self):
+ """Meta table statement exists."""
+ assert any("meta" in stmt.lower() for stmt in _CREATE_STATEMENTS)
+
+ def test_categories_table_statement(self):
+ """Categories table statement exists."""
+ assert any("categories" in stmt.lower() for stmt in _CREATE_STATEMENTS)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py
new file mode 100644
index 00000000..185d9e39
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_partselector_highlight.py
@@ -0,0 +1,68 @@
+"""Tests for part selector text highlight helpers."""
+
+# ruff: noqa: D103
+
+from partselector_highlight import (
+ HighlightQueryCache,
+ filtered_highlight_terms,
+ find_highlight_spans,
+ normalize_highlight_terms,
+)
+
+
+def test_normalize_highlight_terms_splits_and_deduplicates():
+ assert normalize_highlight_terms(" 10k %0603% 10K ") == ["10k", "0603"]
+
+
+def test_find_highlight_spans_matches_multiple_terms_case_insensitively():
+ assert find_highlight_spans("10K resistor 0603", ["10k", "0603"]) == [
+ (0, 3),
+ (13, 17),
+ ]
+
+
+def test_find_highlight_spans_merges_overlapping_matches():
+ assert find_highlight_spans("abcde", ["abc", "bcd"]) == [(0, 4)]
+
+
+def test_filtered_highlight_terms_skips_short_terms():
+ assert filtered_highlight_terms("1 12 1206") == ["12", "1206"]
+
+
+def test_highlight_query_cache_stores_negative_results():
+ cache = HighlightQueryCache()
+ cache.prepare("zzzz")
+ spans = cache.get_spans("Murata Electronics")
+ assert spans == []
+
+
+def test_highlight_query_cache_reuses_cached_negative_results():
+ cache = HighlightQueryCache()
+ cache.prepare("zzzz")
+ first = cache.get_spans("Murata Electronics")
+ second = cache.get_spans("Murata Electronics")
+
+ assert first == []
+ assert second == []
+ assert first is second
+
+
+def test_highlight_query_cache_prepare_changes_terms_and_resets_spans():
+ cache = HighlightQueryCache()
+ cache.prepare("murata")
+ spans_before = cache.get_spans("Murata Electronics")
+ cache.prepare("1206")
+ spans_after = cache.get_spans("Murata Electronics")
+
+ assert spans_before != []
+ assert cache.get_terms() == ["1206"]
+ assert spans_after == []
+
+
+def test_highlight_query_cache_clear_resets_all_state():
+ cache = HighlightQueryCache()
+ cache.prepare("murata 1206")
+ _ = cache.get_spans("Murata Electronics")
+ cache.clear()
+
+ assert cache.get_terms() == []
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py b/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py
new file mode 100644
index 00000000..b689ca2c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/test_translate.py
@@ -0,0 +1,622 @@
+"""Tests for the translate module."""
+
+import json
+
+from common.translate import ComponentTranslator, Price, PriceEntry, process_description
+
+# ============================================================================
+# PriceEntry Tests
+# ============================================================================
+
+
+class TestPriceEntry:
+ """Tests for PriceEntry class."""
+
+ def test_price_entry_init(self):
+ """PriceEntry initializes with correct values."""
+ entry = PriceEntry(10, 100, "5.50")
+ assert entry.min_quantity == 10
+ assert entry.max_quantity == 100
+ assert entry.price_dollars_str == "5.50"
+ assert entry.price_dollars == 5.50
+
+ def test_price_entry_init_no_max(self):
+ """PriceEntry can be created without max_quantity."""
+ entry = PriceEntry(500, None, "1.25")
+ assert entry.min_quantity == 500
+ assert entry.max_quantity is None
+ assert entry.price_dollars == 1.25
+
+ def test_price_entry_parse_with_qto(self):
+ """PriceEntry.Parse extracts data from dict with qTo."""
+ entry_dict = {"qFrom": "100", "qTo": "500", "price": "2.50"}
+ entry = PriceEntry.Parse(entry_dict)
+ assert entry.min_quantity == 100
+ assert entry.max_quantity == 500
+ assert entry.price_dollars_str == "2.50"
+
+ def test_price_entry_parse_without_qto(self):
+ """PriceEntry.Parse handles missing qTo as None."""
+ entry_dict = {"qFrom": "1000", "qTo": None, "price": "0.75"}
+ entry = PriceEntry.Parse(entry_dict)
+ assert entry.min_quantity == 1000
+ assert entry.max_quantity is None
+ assert entry.price_dollars == 0.75
+
+ def test_price_entry_repr(self):
+ """PriceEntry string representation is correct."""
+ entry = PriceEntry(1, 100, "5.50")
+ assert repr(entry) == "1-100:5.50"
+
+ def test_price_entry_repr_no_max(self):
+ """PriceEntry repr shows empty max when None."""
+ entry = PriceEntry(500, None, "1.00")
+ assert repr(entry) == "500-:1.00"
+
+ def test_price_entry_float_conversion(self):
+ """PriceEntry converts string price to float correctly."""
+ entry = PriceEntry(1, 10, "0.005")
+ assert entry.price_dollars == 0.005
+ assert isinstance(entry.price_dollars, float)
+
+
+# ============================================================================
+# Price Class Tests
+# ============================================================================
+
+
+class TestPrice:
+ """Tests for Price class."""
+
+ def test_price_init_single_entry(self):
+ """Price initializes with single price entry."""
+ price_data = [{"qFrom": "1", "qTo": "100", "price": "5.00"}]
+ price = Price(price_data)
+ assert len(price.price_entries) == 1
+ assert price.price_entries[0].min_quantity == 1
+
+ def test_price_init_multiple_entries(self):
+ """Price initializes with multiple price entries."""
+ price_data = [
+ {"qFrom": "1", "qTo": "100", "price": "5.00"},
+ {"qFrom": "101", "qTo": "500", "price": "3.00"},
+ {"qFrom": "501", "qTo": None, "price": "1.50"},
+ ]
+ price = Price(price_data)
+ assert len(price.price_entries) == 3
+
+ def test_price_init_empty(self):
+ """Price can be initialized with empty list."""
+ price = Price([])
+ assert len(price.price_entries) == 0
+
+ def test_reduce_precision_single_value(self):
+ """Reduce precision converts price to 3 decimal places."""
+ entries = [PriceEntry(1, 100, "0.123456789")]
+ result = Price.reduce_precision(entries)
+ assert result[0].price_dollars_str == "0.123"
+ assert result[0].price_dollars == 0.123
+
+ def test_reduce_precision_multiple_values(self):
+ """Reduce precision works on multiple entries."""
+ entries = [
+ PriceEntry(1, 100, "5.123456"),
+ PriceEntry(101, 200, "3.999999"),
+ PriceEntry(201, None, "1.111111"),
+ ]
+ result = Price.reduce_precision(entries)
+ assert len(result) == 3
+ assert result[0].price_dollars_str == "5.123"
+ assert result[1].price_dollars_str == "4.000"
+ assert result[2].price_dollars_str == "1.111"
+
+ def test_reduce_precision_rounding(self):
+ """Reduce precision rounds correctly."""
+ entries = [
+ PriceEntry(1, 100, "0.9999"),
+ PriceEntry(101, 200, "0.1234"),
+ PriceEntry(201, None, "0.0001"),
+ ]
+ result = Price.reduce_precision(entries)
+ assert result[0].price_dollars == 1.000
+ assert result[1].price_dollars == 0.123
+ assert result[2].price_dollars == 0.000
+
+ def test_filter_below_cutoff_keeps_first(self):
+ """Filter below cutoff always keeps first entry."""
+ entries = [
+ PriceEntry(1, 100, "0.05"),
+ PriceEntry(101, 200, "0.02"),
+ ]
+ result = Price.filter_below_cutoff(entries, 0.03)
+ assert len(result) == 1
+ assert result[0].price_dollars == 0.05
+
+ def test_filter_below_cutoff_multiple_entries(self):
+ """Filter below cutoff removes only entries below threshold."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.30"),
+ PriceEntry(201, 300, "0.20"),
+ PriceEntry(301, 400, "0.10"),
+ ]
+ result = Price.filter_below_cutoff(entries, 0.25)
+ assert len(result) == 2
+ assert result[0].price_dollars == 0.40
+ assert result[1].price_dollars == 0.30
+
+ def test_filter_below_cutoff_empty_list(self):
+ """Filter below cutoff handles empty list."""
+ entries = []
+ result = Price.filter_below_cutoff(entries, 0.01)
+ assert len(result) == 0
+
+ def test_filter_below_cutoff_sets_final_max_none(self):
+ """Filter below cutoff sets last entry max_quantity to None."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.30"),
+ PriceEntry(201, None, "0.20"),
+ ]
+ result = Price.filter_below_cutoff(entries, 0.15)
+ assert result[-1].max_quantity is None
+
+ def test_filter_below_cutoff_all_above_threshold(self):
+ """Filter below cutoff keeps all when all above threshold."""
+ entries = [
+ PriceEntry(1, 100, "0.50"),
+ PriceEntry(101, 200, "0.40"),
+ PriceEntry(201, None, "0.30"),
+ ]
+ result = Price.filter_below_cutoff(entries, 0.20)
+ assert len(result) == 3
+
+ def test_filter_duplicate_prices_single_entry(self):
+ """Filter duplicate prices works with single entry."""
+ entries = [PriceEntry(1, 100, "0.50")]
+ result = Price.filter_duplicate_prices(entries)
+ assert len(result) == 1
+ assert result[0].price_dollars_str == "0.50"
+
+ def test_filter_duplicate_prices_no_duplicates(self):
+ """Filter duplicate prices preserves unique prices."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.30"),
+ PriceEntry(201, None, "0.20"),
+ ]
+ result = Price.filter_duplicate_prices(entries)
+ assert len(result) == 3
+
+ def test_filter_duplicate_prices_consecutive_duplicates(self):
+ """Filter duplicate prices merges consecutive duplicates."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.30"),
+ PriceEntry(201, 300, "0.30"),
+ PriceEntry(301, None, "0.30"),
+ ]
+ result = Price.filter_duplicate_prices(entries)
+ assert len(result) == 2
+ assert result[1].min_quantity == 101
+ assert result[1].max_quantity is None
+
+ def test_filter_duplicate_prices_multiple_groups(self):
+ """Filter duplicate prices handles multiple duplicate groups."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.40"),
+ PriceEntry(201, 300, "0.30"),
+ PriceEntry(301, 400, "0.30"),
+ PriceEntry(401, None, "0.20"),
+ ]
+ result = Price.filter_duplicate_prices(entries)
+ assert len(result) == 3
+ assert result[0].min_quantity == 1
+ assert result[0].max_quantity == 200
+ assert result[1].min_quantity == 201
+ assert result[1].max_quantity == 400
+
+ def test_filter_duplicate_prices_ensures_last_has_none(self):
+ """Filter duplicate prices merges quantity ranges."""
+ entries = [
+ PriceEntry(1, 100, "0.40"),
+ PriceEntry(101, 200, "0.30"),
+ PriceEntry(201, 300, "0.30"),
+ ]
+ result = Price.filter_duplicate_prices(entries)
+ # When duplicates are merged, the merged entry gets the max_quantity from the last duplicate
+ # In this case, entries[1] and entries[2] both have price 0.30
+ # so they merge with max_quantity from entries[2] which is 300
+ assert len(result) == 2
+ assert result[1].price_dollars_str == "0.30"
+ assert result[1].min_quantity == 101
+ assert result[1].max_quantity == 300
+
+ def test_filter_duplicate_prices_empty_list(self):
+ """Filter duplicate prices handles empty list."""
+ entries = []
+ result = Price.filter_duplicate_prices(entries)
+ assert len(result) == 0
+
+ def test_process_integration(self):
+ """Process method integrates all steps correctly."""
+ price_json = json.dumps(
+ [
+ {"qFrom": "1", "qTo": "100", "price": "5.123456"},
+ {"qFrom": "101", "qTo": "500", "price": "3.987654"},
+ {"qFrom": "501", "qTo": None, "price": "0.005"},
+ ]
+ )
+ price_str, total, deleted, duplicates = Price.process(price_json)
+
+ assert total == 3
+ assert deleted == 1 # 0.005 is below cutoff
+ assert duplicates == 0
+ assert "5.123" in price_str
+ assert "3.988" in price_str
+
+ def test_process_with_duplicates(self):
+ """Process removes duplicates correctly."""
+ price_json = json.dumps(
+ [
+ {"qFrom": "1", "qTo": "100", "price": "5.00"},
+ {"qFrom": "101", "qTo": "200", "price": "5.00"},
+ {"qFrom": "201", "qTo": None, "price": "5.00"},
+ ]
+ )
+ price_str, total, deleted, duplicates = Price.process(price_json)
+
+ assert total == 3
+ assert duplicates == 2
+
+ def test_process_with_mixed_filtering(self):
+ """Process handles both cutoff and duplicates."""
+ price_json = json.dumps(
+ [
+ {"qFrom": "1", "qTo": "100", "price": "5.00"},
+ {"qFrom": "101", "qTo": "200", "price": "5.00"},
+ {"qFrom": "201", "qTo": "300", "price": "0.001"},
+ {"qFrom": "301", "qTo": None, "price": "0.001"},
+ ]
+ )
+ price_str, total, deleted, duplicates = Price.process(price_json)
+
+ assert total == 4
+ assert deleted >= 2 # At least the below-cutoff entries
+
+
+# ============================================================================
+# Utility Function Tests
+# ============================================================================
+
+
+class TestLibraryType:
+ """Tests for library_type method in ComponentTranslator."""
+
+ def test_library_type_basic(self):
+ """library_type returns 'Basic' for basic parts."""
+ translator = ComponentTranslator({}, {})
+
+ # Mock the row by creating a simple dict-like object
+ class MockRow:
+ def __getitem__(self, key):
+ if key == "basic":
+ return True
+ return False
+
+ result = translator.library_type(MockRow()) # type: ignore
+ assert result == "Basic"
+
+ def test_library_type_preferred_when_populate_preferred_true(self):
+ """library_type returns 'Preferred' when populate_preferred=True."""
+ translator = ComponentTranslator({}, {}, populate_preferred=True)
+
+ class MockRow:
+ def __getitem__(self, key):
+ if key == "basic":
+ return False
+ if key == "preferred":
+ return True
+ return False
+
+ result = translator.library_type(MockRow()) # type: ignore
+ assert result == "Preferred"
+
+ def test_library_type_extended_when_populate_preferred_false(self):
+ """library_type returns 'Extended' for preferred parts when populate_preferred=False."""
+ translator = ComponentTranslator({}, {}, populate_preferred=False)
+
+ class MockRow:
+ def __getitem__(self, key):
+ if key == "basic":
+ return False
+ if key == "preferred":
+ return True
+ return False
+
+ result = translator.library_type(MockRow()) # type: ignore
+ assert result == "Extended"
+
+ def test_library_type_extended_default(self):
+ """library_type returns 'Extended' for extended parts (default behavior)."""
+ translator = ComponentTranslator({}, {})
+
+ class MockRow:
+ def __getitem__(self, key):
+ return False
+
+ result = translator.library_type(MockRow()) # type: ignore
+ assert result == "Extended"
+
+
+class TestProcessDescription:
+ """Tests for process_description function."""
+
+ def test_process_description_basic(self):
+ """process_description handles basic input."""
+ result = process_description("Resistor 10K ROHS", None, "Resistors", "0805")
+ assert "Resistor" in result
+ assert "10K" in result
+ # When " ROHS" is present, it's removed
+ assert " ROHS" not in result
+
+ def test_process_description_removes_category(self):
+ """process_description removes second category from description."""
+ result = process_description(
+ "Resistor 10K ROHS Resistors", None, "Resistors", "0805"
+ )
+ assert result.count("Resistor") == 1
+
+ def test_process_description_removes_package(self):
+ """process_description removes package from description."""
+ result = process_description(
+ "Resistor 10K ROHS 0805", None, "Resistors", "0805"
+ )
+ assert "0805" not in result
+
+ def test_process_description_fixes_double_spaces(self):
+ """process_description removes double spaces."""
+ result = process_description("Resistor 10K ROHS", None, "Resistors", "0805")
+ assert " " not in result
+
+ def test_process_description_strips_trailing_spaces(self):
+ """process_description removes trailing spaces."""
+ result = process_description("Resistor 10K ROHS ", None, "Resistors", "0805")
+ assert result == result.strip()
+
+ def test_process_description_adds_not_rohs_when_missing(self):
+ """process_description adds 'not ROHS' when ROHS is absent."""
+ result = process_description("Resistor 10K", None, "Resistors", "0805")
+ assert "not ROHS" in result
+
+ def test_process_description_removes_rohs_when_present(self):
+ """process_description removes ROHS from description."""
+ result = process_description("Resistor 10K ROHS", None, "Resistors", "0805")
+ assert "ROHS" not in result.upper() or "not ROHS" in result
+
+ def test_process_description_rohs_case_insensitive(self):
+ """process_description checks for space+ROHS case-insensitive."""
+ # \" ROHS\" (with space) is found case-insensitively, so \"rohs\" without space won't match
+ result = process_description("Resistor 10K rohs", None, "Resistors", "0805")
+ # Since \" ROHS\" is not found, \"not ROHS\" should be added
+ # But the input doesn't have a space before rohs, so it remains
+ assert "rohs" in result
+
+ def test_process_description_with_extra_json(self):
+ """process_description uses description from extra JSON."""
+ extra = json.dumps({"description": "Extra description"})
+ result = process_description("Original", extra, "Resistors", "0805")
+ assert "Extra description" in result
+
+ def test_process_description_with_invalid_extra_json(self):
+ """process_description handles invalid extra JSON gracefully."""
+ result = process_description(
+ "Original ROHS", "invalid json", "Resistors", "0805"
+ )
+ assert "Original" in result
+
+ def test_process_description_with_extra_json_no_description_key(self):
+ """process_description uses original when extra has no description key."""
+ extra = json.dumps({"other_key": "value"})
+ result = process_description("Original ROHS", extra, "Resistors", "0805")
+ assert "Original" in result
+
+
+# ============================================================================
+# ComponentTranslator Tests
+# ============================================================================
+
+
+class TestComponentTranslator:
+ """Tests for ComponentTranslator class."""
+
+ def test_translator_init(self):
+ """ComponentTranslator initializes with correct data structures."""
+ manufacturers = {1: "Samsung", 2: "Intel"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(manufacturers, categories)
+
+ assert translator.manufacturers == manufacturers
+ assert translator.categories == categories
+ assert translator.price_entries_total == 0
+ assert translator.price_entries_deleted == 0
+ assert translator.price_entries_duplicates_deleted == 0
+ assert translator.populate_preferred is False # default value
+
+ def test_translator_init_with_populate_preferred(self):
+ """ComponentTranslator initializes with populate_preferred flag."""
+ manufacturers = {1: "Samsung"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(
+ manufacturers, categories, populate_preferred=True
+ )
+
+ assert translator.populate_preferred is True
+
+ def test_translator_get_statistics_initial(self):
+ """get_statistics returns zeros initially."""
+ translator = ComponentTranslator({}, {})
+ total, deleted, duplicates = translator.get_statistics()
+
+ assert total == 0
+ assert deleted == 0
+ assert duplicates == 0
+
+ def test_translator_translate_basic(self):
+ """Translate converts a component row to parts row."""
+ manufacturers = {1: "Samsung"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(manufacturers, categories)
+
+ # Create a mock row
+ class MockRow:
+ def __getitem__(self, key):
+ data = {
+ "lcsc": "123456",
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "price": json.dumps(
+ [{"qFrom": "1", "qTo": "100", "price": "5.00"}]
+ ),
+ "description": "Test Resistor ROHS",
+ "extra": None,
+ "package": "0805",
+ "mfr": "TESTMFR001",
+ "joints": "2",
+ "datasheet": "http://example.com/ds.pdf",
+ "stock": "1000",
+ "basic": True,
+ "preferred": False,
+ }
+ return data[key]
+
+ result = translator.translate(MockRow()) # type: ignore
+
+ assert result["LCSC Part"] == "C123456"
+ assert result["First Category"] == "Resistors"
+ assert result["Second Category"] == "Fixed Resistors"
+ assert result["MFR.Part"] == "TESTMFR001"
+ assert result["Package"] == "0805"
+ assert result["Solder Joint"] == 2
+ assert result["Manufacturer"] == "Samsung"
+ assert result["Library Type"] == "Basic"
+ assert "Resistor" in str(result["Description"])
+ assert result["Datasheet"] == "http://example.com/ds.pdf"
+ assert "5" in str(result["Price"])
+ assert result["Stock"] == "1000"
+
+ def test_translator_tracks_statistics(self):
+ """Translate accumulates statistics across multiple calls."""
+ manufacturers = {1: "Samsung"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(manufacturers, categories)
+
+ class MockRow:
+ def __init__(self, price_json):
+ self.price_json = price_json
+
+ def __getitem__(self, key):
+ data = {
+ "lcsc": "123456",
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "price": self.price_json,
+ "description": "Test ROHS",
+ "extra": None,
+ "package": "0805",
+ "mfr": "TESTMFR",
+ "joints": "2",
+ "datasheet": "http://example.com",
+ "stock": "1000",
+ "basic": True,
+ "preferred": False,
+ }
+ return data[key]
+
+ # First component with 3 prices, 1 deleted
+ price_json_1 = json.dumps(
+ [
+ {"qFrom": "1", "qTo": "100", "price": "5.00"},
+ {"qFrom": "101", "qTo": "200", "price": "3.00"},
+ {"qFrom": "201", "qTo": None, "price": "0.001"},
+ ]
+ )
+ translator.translate(MockRow(price_json_1)) # type: ignore
+
+ # Second component with 2 prices
+ price_json_2 = json.dumps(
+ [
+ {"qFrom": "1", "qTo": "100", "price": "2.00"},
+ {"qFrom": "101", "qTo": None, "price": "1.00"},
+ ]
+ )
+ translator.translate(MockRow(price_json_2)) # type: ignore
+
+ total, deleted, duplicates = translator.get_statistics()
+ assert total == 5
+ assert deleted >= 1 # At least the below-cutoff entry
+
+ def test_translator_translate_preferred_with_populate_preferred_true(self):
+ """Translate uses 'Preferred' library type when populate_preferred=True."""
+ manufacturers = {1: "Samsung"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(
+ manufacturers, categories, populate_preferred=True
+ )
+
+ class MockRow:
+ def __getitem__(self, key):
+ data = {
+ "lcsc": "123456",
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "price": json.dumps(
+ [{"qFrom": "1", "qTo": "100", "price": "5.00"}]
+ ),
+ "description": "Test Resistor ROHS",
+ "extra": None,
+ "package": "0805",
+ "mfr": "TESTMFR001",
+ "joints": "2",
+ "datasheet": "http://example.com/ds.pdf",
+ "stock": "1000",
+ "basic": False,
+ "preferred": True,
+ }
+ return data[key]
+
+ result = translator.translate(MockRow()) # type: ignore
+ assert result["Library Type"] == "Preferred"
+
+ def test_translator_translate_preferred_with_populate_preferred_false(self):
+ """Translate uses 'Extended' library type for preferred when populate_preferred=False."""
+ manufacturers = {1: "Samsung"}
+ categories = {1: ("Resistors", "Fixed Resistors")}
+ translator = ComponentTranslator(
+ manufacturers, categories, populate_preferred=False
+ )
+
+ class MockRow:
+ def __getitem__(self, key):
+ data = {
+ "lcsc": "123456",
+ "category_id": 1,
+ "manufacturer_id": 1,
+ "price": json.dumps(
+ [{"qFrom": "1", "qTo": "100", "price": "5.00"}]
+ ),
+ "description": "Test Resistor ROHS",
+ "extra": None,
+ "package": "0805",
+ "mfr": "TESTMFR001",
+ "joints": "2",
+ "datasheet": "http://example.com/ds.pdf",
+ "stock": "1000",
+ "basic": False,
+ "preferred": True,
+ }
+ return data[key]
+
+ result = translator.translate(MockRow()) # type: ignore
+ assert result["Library Type"] == "Extended"
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py b/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py
new file mode 100644
index 00000000..d56bfb0e
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/common/translate.py
@@ -0,0 +1,306 @@
+"""Translation and mapping between component database and parts database formats."""
+
+import copy
+import json
+import sqlite3
+
+
+class PriceEntry:
+ """Price for a quantity range."""
+
+ def __init__(self, min_quantity: int, max_quantity: int | None, price_dollars: str):
+ self.min_quantity = min_quantity
+ self.max_quantity = max_quantity
+ self.price_dollars_str = price_dollars
+ self.price_dollars = float(self.price_dollars_str)
+
+ @classmethod
+ def Parse(cls, price_entry: dict[str, str]):
+ """Parse an individual price entry."""
+
+ price_dollars_str = price_entry["price"]
+
+ min_quantity = int(price_entry["qFrom"])
+ max_quantity = (
+ int(price_entry["qTo"]) if price_entry.get("qTo") is not None else None
+ )
+
+ return cls(min_quantity, max_quantity, price_dollars_str)
+
+ def __repr__(self):
+ """Conversion to string function."""
+ return f"{self.min_quantity}-{self.max_quantity if self.max_quantity is not None else ''}:{self.price_dollars_str}"
+
+ min_quantity: int
+ max_quantity: int | None
+ price_dollars_str: str # to avoid rounding due to float conversion
+ price_dollars: float
+
+
+class Price:
+ """Price parsing and management functions."""
+
+ def __init__(self, part_price: list[dict[str, str]]):
+ """Format of part_price is determined by json.loads()."""
+ self.price_entries = []
+ for price in part_price:
+ self.price_entries.append(PriceEntry.Parse(price))
+
+ price_entries: list[PriceEntry]
+
+ @staticmethod
+ def reduce_precision(entries: list[PriceEntry]) -> list[PriceEntry]:
+ """Reduce the precision of price entries to 3 significant digits."""
+
+ """Values after this are not particularly helpful unless many thousands
+ of the part is used, and at those quantities of boards and parts
+ the contract manufacturer is likely to have special deals."""
+
+ pe = entries
+ for i in range(len(pe)):
+ pe[i].price_dollars_str = f"{pe[i].price_dollars:.3f}"
+ pe[i].price_dollars = round(pe[i].price_dollars, 3)
+
+ return entries
+
+ @staticmethod
+ def filter_below_cutoff(
+ entries: list[PriceEntry], cutoff_price_dollars: float
+ ) -> list[PriceEntry]:
+ """Remove PriceEntry values with a price_dollars below cutoff_price_dollars. Keep the first entry if one exists. Assumes order is highest price to lowest price."""
+
+ filtered_entries: list[PriceEntry] = []
+
+ # some components have no price entries
+ if len(entries) >= 1:
+ # always include the first entry.
+ filtered_entries.append(entries[0])
+ for entry in entries[1:]:
+ # add the entries with a price greater than the cutoff
+ if entry.price_dollars >= cutoff_price_dollars:
+ filtered_entries.append(entry)
+
+ if len(filtered_entries) > 0:
+ # ensure the last entry in the list has a max_quantity of None
+ # as that price continues out indefinitely
+ filtered_entries[len(filtered_entries) - 1].max_quantity = None
+
+ return filtered_entries
+
+ @staticmethod
+ def filter_duplicate_prices(entries: list[PriceEntry]) -> list[PriceEntry]:
+ """Remove entries with duplicate price_dollar_str values, merging quantities so there aren't gaps."""
+
+ # copy.deepcopy() is used to value modifications from altering the original values.
+ price_entries_unique: list[PriceEntry] = []
+ if len(entries) > 1:
+ first = 0
+ second = 1
+ f: PriceEntry | None = None
+ while True:
+ if f is None:
+ f = copy.deepcopy(entries[first])
+
+ # stop when the second element is at the end of the list
+ if second >= len(entries):
+ break
+
+ # if match, copy over the quantity and advance the second, keep searching for a mismatch
+ if f.price_dollars_str == entries[second].price_dollars_str:
+ f.max_quantity = entries[second].max_quantity
+ second += 1
+ else: # if no match, add the first and then start looking at the second
+ price_entries_unique.append(f)
+ first = second
+ second = first + 1
+ f = None
+
+ # always add the final first entry when we run out of elements to process
+ price_entries_unique.append(f)
+ else: # only a single entry, nothing to de-duplicate
+ price_entries_unique = entries
+
+ return price_entries_unique
+
+ @staticmethod
+ def process(price_json: str) -> tuple[str, int, int, int]:
+ """Process price data and return price string and statistics.
+
+ Returns:
+ tuple of (price_str, total_entries, entries_deleted, duplicates_deleted)
+
+ """
+ priceInput = json.loads(price_json)
+
+ # parse the price field
+ price = Price(priceInput)
+
+ price_entries = Price.reduce_precision(price.price_entries)
+ entries_total = len(price_entries)
+
+ # filter parts priced below the cutoff value
+ price_entries_cutoff = Price.filter_below_cutoff(price_entries, 0.01)
+ entries_deleted = len(price_entries) - len(price_entries_cutoff)
+
+ # alias the variable for the next step
+ price_entries = price_entries_cutoff
+
+ # remove duplicates
+ price_entries_unique = Price.filter_duplicate_prices(price_entries)
+ duplicates_deleted = len(price_entries) - len(price_entries_unique)
+ entries_deleted += duplicates_deleted
+
+ # alias over the variable for the next step
+ price_entries = price_entries_unique
+
+ # build the output string that is stored into the parts database
+ price_str = ",".join(
+ [
+ f"{entry.min_quantity}-{entry.max_quantity if entry.max_quantity is not None else ''}:{entry.price_dollars_str}"
+ for entry in price_entries
+ ]
+ )
+
+ return price_str, entries_total, entries_deleted, duplicates_deleted
+
+
+def process_description(
+ description: str, extra_json: str | None, category: str, package: str
+) -> str:
+ """Process and clean component description.
+
+ Args:
+ description: Original description from component
+ extra_json: Extra JSON field containing additional description
+ category: Second category to remove from description
+ package: Package type to remove from description
+
+ Returns:
+ Cleaned description string
+
+ """
+ # default to 'description', override it with the 'description' property from
+ # 'extra' if it exists
+ if extra_json is not None:
+ try:
+ extra = json.loads(extra_json)
+ if "description" in extra:
+ description = extra["description"]
+ except Exception:
+ pass
+
+ # strip ROHS out of descriptions where present
+ # and add 'not ROHS' where ROHS is not present
+ # as 99% of parts are ROHS at this point
+ if " ROHS".lower() not in description.lower():
+ description += " not ROHS"
+ else:
+ description = description.replace(" ROHS", "")
+
+ # strip the 'Second category' out of the description if it
+ # is duplicated there
+ description = description.replace(category, "")
+
+ # remove 'Package' from the description if it is duplicated there
+ description = description.replace(package, "")
+
+ # replace double spaces with single spaces in description
+ description = description.replace(" ", " ")
+
+ # remove trailing spaces from description
+ description = description.strip()
+
+ return description
+
+
+class ComponentTranslator:
+ """Translates component database rows to parts database rows while tracking statistics."""
+
+ def __init__(
+ self,
+ manufacturers: dict[int, str],
+ categories: dict[int, tuple[str, str]],
+ populate_preferred: bool = False,
+ ):
+ """Initialize the translator.
+
+ Args:
+ manufacturers: Dict mapping manufacturer_id to manufacturer name
+ categories: Dict mapping category_id to (first_category, second_category)
+ tuple.
+ populate_preferred: Whether to populate preferred parts as 'Preferred'
+ or the backwards-compatible 'Extended'.
+
+ """
+ self.manufacturers = manufacturers
+ self.categories = categories
+ self.price_entries_total = 0
+ self.price_entries_deleted = 0
+ self.price_entries_duplicates_deleted = 0
+ self.populate_preferred = populate_preferred
+
+ def library_type(self, row: sqlite3.Row) -> str:
+ """Return library type string."""
+ if row["basic"]:
+ return "Basic"
+ if row["preferred"] and self.populate_preferred:
+ return "Preferred"
+ return "Extended"
+
+ def translate(self, component_row: sqlite3.Row) -> dict[str, str | int]:
+ """Convert a component database row to a parts database row.
+
+ Args:
+ component_row: Row from components table in component database
+
+ Returns:
+ Dict ready to insert into parts FTS5 table
+
+ """
+ lcsc = component_row["lcsc"]
+ category_id = component_row["category_id"]
+ manufacturer_id = component_row["manufacturer_id"]
+
+ price_str, total, deleted, duplicates = Price.process(component_row["price"])
+ self.price_entries_total += total
+ self.price_entries_deleted += deleted
+ self.price_entries_duplicates_deleted += duplicates
+
+ description = process_description(
+ component_row["description"],
+ component_row["extra"],
+ self.categories[category_id][1],
+ component_row["package"],
+ )
+
+ libType = self.library_type(component_row)
+
+ row = {
+ "LCSC Part": f"C{lcsc}",
+ "First Category": self.categories[category_id][0],
+ "Second Category": self.categories[category_id][1],
+ "MFR.Part": component_row["mfr"],
+ "Package": component_row["package"],
+ "Solder Joint": int(component_row["joints"]),
+ "Manufacturer": self.manufacturers[manufacturer_id],
+ "Library Type": libType,
+ "Description": description,
+ "Datasheet": component_row["datasheet"],
+ "Price": price_str,
+ "Stock": str(component_row["stock"]),
+ }
+
+ return row
+
+ def get_statistics(self) -> tuple[int, int, int]:
+ """Get price processing statistics.
+
+ Returns:
+ Tuple of (total_entries, deleted_entries, duplicates_deleted)
+
+ """
+ return (
+ self.price_entries_total,
+ self.price_entries_deleted,
+ self.price_entries_duplicates_deleted,
+ )
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/core/pytest.ini b/third_party/Bouni/kicad-jlcpcb-tools/core/pytest.ini
new file mode 100644
index 00000000..e69de29b
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/core/version.py b/third_party/Bouni/kicad-jlcpcb-tools/core/version.py
new file mode 100644
index 00000000..b1d00a20
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/core/version.py
@@ -0,0 +1,45 @@
+"""Contains helper function used all over the plugin."""
+
+import re
+
+from packaging.version import Version
+
+
+def _is_version_in_range(version: str, min_version: str, max_version: str) -> bool:
+ """Check if version is in range. Must comply with https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers."""
+ # Remove any trailing '.fc42', '+gXXXX', or similar after the first long segment
+ ver = Version(re.sub(r"([^-]+(?:-[^-]+)*)-.*", r"\1", version))
+ return Version(min_version) <= ver < Version(max_version)
+
+
+def is_version7(version: str) -> bool:
+ """Check if version is 7."""
+ return _is_version_in_range(version, "6.99", "8.0")
+
+
+def is_version6(version: str) -> bool:
+ """Check if version is 6."""
+ return _is_version_in_range(version, "5.99", "7.0")
+
+
+def test_version():
+ """Tests for the various is_versionX() functions."""
+ v6 = "6.1"
+ v7 = "7.0.1"
+ v72 = "7.0.2-2.fc42"
+ v7rc1 = "7.0.1-rc1-378-ge76fd128c3"
+ v8 = "8.2.3"
+ v9 = "9.0.1-rc1"
+
+ assert is_version6(v6)
+ assert not is_version6(v7)
+
+ assert is_version7(v7)
+ assert is_version7(v72)
+ assert is_version7(v7rc1)
+ assert not is_version7(v8)
+
+ assert not is_version6(v8)
+ assert not is_version6(v9)
+ assert not is_version7(v8)
+ assert not is_version7(v9)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/corrections.py b/third_party/Bouni/kicad-jlcpcb-tools/corrections.py
new file mode 100644
index 00000000..9b3a53bb
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/corrections.py
@@ -0,0 +1,700 @@
+"""Contains the corrections manager."""
+
+import csv
+import logging
+import os
+
+import requests # pylint: disable=import-error
+import wx # pylint: disable=import-error
+import wx.dataview # pylint: disable=import-error
+
+from .events import PopulateFootprintListEvent
+from .helpers import PLUGIN_PATH, HighResWxSize, loadBitmapScaled
+
+
+class CorrectionManagerDialog(wx.Dialog):
+ """Dialog for managing part corrections."""
+
+ def __init__(self, parent, footprint):
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title="Corrections Manager",
+ pos=wx.DefaultPosition,
+ size=HighResWxSize(parent.window, wx.Size(800, 800)),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
+ )
+
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+ self.selection_regex = None
+ self.selection_rotation = None
+ self.selection_offset_x = None
+ self.selection_offset_y = None
+ self.import_legacy_corrections()
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+ # ---------------------------------------------------------------------
+ # ------------------------- Add/Edit inputs ---------------------------
+ # ---------------------------------------------------------------------
+
+ regex_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Regex",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.regex = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ footprint,
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ )
+
+ sizer_regex = wx.BoxSizer(wx.VERTICAL)
+ sizer_regex.Add(regex_label, 0, wx.ALL, 5)
+ sizer_regex.Add(
+ self.regex,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ rotation_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Rotation",
+ size=HighResWxSize(parent.window, wx.Size(100, 15)),
+ )
+ self.rotation = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "0",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, 24)),
+ )
+
+ sizer_rotation = wx.BoxSizer(wx.VERTICAL)
+ sizer_rotation.Add(rotation_label, 0, wx.ALL, 5)
+ sizer_rotation.Add(
+ self.rotation,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ offset_x_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Offset X",
+ size=HighResWxSize(parent.window, wx.Size(100, 15)),
+ )
+ self.offset_x = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "0.00",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, 24)),
+ )
+
+ sizer_offset_x = wx.BoxSizer(wx.VERTICAL)
+ sizer_offset_x.Add(offset_x_label, 0, wx.ALL, 5)
+ sizer_offset_x.Add(
+ self.offset_x,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ offset_y_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Offset Y",
+ size=HighResWxSize(parent.window, wx.Size(100, 15)),
+ )
+ self.offset_y = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "0.00",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, 24)),
+ )
+
+ sizer_offset_y = wx.BoxSizer(wx.VERTICAL)
+ sizer_offset_y.Add(offset_y_label, 0, wx.ALL, 5)
+ sizer_offset_y.Add(
+ self.offset_y,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ self.regex.Bind(wx.EVT_TEXT, self.on_textfield_change)
+ self.rotation.Bind(wx.EVT_TEXT, self.on_textfield_change)
+ self.offset_x.Bind(wx.EVT_TEXT, self.on_textfield_change)
+ self.offset_y.Bind(wx.EVT_TEXT, self.on_textfield_change)
+
+ add_edit_sizer = wx.StaticBoxSizer(wx.HORIZONTAL, self, "Add / Edit")
+ add_edit_sizer.Add(sizer_regex, 0, wx.RIGHT, 20)
+ add_edit_sizer.Add(sizer_rotation, 0, wx.RIGHT, 20)
+ add_edit_sizer.Add(sizer_offset_x, 0, wx.RIGHT, 20)
+ add_edit_sizer.Add(sizer_offset_y, 0, wx.RIGHT, 20)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Corrections list ---------------------------
+ # ---------------------------------------------------------------------
+
+ self.corrections_list = wx.dataview.DataViewListCtrl(
+ self,
+ wx.ID_ANY,
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ style=wx.dataview.DV_MULTIPLE,
+ )
+
+ self.corrections_list.AppendTextColumn(
+ "Regex",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 280),
+ align=wx.ALIGN_LEFT,
+ )
+ self.corrections_list.AppendTextColumn(
+ "Rotation",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 100),
+ align=wx.ALIGN_LEFT,
+ )
+ self.corrections_list.AppendTextColumn(
+ "Offset X",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 100),
+ align=wx.ALIGN_LEFT,
+ )
+ self.corrections_list.AppendTextColumn(
+ "Offset Y",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 100),
+ align=wx.ALIGN_LEFT,
+ )
+
+ self.corrections_list.SetMinSize(
+ HighResWxSize(parent.window, wx.Size(600, 500))
+ )
+
+ self.corrections_list.Bind(
+ wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.on_correction_selected
+ )
+
+ table_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400)))
+ table_sizer.Add(self.corrections_list, 20, wx.ALL | wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Right side toolbar -------------------------
+ # ---------------------------------------------------------------------
+
+ self.save_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Save",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.delete_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Delete",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.update_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Update",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.import_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Import",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.export_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Export",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+
+ self.save_button.Bind(wx.EVT_BUTTON, self.save_correction)
+ self.delete_button.Bind(wx.EVT_BUTTON, self.delete_correction)
+ self.update_button.Bind(wx.EVT_BUTTON, self.download_correction_data)
+ self.import_button.Bind(wx.EVT_BUTTON, self.import_corrections_dialog)
+ self.export_button.Bind(wx.EVT_BUTTON, self.export_corrections_dialog)
+
+ self.save_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-content-save-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.save_button.SetBitmapMargins((2, 0))
+
+ self.delete_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-trash-can-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.delete_button.SetBitmapMargins((2, 0))
+
+ self.update_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-cloud-download-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.update_button.SetBitmapMargins((2, 0))
+
+ self.import_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-database-import-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.import_button.SetBitmapMargins((2, 0))
+
+ self.export_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-database-export-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.export_button.SetBitmapMargins((2, 0))
+
+ self.global_corrections = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Use global corrections",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="corrections_global_corrections",
+ )
+
+ self.global_corrections.SetToolTip(
+ wx.ToolTip(
+ "Whether the global corrections database is used or a project local one"
+ )
+ )
+ self.global_corrections.Bind(
+ wx.EVT_CHECKBOX, self.on_global_corrections_changed
+ )
+ self.global_corrections.SetValue(
+ self.parent.library.uses_global_correction_database()
+ )
+
+ tool_sizer = wx.BoxSizer(wx.VERTICAL)
+ tool_sizer.Add(self.save_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.delete_button, 0, wx.ALL, 5)
+ tool_sizer.AddStretchSpacer()
+ tool_sizer.Add(self.update_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.import_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.export_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.global_corrections, 0, wx.ALL, 5)
+
+ table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------------ Sizers ------------------------------
+ # ---------------------------------------------------------------------
+
+ layout = wx.BoxSizer(wx.VERTICAL)
+ layout.Add(add_edit_sizer, 1, wx.ALL | wx.EXPAND, 5)
+ layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5)
+
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+ self.enable_toolbar_buttons()
+ self.populate_corrections_list()
+
+ def quit_dialog(self, *_):
+ """Close this dialog."""
+ self.Destroy()
+ self.EndModal(0)
+
+ def enable_toolbar_buttons(self):
+ """Control the state of all the buttons in toolbar on the right side."""
+ if (
+ self.regex.GetValue()
+ and self.rotation.GetValue()
+ and self.offset_x.GetValue()
+ and self.offset_y.GetValue()
+ ):
+ self.save_button.Enable(True)
+ else:
+ self.save_button.Enable(False)
+
+ if self.corrections_list.GetSelectedRow() != wx.NOT_FOUND:
+ self.delete_button.Enable(True)
+ else:
+ self.delete_button.Enable(False)
+
+ def to_float(self, value):
+ """Convert the given value to a float, return 0 if convertion fails."""
+ try:
+ return float(value)
+ except ValueError:
+ return 0
+
+ def str_from_float(self, value):
+ """Convert the given floating point value to a string.
+
+ Us as many decimal digits as required but for small numbers
+ at least two decimal digits are used.
+ """
+ s = str(value)
+ return f"{value:.2f}" if len(s) < 4 else s
+
+ def populate_corrections_list(self):
+ """Populate the list with the result of the search."""
+ self.corrections_list.DeleteAllItems()
+ for regex, rotation, offset in self.parent.library.get_all_correction_data():
+ self.corrections_list.AppendItem(
+ [
+ str(regex),
+ str(rotation),
+ self.str_from_float(offset[0]),
+ self.str_from_float(offset[1])
+ ]
+ )
+ selected_row = None
+ if self.selection_regex is not None:
+ for row in range(self.corrections_list.GetItemCount()):
+ row_regex = self.corrections_list.GetTextValue(row, 0)
+ if row_regex == self.selection_regex:
+ selected_row = row
+ if selected_row is not None:
+ self.corrections_list.SelectRow(selected_row)
+
+ def save_correction(self, *_):
+ """Add/Update a correction in the database."""
+ regex = self.regex.GetValue()
+ rotation = int(self.to_float(self.rotation.GetValue()))
+ offset_x = self.to_float(self.offset_x.GetValue())
+ offset_y = self.to_float(self.offset_y.GetValue())
+ offset = (offset_x, offset_y)
+ if regex == self.selection_regex:
+ # the regex of the selection was not changed, just update values.
+ self.parent.library.update_correction_data(regex, rotation, offset)
+ else:
+ # regex was modified or nothing was selected.
+ # Check if there is a existing rule for that regex
+ row_of_that_regex = None
+ for row in range(self.corrections_list.GetItemCount()):
+ row_regex = self.corrections_list.GetTextValue(row, 0)
+ if row_regex == regex:
+ row_of_that_regex = row
+
+ if row_of_that_regex is None:
+ # the regex is a new one, just create it or update the selected entry
+
+ if self.selection_regex is not None:
+ # remove old line, if one existed
+ self.parent.library.delete_correction_data(self.selection_regex)
+
+ # Add the modified regex and values
+ self.parent.library.insert_correction_data(regex, rotation, offset)
+ self.selection_regex = regex
+ else:
+ # The regex already exists.
+ existing_rotation = int(
+ self.to_float(self.corrections_list.GetTextValue(row, 1))
+ )
+ existing_offset_x = self.to_float(
+ self.corrections_list.GetTextValue(row, 2)
+ )
+ existing_offset_y = self.to_float(
+ self.corrections_list.GetTextValue(row, 3)
+ )
+
+ if (
+ rotation == existing_rotation
+ and offset_x == existing_offset_x
+ and offset_y == existing_offset_y
+ ):
+ # User entered a regex that already exists, just select that one
+ self.selection_regex = regex
+ else:
+ # The regex exists with different values, ask the user what to do.
+ existing_correction = "(" + \
+ str(existing_rotation) + "°, " + \
+ self.str_from_float(existing_offset_x) + "/" + \
+ self.str_from_float(existing_offset_y) + \
+ ")"
+ new_correction = "(" + \
+ str(rotation) + "°, " + \
+ self.str_from_float(offset_x) + "/" + \
+ self.str_from_float(offset_y) + \
+ ")"
+
+ dialog = wx.MessageDialog(
+ self,
+ f"A rule for '{regex}' already exists!",
+ "Regex exists!",
+ wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION,
+ )
+ if self.selection_regex is None:
+ # The user entered a regex that already exists with different values.
+ dialog.ExtendedMessage = "Do you want to update the corrections " + \
+ existing_correction + \
+ " to " + \
+ new_correction
+ else:
+ # The user has selected regex_a, changed it to regex_b
+ # (but regex_b exists).
+ dialog.ExtendedMessage = "Do you want to replace the corrections " + \
+ existing_correction + \
+ " with " + \
+ new_correction + \
+ ",\n" + \
+ f"removing the rule for '{self.selection_regex}'?"
+ result = dialog.ShowModal()
+
+ if result == wx.ID_YES:
+ if self.selection_regex is not None:
+ self.parent.library.delete_correction_data(self.selection_regex)
+ self.parent.library.update_correction_data(regex, rotation, offset)
+ self.selection_regex = regex
+
+ self.rotation.SetValue(str(rotation))
+ self.offset_x.SetValue(self.str_from_float(offset_x))
+ self.offset_y.SetValue(self.str_from_float(offset_y))
+ self.populate_corrections_list()
+ wx.PostEvent(self.parent, PopulateFootprintListEvent())
+
+ def delete_correction(self, *_):
+ """Delete a correction from the database."""
+ item = self.corrections_list.GetSelection()
+ row = self.corrections_list.ItemToRow(item)
+ if row == -1:
+ return
+ regex = self.corrections_list.GetTextValue(row, 0)
+ self.parent.library.delete_correction_data(regex)
+ self.populate_corrections_list()
+ wx.PostEvent(self.parent, PopulateFootprintListEvent())
+
+ def on_correction_selected(self, event):
+ """Enable the toolbar buttons when a selection was made."""
+ if len(self.corrections_list.GetSelections()) > 1:
+ for item in self.corrections_list.GetSelections():
+ if item != event.GetItem():
+ self.corrections_list.Unselect(item)
+
+ if self.corrections_list.GetSelectedItemsCount() > 0:
+ item = self.corrections_list.GetSelection()
+ row = self.corrections_list.ItemToRow(item)
+ if row == -1:
+ return
+
+ self.selection_regex = self.corrections_list.GetTextValue(row, 0)
+ self.selection_rotation = int(
+ self.to_float(self.corrections_list.GetTextValue(row, 1))
+ )
+ self.selection_offset_x = self.to_float(
+ self.corrections_list.GetTextValue(row, 2)
+ )
+ self.selection_offset_y = self.to_float(
+ self.corrections_list.GetTextValue(row, 3)
+ )
+ self.regex.SetValue(self.selection_regex)
+ self.rotation.SetValue(str(self.selection_rotation))
+ self.offset_x.SetValue(self.str_from_float(self.selection_offset_x))
+ self.offset_y.SetValue(self.str_from_float(self.selection_offset_y))
+ else:
+ self.selection_row = None
+ self.selection_regex = None
+
+ self.enable_toolbar_buttons()
+
+ def on_textfield_change(self, *_):
+ """Check if the texfield change affects toolbars."""
+ self.enable_toolbar_buttons()
+
+ def on_global_corrections_changed(self, use_global):
+ """Switch between global or local correction database file."""
+ if self.parent.library.uses_global_correction_database():
+ dialog = wx.MessageDialog(
+ self,
+ "Do you want to switch to the local corrections database?",
+ "Switching corrections database",
+ wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION,
+ )
+ dialog.ExtendedMessage = "Switching to a board local database copies the current global database."
+ else:
+ dialog = wx.MessageDialog(
+ self,
+ "Do you want to switch to the global corrections database?",
+ "Switching corrections database",
+ wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING,
+ )
+ result = dialog.ShowModal()
+
+ if result == wx.ID_NO:
+ self.global_corrections.SetValue(
+ self.parent.library.uses_global_correction_database()
+ )
+ return
+
+ self.parent.library.switch_to_global_correction_database(
+ not self.parent.library.uses_global_correction_database()
+ )
+ self.populate_corrections_list()
+ wx.PostEvent(self.parent, PopulateFootprintListEvent())
+ self.global_corrections.SetValue(
+ self.parent.library.uses_global_correction_database()
+ )
+
+ def download_correction_data(self, *_):
+ """Fetch the latest rotation correction table from Matthew Lai's JLCKicadTool repo."""
+ self.parent.library.create_correction_table()
+ try:
+ r = requests.get(
+ "https://raw.githubusercontent.com/matthewlai/JLCKicadTools/master/jlc_kicad_tools/cpl_rotations_db.csv",
+ timeout=5,
+ )
+ corrections = csv.reader(r.text.splitlines(), delimiter=",", quotechar='"')
+ next(corrections)
+ for row in corrections:
+ if not self.parent.library.get_correction_data(row[0]):
+ if len(row) >= 4:
+ self.parent.library.insert_correction_data(
+ row[0], row[1], (row[2], row[3])
+ )
+ else:
+ self.parent.library.insert_correction_data(
+ row[0], row[1], (0, 0)
+ )
+ else:
+ self.logger.info(
+ "Correction '%s' exists already in database. Leaving this one out.",
+ row[0],
+ )
+ except Exception as err: # pylint: disable=broad-exception-caught
+ self.logger.debug(err)
+ self.populate_corrections_list()
+ wx.PostEvent(self.parent, PopulateFootprintListEvent())
+
+ def import_legacy_corrections(self):
+ """Check if corrections in CSV format are found and import them into the database."""
+ csv_file = os.path.join(PLUGIN_PATH, "corrections", "cpl_rotations_db.csv")
+ if os.path.isfile(csv_file):
+ self._import_corrections(csv_file)
+ os.rename(csv_file, f"{csv_file}.backup")
+
+ def import_corrections_dialog(self, *_):
+ """Dialog to import correctios from a CSV file."""
+ with wx.FileDialog(
+ self,
+ "Import",
+ "",
+ "",
+ "CSV files (*.csv)|*.csv",
+ wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
+ ) as importFileDialog:
+ if importFileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = importFileDialog.GetPath()
+ self._import_corrections(path)
+
+ def export_corrections_dialog(self, *_):
+ """Dialog to export correctios to a CSV file."""
+ with wx.FileDialog(
+ self,
+ "Export",
+ "",
+ "",
+ "CSV files (*.csv)|*.csv",
+ wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
+ ) as exportFileDialog:
+ if exportFileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = exportFileDialog.GetPath()
+ self._export_corrections(path)
+
+ def _import_corrections(self, path):
+ """Corrections import logic."""
+ if os.path.isfile(path):
+ with open(path, encoding="utf-8") as f:
+ csvreader = csv.DictReader(
+ f, fieldnames=("regex", "rotation", "offset_x", "offset_y")
+ )
+ next(csvreader)
+ for row in csvreader:
+ if "regex" in row and row["regex"] is not None:
+ regex = row["regex"]
+ rotation = row["rotation"] if row["rotation"] is not None else 0
+ offset_x = row["offset_x"] if row["offset_x"] is not None else 0
+ offset_y = row["offset_y"] if row["offset_y"] is not None else 0
+ existing_data = self.parent.library.get_correction_data(regex)
+ if existing_data:
+ self.parent.library.update_correction_data(
+ regex, rotation, (offset_x, offset_y)
+ )
+ self.logger.info(
+ "Correction '%s' exists already in database with correction value '%s, %s/%s'. Overwrite it with local values from CSV (%s, %s/%s).",
+ regex,
+ existing_data[1],
+ existing_data[2],
+ existing_data[3],
+ rotation,
+ offset_x,
+ offset_y,
+ )
+ else:
+ self.parent.library.insert_correction_data(
+ regex, rotation, (offset_x, offset_y)
+ )
+ self.logger.info(
+ "Correction '%s' with correction value '%s, %s/%s' is added to the database from local CSV.",
+ regex,
+ rotation,
+ offset_x,
+ offset_y,
+ )
+ self.populate_corrections_list()
+ wx.PostEvent(self.parent, PopulateFootprintListEvent())
+
+ def _export_corrections(self, path):
+ """Corrections export logic."""
+ with open(path, "w", newline="", encoding="utf-8") as f:
+ csvwriter = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL)
+ csvwriter.writerow(["Pattern", "Rotation", "Offset X", "Offset Y"])
+ for (
+ regex,
+ rotation,
+ offset,
+ ) in self.parent.library.get_all_correction_data():
+ csvwriter.writerow([regex, rotation, offset[0], offset[1]])
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py b/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py
new file mode 100644
index 00000000..2cf7f744
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/datamodel.py
@@ -0,0 +1,347 @@
+"""Implementation of the Datamodel for the parts list with natural sort."""
+
+import logging
+import re
+
+import wx.dataview as dv
+
+from .helpers import loadIconScaled
+from .partselector_columns import COLUMN_INDEX, MODEL_COLUMN_TYPES
+
+
+class PartListDataModel(dv.PyDataViewModel):
+ """Datamodel for use with the DataViewCtrl of the mainwindow."""
+
+ def __init__(self, scale_factor):
+ super().__init__()
+ self.data = []
+ self.columns = {
+ "REF_COL": 0,
+ "VALUE_COL": 1,
+ "FP_COL": 2,
+ "LCSC_COL": 3,
+ "TYPE_COL": 4,
+ "STOCK_COL": 5,
+ "BOM_COL": 6,
+ "POS_COL": 7,
+ "DNP_COL": 8,
+ "ROT_COL": 9,
+ "SIDE_COL": 10,
+ "PARAMS_COL": 11,
+ }
+
+ self.bom_pos_icons = [
+ loadIconScaled(
+ "mdi-check-color.png",
+ scale_factor,
+ ),
+ loadIconScaled(
+ "mdi-close-color.png",
+ scale_factor,
+ ),
+ ]
+ self.side_icons = [
+ loadIconScaled(
+ "TOP.png",
+ scale_factor,
+ ),
+ loadIconScaled(
+ "BOT.png",
+ scale_factor,
+ ),
+ ]
+ self.logger = logging.getLogger(__name__)
+
+ @staticmethod
+ def natural_sort_key(s):
+ """Return a tuple that can be used for natural sorting."""
+ return [
+ int(text) if text.isdigit() else text.lower()
+ for text in re.split("([0-9]+)", s)
+ ]
+
+ def GetColumnCount(self):
+ """Get number of columns."""
+ return len(self.columns)
+
+ def GetColumnType(self, col):
+ """Get type of each column."""
+ columntypes = (
+ "string",
+ "string",
+ "string",
+ "string",
+ "string",
+ "string",
+ "wxDataViewIconText",
+ "wxDataViewIconText",
+ "wxDataViewIconText",
+ "string",
+ "wxDataViewIconText",
+ "string",
+ )
+ return columntypes[col]
+
+ def GetChildren(self, parent, children):
+ """Get child items of a parent."""
+ if not parent:
+ for row in self.data:
+ children.append(self.ObjectToItem(row))
+ return len(self.data)
+ return 0
+
+ def IsContainer(self, item):
+ """Check if tem is a container."""
+ return not item
+
+ def GetParent(self, item):
+ """Get parent item."""
+ return dv.NullDataViewItem
+
+ def GetValue(self, item, col):
+ """Get value of an item."""
+ row = self.ItemToObject(item)
+ if col in [
+ self.columns["BOM_COL"],
+ self.columns["POS_COL"],
+ self.columns["DNP_COL"],
+ self.columns["SIDE_COL"],
+ ]:
+ icon = row[col]
+ return dv.DataViewIconText("", icon)
+ return row[col]
+
+ def SetValue(self, value, item, col):
+ """Set value of an item."""
+ row = self.ItemToObject(item)
+ if col in [
+ self.columns["BOM_COL"],
+ self.columns["POS_COL"],
+ self.columns["DNP_COL"],
+ self.columns["SIDE_COL"],
+ ]:
+ return False
+ row[col] = value
+ return True
+
+ def Compare(self, item1, item2, column, ascending):
+ """Override to implement natural sorting."""
+ val1 = self.GetValue(item1, column)
+ val2 = self.GetValue(item2, column)
+
+ key1 = self.natural_sort_key(val1)
+ key2 = self.natural_sort_key(val2)
+
+ if ascending:
+ return (key1 > key2) - (key1 < key2)
+ else:
+ return (key2 > key1) - (key2 < key1)
+
+ def find_index(self, ref):
+ """Get the index of a part within the data list by its reference."""
+ try:
+ return self.data.index([x for x in self.data if x[0] == ref].pop())
+ except (ValueError, IndexError):
+ return None
+
+ def get_bom_pos_icon(self, state: str):
+ """Get an icon for a state."""
+ return self.bom_pos_icons[int(state)]
+
+ def get_side_icon(self, side: str):
+ """Get The side for a layer number."""
+ return self.side_icons[0] if side == "0" else self.side_icons[1]
+
+ def AddEntry(self, data: list):
+ """Add a new entry to the data model."""
+ data[self.columns["BOM_COL"]] = self.get_bom_pos_icon(
+ data[self.columns["BOM_COL"]]
+ )
+ data[self.columns["POS_COL"]] = self.get_bom_pos_icon(
+ data[self.columns["POS_COL"]]
+ )
+ data[self.columns["DNP_COL"]] = self.get_bom_pos_icon(
+ data[self.columns["DNP_COL"]]
+ )
+ data[self.columns["SIDE_COL"]] = self.get_side_icon(
+ data[self.columns["SIDE_COL"]]
+ )
+ self.data.append(data)
+ self.ItemAdded(dv.NullDataViewItem, self.ObjectToItem(data))
+
+ def RemoveAll(self):
+ """Remove all entries from the data model."""
+ self.data.clear()
+ self.Cleared()
+
+ def get_all(self):
+ """Get tall items."""
+ return self.data
+
+ def get_reference(self, item):
+ """Get the reference of an item."""
+ return self.ItemToObject(item)[self.columns["REF_COL"]]
+
+ def get_value(self, item):
+ """Get the value of an item."""
+ return self.ItemToObject(item)[self.columns["VALUE_COL"]]
+
+ def get_lcsc(self, item):
+ """Get the lcsc of an item."""
+ return self.ItemToObject(item)[self.columns["LCSC_COL"]]
+
+ def get_footprint(self, item):
+ """Get the footprint of an item."""
+ return self.ItemToObject(item)[self.columns["FP_COL"]]
+
+ def select_alike(self, item):
+ """Select all items that have the same value and footprint."""
+ obj = self.ItemToObject(item)
+ alike = []
+ for data in self.data:
+ if data[1:3] == obj[1:3]:
+ alike.append(self.ObjectToItem(data))
+ return alike
+
+ def set_lcsc(self, ref, lcsc, type, stock, params):
+ """Set an lcsc number, type and stock for given reference."""
+ if (index := self.find_index(ref)) is None:
+ return
+ item = self.data[index]
+ item[self.columns["LCSC_COL"]] = lcsc
+ item[self.columns["TYPE_COL"]] = type
+ item[self.columns["STOCK_COL"]] = stock
+ item[self.columns["PARAMS_COL"]] = params
+ self.ItemChanged(self.ObjectToItem(item))
+
+ def remove_lcsc_number(self, item):
+ """Remove the LCSC number of an item."""
+ obj = self.ItemToObject(item)
+ obj[self.columns["LCSC_COL"]] = ""
+ obj[self.columns["TYPE_COL"]] = ""
+ obj[self.columns["STOCK_COL"]] = ""
+ obj[self.columns["PARAMS_COL"]] = ""
+ self.ItemChanged(self.ObjectToItem(obj))
+
+ def toggle_bom(self, item):
+ """Toggle BOM for a given item."""
+ obj = self.ItemToObject(item)
+ if obj[self.columns["BOM_COL"]] == self.bom_pos_icons[0]:
+ obj[self.columns["BOM_COL"]] = self.bom_pos_icons[1]
+ else:
+ obj[self.columns["BOM_COL"]] = self.bom_pos_icons[0]
+ self.ItemChanged(self.ObjectToItem(obj))
+
+ def toggle_pos(self, item):
+ """Toggle POS for a given item."""
+ obj = self.ItemToObject(item)
+ if obj[self.columns["POS_COL"]] == self.bom_pos_icons[0]:
+ obj[self.columns["POS_COL"]] = self.bom_pos_icons[1]
+ else:
+ obj[self.columns["POS_COL"]] = self.bom_pos_icons[0]
+ self.ItemChanged(self.ObjectToItem(obj))
+
+ def toggle_bom_pos(self, item):
+ """Toggle BOM and POS for a given item."""
+ self.toggle_bom(item)
+ self.toggle_pos(item)
+
+
+class PartSelectorDataModel(dv.PyDataViewModel):
+ """Datamodel for use with the DataViewCtrl of the partselector modal window."""
+
+ def __init__(self):
+ super().__init__()
+ self.data = []
+ self.columns = dict(COLUMN_INDEX)
+
+ self.logger = logging.getLogger(__name__)
+
+ @staticmethod
+ def natural_sort_key(s):
+ """Return a tuple that can be used for natural sorting."""
+ return [
+ int(text) if text.isdigit() else text.lower()
+ for text in re.split("([0-9]+)", s)
+ ]
+
+ def GetColumnCount(self):
+ """Get number of columns."""
+ return len(self.columns)
+
+ def GetColumnType(self, col):
+ """Get type of each column."""
+ return MODEL_COLUMN_TYPES[col]
+
+ def GetChildren(self, parent, children):
+ """Get child items of a parent."""
+ if not parent:
+ for row in self.data:
+ children.append(self.ObjectToItem(row))
+ return len(self.data)
+ return 0
+
+ def IsContainer(self, item):
+ """Check if tem is a container."""
+ return not item
+
+ def GetParent(self, item):
+ """Get parent item."""
+ return dv.NullDataViewItem
+
+ def GetValue(self, item, col):
+ """Get value of an item."""
+ row = self.ItemToObject(item)
+ return row[col]
+
+ def SetValue(self, value, item, col):
+ """Set value of an item."""
+ row = self.ItemToObject(item)
+ row[col] = value
+ return True
+
+ def Compare(self, item1, item2, column, ascending):
+ """Override to implement natural sorting."""
+ val1 = self.GetValue(item1, column)
+ val2 = self.GetValue(item2, column)
+
+ key1 = self.natural_sort_key(val1)
+ key2 = self.natural_sort_key(val2)
+
+ if ascending:
+ return (key1 > key2) - (key1 < key2)
+ else:
+ return (key2 > key1) - (key2 < key1)
+
+ def find_index(self, ref):
+ """Get the index of a part within the data list by its reference."""
+ try:
+ return self.data.index([x for x in self.data if x[0] == ref].pop())
+ except (ValueError, IndexError):
+ return None
+
+ def AddEntry(self, data: list):
+ """Add a new entry to the data model."""
+ self.data.append(data)
+ self.ItemAdded(dv.NullDataViewItem, self.ObjectToItem(data))
+
+ def RemoveAll(self):
+ """Remove all entries from the data model."""
+ self.data.clear()
+ self.Cleared()
+
+ def get_all(self):
+ """Get tall items."""
+ return self.data
+
+ def get_lcsc(self, item):
+ """Get the reference of an item."""
+ return self.ItemToObject(item)[self.columns["lcsc"]]
+
+ def get_type(self, item):
+ """Get the reference of an item."""
+ return self.ItemToObject(item)[self.columns["type"]]
+
+ def get_stock(self, item):
+ """Get the reference of an item."""
+ return self.ItemToObject(item)[self.columns["stock"]]
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md b/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md
new file mode 100644
index 00000000..55745589
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/README.md
@@ -0,0 +1,80 @@
+# Database Building and Conversion
+
+The code in this directory handles building the sqlite database files
+that are downloaded by the user.
+
+## Main Concepts
+
+### The Components Database
+
+The components database is compatible with the 'cache.sqlite3' database that was
+originally released as part of the yaqswx/jlcparts distribution. It contains
+information about every component JLC has ever stocked. The components database
+is effectively a local cache of all of the data available in the public JLC API.
+
+### The Parts Database(s)
+
+The Parts database is the database that is consumed by the UI in this package.
+It is built from the Components database, but does some additional data
+normalization, price compression, and uses full text search indexing to make the
+component search fast in the UI. There are multiple parts databases built,
+which can be selectable in the UI:
+
+#### Recently Stocked (default)
+
+This filters the Parts database based on parts which have been seen in-stock at
+some point in the last year. Removing components that haven't been in stock for
+more than one year reduces the database size by over 90%.
+
+#### Basic and Preferred Only
+
+This filters the Parts database to the ~1500 components that are either 'Basic'
+or 'Preferred'. Useful for high speed in very simple or price-concious builds.
+
+#### Empty
+
+This is an empty Parts database. This is useful for users who only use the plugin
+for PCB generation and do not create BOMs, or who have manually added LCSC numbers
+to all of their components.
+
+#### All Components (old default)
+
+This is the unfiltered Parts database - all of the components which have ever been
+seen at JLC are searchable.
+
+This is much larger, and has worse performance, but may be useful for some users
+who have old parts in their BOM and don't want to lose them from the search index
+despite not being in-stock -- It is possible at JLC to pre-order parts which are
+not otherwise in stock.
+
+### The JLC API
+
+JLC provides a public API for searching components - this is ultimately the source
+of truth for building the cached Components database.
+
+## The build process
+
+The database build process is approximately:
+
+1. Download and reassemble the components DB artifact.
+
+1. Scrape the JLC API to update the components DB.
+
+1. Set stock=0 on any items which have not been seen in the API scrape for more
+ than 1 week.
+
+1. For any item that has been out of stock for more than 1 year, remove the
+ 'extra' and 'price' information from the Components DB and compact it. This
+ reduces the size of the database by > 2/3. The 'extra' information is not
+ used in the Parts database, and the price information for something out of
+ stock for > 1 year will be irrelevant.
+
+1. Using the updated components database, create each of the fitlered parts
+ databases.
+
+1. Archive and split each of the parts databases into a separate directory.
+
+1. Archive and split the components database. This allows the next run of the
+ download script to have an updated copy of the components database.
+
+1. From the github workflow, upload each of the artifact directories.
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py
new file mode 100644
index 00000000..98402222
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/__init__.py
@@ -0,0 +1 @@
+"""Database building utilities for JLCPCB parts database."""
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py b/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py
new file mode 100644
index 00000000..4523657a
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/db_build/jlcparts_db_convert.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+
+"""Use the amazing work of https://github.com/yaqwsx/jlcparts and convert their database into something we can conveniently use for this plugin.
+
+This replaces the old .csv based database creation that JLCPCB no longer supports.
+"""
+
+import os
+from pathlib import Path
+import sys
+
+# Add parent directory to path so we can import common module
+# TODO(z2amiller): Use proper packaging
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import click # noqa: E402
+
+from common.componentdb import ComponentsDatabase # noqa: E402
+from common.filemgr import FileManager # noqa: E402
+from common.jlcapi import CategoryFetch, Component, JlcApi # noqa: E402
+from common.partsdb import Generate, PartsDatabase # noqa: E402
+from common.progress import PrintNestedProgressBar, TqdmNestedProgressBar # noqa: E402
+from dblib import DatabaseConfig # noqa: E402
+
+
+def update_components_db_from_api() -> None:
+ """Update the component cache database."""
+ db = ComponentsDatabase("db_working/cache.sqlite3")
+ print("Fetching categories...")
+ initial_categories = JlcApi.fetchCategories(instockOnly=True)
+ categories = JlcApi.collapseCategories(initial_categories, limit=50000)
+ print(f"Found {len(initial_categories)} categories, collaped to {len(categories)}.")
+
+ progress = (
+ TqdmNestedProgressBar()
+ if sys.stdout.isatty()
+ else PrintNestedProgressBar(outer_threshold=1, inner_threshold=2000)
+ )
+
+ with progress.outer(len(categories), "Fetching categories") as outer_pbar:
+ for category in categories:
+ fetcher = CategoryFetch(category)
+
+ with progress.inner(category.count, f"{category}") as inner_pbar:
+ for components in fetcher.fetchAll():
+ comp_objs = [Component(comp) for comp in components]
+ db.update_cache(comp_objs)
+ inner_pbar.update(len(components))
+
+ outer_pbar.update()
+
+ db.cleanup_stock()
+ db.close()
+
+
+@click.command()
+@click.option(
+ "--skip-cleanup",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Disable cleanup, intermediate database files will not be deleted",
+)
+@click.option(
+ "--components-db-base-url",
+ default="http://bouni.github.io/jlcparts/data",
+ show_default=True,
+ help="Base URL to fetch the components database from",
+)
+@click.option(
+ "--fetch-components-db",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Fetch the components db from the remote server",
+)
+@click.option(
+ "--fix-components-db-descriptions",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Fix descriptions in the components db by pulling from the 'extra' field",
+)
+@click.option(
+ "--update-components-db",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Update the local components db using LCSC API data",
+)
+@click.option(
+ "--clean-components-db",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Clean the local components db by removing old and out-of-stock parts",
+)
+@click.option(
+ "--archive-components-db",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Archive the components db after updating from the API",
+)
+@click.option(
+ "--skip-generate",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Skip the DB generation phase",
+)
+@click.option(
+ "--obsolete-parts-threshold-days",
+ show_default=True,
+ default=365,
+ type=int,
+ help="""
+ Setting this to > 0 will generate an additional dataabase that ignores parts
+ that have been out of stock for more than the specified number of days.
+ """,
+)
+@click.option(
+ "--populate-preferred-extended",
+ is_flag=True,
+ show_default=True,
+ default=False,
+ help="Populate 'Preferred' parts as 'Preferred' library type instead of 'Extended'",
+)
+def main(
+ skip_cleanup: bool,
+ fetch_components_db: bool,
+ components_db_base_url: str,
+ fix_components_db_descriptions: bool,
+ update_components_db: bool,
+ clean_components_db: bool,
+ archive_components_db: bool,
+ skip_generate: bool,
+ obsolete_parts_threshold_days: int,
+ populate_preferred_extended: bool,
+):
+ """Perform the database steps."""
+
+ working_directory = "db_working"
+ components_db = f"{working_directory}/cache.sqlite3"
+
+ if fetch_components_db:
+ print("Fetching components database...")
+ fm = FileManager(
+ file_path=Path(components_db),
+ sentinel_filename="cache_chunk_num.txt",
+ )
+ fm.download_and_reassemble(
+ url=components_db_base_url,
+ output_dir=Path(working_directory),
+ cleanup=not skip_cleanup,
+ )
+
+ if not os.path.exists(working_directory):
+ os.mkdir(working_directory)
+
+ if fix_components_db_descriptions:
+ print("Fixing components database descriptions")
+ db = ComponentsDatabase(components_db)
+ db.fix_description()
+ db.close()
+
+ if update_components_db:
+ update_components_db_from_api()
+
+ if clean_components_db:
+ print("Cleaning components database")
+ db = ComponentsDatabase(components_db)
+ db.truncate_old()
+ db.close()
+
+ configs = [
+ DatabaseConfig.preferredAndBasic(),
+ DatabaseConfig.allParts(),
+ DatabaseConfig.emptyParts(),
+ ]
+ if obsolete_parts_threshold_days > 0:
+ configs.insert(
+ 0, DatabaseConfig.ignoreObsoleteParts(obsolete_parts_threshold_days)
+ )
+
+ archive_dir = Path("archive")
+ if not archive_dir.exists():
+ os.makedirs(archive_dir)
+ for config in configs:
+ if not skip_generate:
+ print(f"Generating {config.name}...")
+ componentdb = ComponentsDatabase(components_db)
+ partsdb = PartsDatabase(
+ output_db=Path(working_directory) / config.name,
+ archive_dir=Path(archive_dir),
+ chunk_num=Path(config.chunk_file_name),
+ skip_cleanup=skip_cleanup,
+ )
+ progress = (
+ TqdmNestedProgressBar()
+ if sys.stdout.isatty()
+ else PrintNestedProgressBar()
+ )
+ generator = Generate(
+ componentdb=componentdb,
+ partsdb=partsdb,
+ progress=progress,
+ populate_preferred=config.populate_preferred,
+ )
+ generator.generate(where_clause=config.where_clause)
+
+ if archive_components_db:
+ fm = FileManager(
+ file_path=Path(components_db),
+ chunk_size=50 * 1024 * 1024, # 50 MB
+ sentinel_filename="cache_chunk_num.txt",
+ )
+ fm.compress_and_split(
+ output_dir=Path(archive_dir), delete_original=skip_cleanup
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/db_build/pytest.ini b/third_party/Bouni/kicad-jlcpcb-tools/db_build/pytest.ini
new file mode 100644
index 00000000..e69de29b
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py b/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py
new file mode 100644
index 00000000..9948dc5b
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/dblib/__init__.py
@@ -0,0 +1,89 @@
+"""Database configuration library for parts database generation.
+
+This module provides database configuration classes and constants for managing
+different parts database variants without pulling in heavy dependencies.
+"""
+
+import time
+from typing import NamedTuple
+
+
+class PartDatabaseConfig(NamedTuple):
+ """Configuration for part database generation."""
+
+ name: str
+ chunk_file_name: str
+ where_clause: str
+ display_name: str
+ populate_preferred: bool = False
+
+
+class DatabaseConfig:
+ """Predefined database configurations."""
+
+ @staticmethod
+ def preferredAndBasic() -> PartDatabaseConfig:
+ """Select only preferred and basic parts."""
+ return PartDatabaseConfig(
+ name="basic-parts-fts5.db",
+ chunk_file_name="chunk_num_basic_parts_fts5.txt",
+ where_clause="basic = 1 OR preferred = 1",
+ display_name="Basic + Preferred Library",
+ populate_preferred=True,
+ )
+
+ @staticmethod
+ def allParts() -> PartDatabaseConfig:
+ """Select all parts.
+
+ This is the most backwards-compatible database, and therefore uses
+ the default "parts-fts5.db" name.
+ """
+ return PartDatabaseConfig(
+ name="parts-fts5.db",
+ chunk_file_name="chunk_num_fts5.txt",
+ where_clause="TRUE",
+ display_name="Full Library - All Parts",
+ populate_preferred=False,
+ )
+
+ @staticmethod
+ def ignoreObsoleteParts(obsolete_threshold_days: int = 365) -> PartDatabaseConfig:
+ """Select all parts except obsolete parts."""
+ filter_seconds = int(time.time()) - obsolete_threshold_days * 24 * 60 * 60
+ return PartDatabaseConfig(
+ name="current-parts-fts5.db",
+ chunk_file_name="chunk_num_current_parts_fts5.txt",
+ where_clause=f"NOT (stock = 0 AND last_on_stock < {filter_seconds})",
+ display_name="Current Parts (Exclude Obsolete)",
+ populate_preferred=True,
+ )
+
+ @staticmethod
+ def emptyParts() -> PartDatabaseConfig:
+ """Select no parts."""
+ return PartDatabaseConfig(
+ name="empty-parts-fts5.db",
+ chunk_file_name="chunk_num_empty_parts_fts5.txt",
+ where_clause="FALSE",
+ display_name="Empty Library - No parts!",
+ )
+
+
+# Library configuration mapping: defines available library options
+LIBRARY_CONFIGS = {
+ "all-parts": DatabaseConfig.allParts(),
+ "basic-preferred": DatabaseConfig.preferredAndBasic(),
+ "current-parts": DatabaseConfig.ignoreObsoleteParts(),
+ "empty": DatabaseConfig.emptyParts(),
+}
+
+DEFAULT_LIBRARY = "current-parts"
+
+
+__all__ = [
+ "DatabaseConfig",
+ "DEFAULT_LIBRARY",
+ "LIBRARY_CONFIGS",
+ "PartDatabaseConfig",
+]
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py b/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py
new file mode 100644
index 00000000..42ec2402
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/dblib/test_dblibrary.py
@@ -0,0 +1,234 @@
+"""Tests for the dblibrary module."""
+
+from pathlib import Path
+import re
+import sys
+import time
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from dblib import DEFAULT_LIBRARY, LIBRARY_CONFIGS, DatabaseConfig, PartDatabaseConfig
+
+# ============================================================================
+# PartDatabaseConfig Tests
+# ============================================================================
+
+
+class TestPartDatabaseConfig:
+ """Tests for PartDatabaseConfig class."""
+
+ def test_part_database_config_creation(self):
+ """PartDatabaseConfig can be created with all fields."""
+ config = PartDatabaseConfig(
+ name="test.db",
+ chunk_file_name="chunk_test.txt",
+ where_clause="stock > 0",
+ display_name="Test Database",
+ populate_preferred=True,
+ )
+ assert config.name == "test.db"
+ assert config.chunk_file_name == "chunk_test.txt"
+ assert config.where_clause == "stock > 0"
+ assert config.display_name == "Test Database"
+ assert config.populate_preferred is True
+
+ def test_part_database_config_default_populate_preferred(self):
+ """PartDatabaseConfig defaults populate_preferred to False."""
+ config = PartDatabaseConfig(
+ name="test.db",
+ chunk_file_name="chunk_test.txt",
+ where_clause="TRUE",
+ display_name="Test Database",
+ )
+ assert config.populate_preferred is False
+
+ def test_part_database_config_is_immutable(self):
+ """PartDatabaseConfig is immutable (NamedTuple)."""
+ config = PartDatabaseConfig(
+ name="test.db",
+ chunk_file_name="chunk_test.txt",
+ where_clause="TRUE",
+ display_name="Test Database",
+ )
+ try:
+ config.name = "modified.db" # type: ignore
+ assert False, "Should not be able to modify NamedTuple"
+ except AttributeError:
+ pass # Expected
+
+
+# ============================================================================
+# DatabaseConfig Tests
+# ============================================================================
+
+
+class TestDatabaseConfig:
+ """Tests for DatabaseConfig class."""
+
+ def test_preferred_and_basic_config(self):
+ """PreferredAndBasic returns correct configuration."""
+ config = DatabaseConfig.preferredAndBasic()
+
+ assert config.name == "basic-parts-fts5.db"
+ assert config.chunk_file_name == "chunk_num_basic_parts_fts5.txt"
+ assert config.where_clause == "basic = 1 OR preferred = 1"
+ assert config.display_name == "Basic + Preferred Library"
+ assert config.populate_preferred is True
+
+ def test_all_parts_config(self):
+ """AllParts returns correct configuration."""
+ config = DatabaseConfig.allParts()
+
+ assert config.name == "parts-fts5.db"
+ assert config.chunk_file_name == "chunk_num_fts5.txt"
+ assert config.where_clause == "TRUE"
+ assert config.display_name == "Full Library - All Parts"
+ assert config.populate_preferred is False
+
+ def test_ignore_obsolete_parts_default(self):
+ """IgnoreObsoleteParts returns correct configuration with default threshold."""
+ before_time = int(time.time()) - 365 * 24 * 60 * 60
+ config = DatabaseConfig.ignoreObsoleteParts()
+ after_time = int(time.time()) - 365 * 24 * 60 * 60
+
+ assert config.name == "current-parts-fts5.db"
+ assert config.chunk_file_name == "chunk_num_current_parts_fts5.txt"
+ assert config.display_name == "Current Parts (Exclude Obsolete)"
+ assert config.populate_preferred is True
+
+ # Check that the where clause contains a timestamp within reasonable bounds
+ assert "NOT (stock = 0 AND last_on_stock <" in config.where_clause
+
+ # Extract the timestamp from the where clause
+
+ match = re.search(r"last_on_stock < (\d+)", config.where_clause)
+ assert match is not None
+ timestamp = int(match.group(1))
+
+ # Allow 2 seconds of margin for test execution time
+ assert before_time - 2 <= timestamp <= after_time + 2
+
+ def test_ignore_obsolete_parts_custom_threshold(self):
+ """IgnoreObsoleteParts accepts custom threshold."""
+ days = 180
+ before_time = int(time.time()) - days * 24 * 60 * 60
+ config = DatabaseConfig.ignoreObsoleteParts(obsolete_threshold_days=days)
+ after_time = int(time.time()) - days * 24 * 60 * 60
+
+ assert config.name == "current-parts-fts5.db"
+ # Verify the timestamp is approximately correct (within a small margin)
+ assert "NOT (stock = 0 AND last_on_stock <" in config.where_clause
+
+ match = re.search(r"last_on_stock < (\d+)", config.where_clause)
+ assert match is not None
+ timestamp = int(match.group(1))
+
+ # Allow 2 seconds of margin for test execution time
+ assert before_time - 2 <= timestamp <= after_time + 2
+
+ def test_empty_parts_config(self):
+ """EmptyParts returns correct configuration."""
+ config = DatabaseConfig.emptyParts()
+
+ assert config.name == "empty-parts-fts5.db"
+ assert config.chunk_file_name == "chunk_num_empty_parts_fts5.txt"
+ assert config.where_clause == "FALSE"
+ assert config.display_name == "Empty Library - No parts!"
+ assert config.populate_preferred is False
+
+ def test_all_configs_return_part_database_config(self):
+ """All DatabaseConfig methods return PartDatabaseConfig instances."""
+ configs = [
+ DatabaseConfig.preferredAndBasic(),
+ DatabaseConfig.allParts(),
+ DatabaseConfig.ignoreObsoleteParts(),
+ DatabaseConfig.emptyParts(),
+ ]
+
+ for config in configs:
+ assert isinstance(config, PartDatabaseConfig)
+
+ def test_configs_have_unique_names(self):
+ """All default configurations have unique database names."""
+ configs = [
+ DatabaseConfig.preferredAndBasic(),
+ DatabaseConfig.allParts(),
+ DatabaseConfig.ignoreObsoleteParts(),
+ DatabaseConfig.emptyParts(),
+ ]
+
+ names = [config.name for config in configs]
+ assert len(names) == len(set(names)), "Database names should be unique"
+
+ def test_configs_have_unique_chunk_files(self):
+ """All default configurations have unique chunk file names."""
+ configs = [
+ DatabaseConfig.preferredAndBasic(),
+ DatabaseConfig.allParts(),
+ DatabaseConfig.ignoreObsoleteParts(),
+ DatabaseConfig.emptyParts(),
+ ]
+
+ chunk_files = [config.chunk_file_name for config in configs]
+ assert len(chunk_files) == len(set(chunk_files)), (
+ "Chunk file names should be unique"
+ )
+
+
+# ============================================================================
+# LIBRARY_CONFIGS and DEFAULT_LIBRARY Tests
+# ============================================================================
+
+
+class TestLibraryConfigs:
+ """Tests for LIBRARY_CONFIGS and DEFAULT_LIBRARY constants."""
+
+ def test_library_configs_exists(self):
+ """LIBRARY_CONFIGS is defined and is a dict."""
+ assert LIBRARY_CONFIGS is not None
+ assert isinstance(LIBRARY_CONFIGS, dict)
+
+ def test_default_library_exists(self):
+ """DEFAULT_LIBRARY is defined and is a string."""
+ assert DEFAULT_LIBRARY is not None
+ assert isinstance(DEFAULT_LIBRARY, str)
+
+ def test_default_library_in_configs(self):
+ """DEFAULT_LIBRARY key exists in LIBRARY_CONFIGS."""
+ assert DEFAULT_LIBRARY in LIBRARY_CONFIGS
+
+ def test_library_configs_has_expected_keys(self):
+ """LIBRARY_CONFIGS has all expected configuration keys."""
+ expected_keys = {"all-parts", "basic-preferred", "current-parts", "empty"}
+ assert set(LIBRARY_CONFIGS.keys()) == expected_keys
+
+ def test_library_configs_values_are_part_database_configs(self):
+ """All LIBRARY_CONFIGS values are PartDatabaseConfig instances."""
+ for key, config in LIBRARY_CONFIGS.items():
+ assert isinstance(config, PartDatabaseConfig), (
+ f"Config for '{key}' should be PartDatabaseConfig"
+ )
+
+ def test_library_configs_have_display_names(self):
+ """All LIBRARY_CONFIGS entries have non-empty display_name."""
+ for key, config in LIBRARY_CONFIGS.items():
+ assert config.display_name, (
+ f"Config for '{key}' should have non-empty display_name"
+ )
+
+ def test_library_configs_display_names_unique(self):
+ """All display_name values in LIBRARY_CONFIGS are unique."""
+ display_names = [config.display_name for config in LIBRARY_CONFIGS.values()]
+ assert len(display_names) == len(set(display_names)), (
+ "Display names should be unique"
+ )
+
+ def test_library_configs_db_names_unique(self):
+ """All database names in LIBRARY_CONFIGS are unique."""
+ db_names = [config.name for config in LIBRARY_CONFIGS.values()]
+ assert len(db_names) == len(set(db_names)), "Database names should be unique"
+
+ def test_default_library_is_current_parts(self):
+ """DEFAULT_LIBRARY points to 'current-parts'."""
+ assert DEFAULT_LIBRARY == "current-parts"
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py b/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py
new file mode 100644
index 00000000..df4d03c6
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/derive_params.py
@@ -0,0 +1,171 @@
+"""Derive parameters from the part description and package."""
+
+# LCSC hides the critical parameters (like resistor values) in the middle of the
+# description field, which makes them invisible in the footprint list. This
+# makes it very tedious to double-check the part mappings, as the part details
+# dialog has to be opened for each one.
+#
+# This function uses heuristics to extract a human-readable summary of the most
+# significant parameters of the parts from the LCSC data so they can be displayed
+# separately in the footprint list.
+
+import logging
+import re
+
+logger = logging.getLogger()
+logging.basicConfig(encoding="utf-8", level=logging.DEBUG)
+
+
+def params_for_part(part) -> str:
+ """Derive parameters from the part description and package."""
+ description = part.get("description", "")
+ category = part.get("category", "")
+ part_no = part.get("part_no", "")
+ package = part.get("package", "")
+
+ result = []
+
+ # These are heuristic regexes to pull out parameters like resistance,
+ # capacitance, voltage, current, etc. from the description field. As LCSC
+ # makes random changes to the format of the descriptions, this function will
+ # need to follow along.
+ #
+ # The package size isn't always in the description, but will be added by the
+ # caller.
+
+ # For passives, focus on generic values like resistance, capacitance, voltage
+
+ if "Resistors" in category:
+ result.extend(re.findall(r"([.\d]+[mkM]?Ω)", description))
+ result.extend(re.findall(r"(±[.\d]+%)", description))
+ elif "Capacitors" in category:
+ result.extend(re.findall(r"([.\d]+[pnmuμ]?F)", description))
+ result.extend(re.findall(r"([.\d]+[mkM]?V)", description))
+ elif "Inductors" in category:
+ result.extend(re.findall(r"([.\d]+[nuμm]?H)", description))
+ result.extend(re.findall(r"([.\d]+m?A)", description))
+
+ # For diodes, we may be looking for specific part or generic I/V specs
+
+ elif "Diodes" in category:
+ if part_no:
+ result.append(part_no)
+ result.extend(re.findall(r"(? str:
+ """Check the plausibility of the parts, there should be just one value per LCSC number.
+
+ Returns an empty sting if all parts are ok, otherwise a otherwise a overview of parts that share a LCSC number but have different values.
+ """
+ lcsc_numbers = {}
+ for item in self.parent.store.read_bom_parts():
+ if not item["lcsc"]:
+ continue
+ if item["lcsc"] not in lcsc_numbers:
+ lcsc_numbers[item["lcsc"]] = [
+ {"refs": item["refs"], "values": item["value"]}
+ ]
+ else:
+ lcsc_numbers[item["lcsc"]].append(
+ {"refs": item["refs"], "values": item["value"]}
+ )
+ filtered = {key: value for key, value in lcsc_numbers.items() if len(value) > 1}
+ result = ""
+ for lcsc, items in filtered.items():
+ result += f"{lcsc}:\n"
+ for item in items:
+ result += f" - {item['refs']} -> {item['values']}\n"
+ return result
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/helpers.py b/third_party/Bouni/kicad-jlcpcb-tools/helpers.py
new file mode 100644
index 00000000..ddcb045e
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/helpers.py
@@ -0,0 +1,207 @@
+"""Contains helper function used all over the plugin."""
+
+import os
+from pathlib import Path
+import re
+
+import wx # pylint: disable=import-error
+import wx.dataview # pylint: disable=import-error
+
+PLUGIN_PATH = Path(__file__).resolve().parent
+
+EXCLUDE_FROM_POS = 2
+EXCLUDE_FROM_BOM = 3
+
+
+def getWxWidgetsVersion():
+ """Get wx widgets version."""
+ v = re.search(r"wxWidgets\s([\d\.]+)", wx.version())
+ v = int(v.group(1).replace(".", ""))
+ return v
+
+
+def getVersion():
+ """READ Version from file."""
+ if not os.path.isfile(os.path.join(PLUGIN_PATH, "VERSION")):
+ return "unknown"
+ with open(os.path.join(PLUGIN_PATH, "VERSION"), encoding="utf-8") as f:
+ return f.read().strip()
+
+
+def GetOS():
+ """Get String with OS type."""
+ return wx.PlatformInformation.Get().GetOperatingSystemIdName()
+
+
+def GetScaleFactor(window):
+ """Workaround if wxWidgets Version does not support GetDPIScaleFactor, for Mac OS always return 1.0."""
+ if "Apple Mac OS" in GetOS():
+ return 1.0
+ if hasattr(window, "GetDPIScaleFactor"):
+ return window.GetDPIScaleFactor()
+ return 1.0
+
+
+def HighResWxSize(window, size):
+ """Workaround if wxWidgets Version does not support FromDIP."""
+ if hasattr(window, "FromDIP"):
+ return window.FromDIP(size)
+ return size
+
+
+def loadBitmapScaled(filename, scale=1.0, static=False):
+ """Load a scaled bitmap, handle differences between Kicad versions."""
+ if filename:
+ path = os.path.join(PLUGIN_PATH, "icons", filename)
+ bmp = wx.Bitmap(path)
+ w, h = bmp.GetSize()
+ img = bmp.ConvertToImage()
+ if hasattr(wx.SystemSettings, "GetAppearance") and hasattr(
+ wx.SystemSettings.GetAppearance, "IsUsingDarkBackground"
+ ):
+ if wx.SystemSettings.GetAppearance().IsUsingDarkBackground():
+ img.Replace(0, 0, 0, 255, 255, 255)
+ bmp = wx.Bitmap(img.Scale(int(w * scale), int(h * scale)))
+ else:
+ bmp = wx.Bitmap()
+ if getWxWidgetsVersion() > 315 and not static:
+ return wx.BitmapBundle(bmp)
+ return bmp
+
+
+def loadIconScaled(filename, scale=1.0):
+ """Load a scaled icon, handle differences between Kicad versions."""
+ bmp = loadBitmapScaled(filename, scale=scale, static=False)
+ if getWxWidgetsVersion() > 315:
+ return bmp
+ return wx.Icon(bmp)
+
+
+def natural_sort_collation(a, b):
+ """Natural sort collation for use in sqlite."""
+ if a == b:
+ return 0
+
+ def convert(text):
+ return int(text) if text.isdigit() else text.lower()
+
+ def alphanum_key(key):
+ return [convert(c) for c in re.split("([0-9]+)", key)]
+
+ natorder = sorted([a, b], key=alphanum_key)
+ return -1 if natorder.index(a) == 0 else 1
+
+
+def dict_factory(cursor, row) -> dict:
+ """Row factory that returns a dict."""
+ d = {}
+ for idx, col in enumerate(cursor.description):
+ d[col[0]] = row[idx]
+ return d
+
+
+def get_lcsc_value(fp):
+ """Get the first lcsc number (C123456 for example) from the properties of the footprint."""
+ # KiCad 7.99
+ try:
+ for field in fp.GetFields():
+ if re.match(r"lcsc|jlc", field.GetName(), re.IGNORECASE) and re.match(
+ r"^C\d+$", field.GetText()
+ ):
+ return field.GetText()
+ # KiCad <= V7
+ except AttributeError:
+ for key, value in fp.GetProperties().items():
+ if re.match(r"lcsc|jlc", key, re.IGNORECASE) and re.match(r"^C\d+$", value):
+ return value
+ return ""
+
+
+def set_lcsc_value(fp, lcsc: str):
+ """Set an lcsc number to the first matching propertie of the footprint, use LCSC as property name if not found."""
+ lcsc_field = None
+ for field in fp.GetFields():
+ if re.match(r"lcsc|jlc", field.GetName(), re.IGNORECASE) and re.match(
+ r"^C\d+$", field.GetText()
+ ):
+ lcsc_field = field
+
+ if lcsc_field:
+ fp.SetField(lcsc_field.GetName(), lcsc)
+ else:
+ fp.SetField("LCSC", lcsc)
+ if hasattr(fp, "GetFieldByName"):
+ fp.GetFieldByName("LCSC").SetVisible(False)
+ else:
+ for field in fp.GetFields():
+ if field.GetName() == "LCSC":
+ field.SetVisible(False)
+ break
+
+def get_valid_footprints(board):
+ """Get all footprints that have a valid reference.
+
+ Drop all REF** for example
+ Drop kibuzzard footprints (length check)
+ """
+ footprints = []
+ for fp in board.GetFootprints():
+ if re.match(r"[\w\d-]+", fp.GetReference()):
+ footprints.append(fp)
+ return footprints
+
+
+def get_bit(value, bit):
+ """Get the nth bit of a byte."""
+ return value & (1 << bit)
+
+
+def toggle_bit(value, bit):
+ """Toggle the nth bit of a byte."""
+ return value ^ (1 << bit)
+
+
+def get_exclude_from_pos(footprint):
+ """Get the 'exclude from POS' property of a footprint."""
+ if not footprint:
+ return None
+ val = footprint.GetAttributes()
+ return bool(get_bit(val, EXCLUDE_FROM_POS))
+
+
+def get_exclude_from_bom(footprint):
+ """Get the 'exclude from BOM' property of a footprint."""
+ if not footprint:
+ return None
+ val = footprint.GetAttributes()
+ return bool(get_bit(val, EXCLUDE_FROM_BOM))
+
+
+def get_is_dnp(footprint):
+ """Get the runtime 'Do not place' state of a footprint."""
+ if not footprint:
+ return False
+ is_dnp = getattr(footprint, "IsDNP", None)
+ if not callable(is_dnp):
+ return False
+ return bool(is_dnp())
+
+
+def toggle_exclude_from_pos(footprint):
+ """Toggle the 'exclude from POS' property of a footprint."""
+ if not footprint:
+ return None
+ val = footprint.GetAttributes()
+ val = toggle_bit(val, EXCLUDE_FROM_POS)
+ footprint.SetAttributes(val)
+ return bool(get_bit(val, EXCLUDE_FROM_POS))
+
+
+def toggle_exclude_from_bom(footprint):
+ """Toggle the 'exclude from BOM' property of a footprint."""
+ if not footprint:
+ return None
+ val = footprint.GetAttributes()
+ val = toggle_bit(val, EXCLUDE_FROM_BOM)
+ footprint.SetAttributes(val)
+ return bool(get_bit(val, EXCLUDE_FROM_BOM))
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png
new file mode 100644
index 00000000..bc089ee9
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/BOT.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png
new file mode 100644
index 00000000..d9c6c698
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/TOP.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png
new file mode 100644
index 00000000..8fff2020
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom-pos.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png
new file mode 100644
index 00000000..113ea0d2
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bom.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png
new file mode 100644
index 00000000..b0b52705
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/bug-check-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png
new file mode 100644
index 00000000..d965f2c1
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/database-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png
new file mode 100644
index 00000000..e864de3d
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/fabrication.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png
new file mode 100644
index 00000000..f6698363
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/fill-zones.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png
new file mode 100644
index 00000000..6c9b91b4
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-application-export.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png
new file mode 100644
index 00000000..53008eb7
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cash.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png
new file mode 100644
index 00000000..bdab158f
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check-color.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png
new file mode 100644
index 00000000..ca6c2e4d
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-check.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png
new file mode 100644
index 00000000..0d3db507
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-checkbox-multiple-marked.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png
new file mode 100644
index 00000000..959f396e
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-box-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png
new file mode 100644
index 00000000..1342eed9
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-close-color.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png
new file mode 100644
index 00000000..ee8684ef
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cloud-download-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png
new file mode 100644
index 00000000..1e888ef1
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-cog-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png
new file mode 100644
index 00000000..a5b58505
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png
new file mode 100644
index 00000000..a1cb69a8
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-content-save-settings.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png
new file mode 100644
index 00000000..5996f368
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-crosshairs-gps.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png
new file mode 100644
index 00000000..5dc07770
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-export-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png
new file mode 100644
index 00000000..7190880d
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-import-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png
new file mode 100644
index 00000000..1d246677
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-database-search-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png
new file mode 100644
index 00000000..e8442a9e
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-earth.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png
new file mode 100644
index 00000000..cc31e086
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-off-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png
new file mode 100644
index 00000000..9753cd7e
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-eye-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png
new file mode 100644
index 00000000..4778ad92
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-file-document-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png
new file mode 100644
index 00000000..538c78d1
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-list-bulleted.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png
new file mode 100644
index 00000000..e1cd2eb5
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-format-rotate-90.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png
new file mode 100644
index 00000000..40a7e114
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-help-circle-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png
new file mode 100644
index 00000000..045f1366
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-layers-triple-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png
new file mode 100644
index 00000000..89326bc1
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-lead-pencil.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png
new file mode 100644
index 00000000..a356480b
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-magnify.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png
new file mode 100644
index 00000000..7feb05fc
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-plus-circle-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png
new file mode 100644
index 00000000..a09ea2f6
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-selection.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png
new file mode 100644
index 00000000..e3bd6d82
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-text-box-search-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png
new file mode 100644
index 00000000..53efda57
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-off-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png
new file mode 100644
index 00000000..b7569421
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-toggle-switch-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png
new file mode 100644
index 00000000..58a3aa36
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/mdi-trash-can-outline.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png
new file mode 100644
index 00000000..1822fd49
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_bom.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png
new file mode 100644
index 00000000..bfbdea8a
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_order_number.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png
new file mode 100644
index 00000000..9e244e16
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_refs.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png
new file mode 100644
index 00000000..85bd5b0c
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/no_values.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png
new file mode 100644
index 00000000..7f83a4cf
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/order_number.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png
new file mode 100644
index 00000000..ba0f99a3
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/placeholder.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png
new file mode 100644
index 00000000..4dcc81a0
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_refs.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png
new file mode 100644
index 00000000..d17c4ec9
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/plot_values.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png
new file mode 100644
index 00000000..7fef12d4
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/schematic.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg
new file mode 100644
index 00000000..51d341b6
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/BOT.svg
@@ -0,0 +1,72 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg
new file mode 100644
index 00000000..1768c0b9
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/TOP.svg
@@ -0,0 +1,72 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg
new file mode 100644
index 00000000..e21a0164
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/bom.svg
@@ -0,0 +1,131 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg
new file mode 100644
index 00000000..3dc93e68
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_bom.svg
@@ -0,0 +1,142 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg
new file mode 100644
index 00000000..374ec48c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_order_number.svg
@@ -0,0 +1,88 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg
new file mode 100644
index 00000000..e2de8e65
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_refs.svg
@@ -0,0 +1,176 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg
new file mode 100644
index 00000000..3ad44163
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/no_value.svg
@@ -0,0 +1,181 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg
new file mode 100644
index 00000000..b1f2e262
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/order_number.svg
@@ -0,0 +1,53 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg
new file mode 100644
index 00000000..d413984b
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_refs.svg
@@ -0,0 +1,167 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg
new file mode 100644
index 00000000..cdfd3be2
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/plot_value.svg
@@ -0,0 +1,172 @@
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg
new file mode 100644
index 00000000..10408a5f
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/tented.svg
@@ -0,0 +1,122 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg
new file mode 100644
index 00000000..16136b8c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/icons/svg/untented.svg
@@ -0,0 +1,122 @@
+
+
+
+
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png
new file mode 100644
index 00000000..f2f7dd82
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/tented.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png
new file mode 100644
index 00000000..72430661
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/unfill-zones.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png b/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png
new file mode 100644
index 00000000..e75d8e5d
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/icons/untented.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png b/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png
new file mode 100644
index 00000000..2247b910
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/additional_jlc_layers.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png b/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png
new file mode 100644
index 00000000..3468a8c1
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/fabrication_files.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png b/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png
new file mode 100644
index 00000000..bb857207
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/footprint_selection.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/main.png b/third_party/Bouni/kicad-jlcpcb-tools/images/main.png
new file mode 100644
index 00000000..175b193b
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/main.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png b/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png
new file mode 100644
index 00000000..0ba372d9
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/part_details.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png b/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png
new file mode 100644
index 00000000..cff6b8a6
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/part_library.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png
new file mode 100644
index 00000000..15ab0495
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_1.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png
new file mode 100644
index 00000000..acb2671a
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_2.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png
new file mode 100644
index 00000000..e21075ff
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_3.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png
new file mode 100644
index 00000000..83d1615e
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/release_step_4.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif b/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif
new file mode 100644
index 00000000..de97864b
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/images/showcase.gif differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png b/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png
new file mode 100644
index 00000000..14dcc2fa
Binary files /dev/null and b/third_party/Bouni/kicad-jlcpcb-tools/jlcpcb-icon.png differ
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py b/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py
new file mode 100644
index 00000000..61cf3b3d
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/kicad_cli.py
@@ -0,0 +1,143 @@
+"""Helpers for invoking kicad-cli across platforms."""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+from typing import Any
+
+
+def resolve_kicad_cli_path(pcbnew_module: Any = None) -> str | None:
+ """Resolve the `kicad-cli` executable path across platforms.
+
+ Resolution order:
+ 1. `KICAD_CLI` env var (full path)
+ 2. `kicad-cli` on `PATH`
+ 3. Platform-specific default install locations
+ 4. Derive app bundle path from `pcbnew` module location (macOS)
+ """
+ env_cli = os.getenv("KICAD_CLI", "").strip()
+ if env_cli and os.path.isfile(env_cli):
+ return env_cli
+
+ if cli := shutil.which("kicad-cli"):
+ return cli
+
+ candidates: list[str] = []
+
+ if sys.platform.startswith("win"):
+ base_path = os.environ.get("KICAD_PATH", r"C:\Program Files\KiCad")
+ candidates.extend(
+ [
+ os.path.join(base_path, "bin", "kicad-cli.exe"),
+ os.path.join(base_path, "10.0", "bin", "kicad-cli.exe"),
+ os.path.join(base_path, "9.0", "bin", "kicad-cli.exe"),
+ os.path.join(base_path, "8.0", "bin", "kicad-cli.exe"),
+ ]
+ )
+ elif sys.platform == "darwin":
+ candidates.extend(
+ [
+ "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
+ "/Applications/KiCad/KiCad Nightly.app/Contents/MacOS/kicad-cli",
+ ]
+ )
+ else:
+ # Linux and other POSIX paths
+ candidates.extend(["/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli"])
+
+ pcbnew_file = getattr(pcbnew_module, "__file__", "")
+ if isinstance(pcbnew_file, str) and "/Contents/" in pcbnew_file:
+ contents_idx = pcbnew_file.find("/Contents/")
+ if contents_idx > 0:
+ app_contents = pcbnew_file[: contents_idx + len("/Contents")]
+ candidates.append(os.path.join(app_contents, "MacOS", "kicad-cli"))
+
+ for candidate in candidates:
+ if os.path.isfile(candidate):
+ return candidate
+
+ return None
+
+
+def run_drc(
+ kicad_cli_path: str,
+ board_filename: str,
+ output_path: str,
+) -> subprocess.CompletedProcess[str]:
+ """Run `kicad-cli pcb drc` and return process result."""
+ cmd = [
+ kicad_cli_path,
+ "pcb",
+ "drc",
+ board_filename,
+ f"--output={output_path}",
+ "--format=json",
+ "--refill-zones",
+ "--severity-error",
+ ]
+ return subprocess.run(
+ cmd,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+
+def count_drc_violations(report_path: str | Path) -> int:
+ """Count DRC violations in a KiCad JSON DRC report."""
+ with open(report_path, encoding="utf-8") as report_file:
+ report = json.load(report_file)
+ return len(report.get("violations", []))
+
+
+class DRCViolationCounter:
+ """Run KiCad CLI DRC and return the count of error-severity violations."""
+
+ def __init__(
+ self,
+ pcbnew_module: Any = None,
+ working_dir: str | None = None,
+ ):
+ self._pcbnew_module = pcbnew_module
+ self._working_dir = working_dir
+
+ def get_violation_count(self, board_filename: str) -> int:
+ """Run DRC and return violation count, raising RuntimeError on failure."""
+ cli_path = resolve_kicad_cli_path(self._pcbnew_module)
+ if not cli_path:
+ raise RuntimeError(
+ "Could not locate kicad-cli. Install KiCad CLI support, add it to PATH, "
+ "or set KICAD_CLI to the executable path."
+ )
+
+ report_path = ""
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".json",
+ delete=False,
+ dir=self._working_dir,
+ ) as report_file:
+ report_path = report_file.name
+
+ completed = run_drc(cli_path, board_filename, report_path)
+
+ if os.path.exists(report_path) and os.path.getsize(report_path) > 0:
+ return count_drc_violations(report_path)
+
+ if completed.returncode != 0:
+ msg = completed.stderr.strip() or completed.stdout.strip() or "Unknown error"
+ raise RuntimeError(f"kicad-cli returned: {msg}")
+
+ return 0
+ finally:
+ if report_path and os.path.exists(report_path):
+ with contextlib.suppress(OSError):
+ os.remove(report_path)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py b/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py
new file mode 100644
index 00000000..d989df17
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/lcsc_api.py
@@ -0,0 +1,49 @@
+"""Unofficial LCSC API."""
+
+import io
+from pathlib import Path
+from typing import Union
+
+import requests # pylint: disable=import-error
+
+
+class LCSC_API:
+ """Unofficial LCSC API."""
+
+ def __init__(self):
+ self.headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"
+ } # pretend we are browser, otherwise their cloud service blocks the request
+
+ def get_part_data(self, lcsc_number: str) -> dict:
+ """Get data for a given LCSC number from the API."""
+ r = requests.get(
+ f"https://cart.jlcpcb.com/shoppingCart/smtGood/getComponentDetail?componentCode={lcsc_number}",
+ headers=self.headers,
+ timeout=10,
+ )
+ if r.status_code != requests.codes.ok: # pylint: disable=no-member
+ return {"success": False, "msg": "non-OK HTTP response status"}
+ data = r.json()
+ if not data.get("data"):
+ return {
+ "success": False,
+ "msg": "returned JSON data does not have expected 'data' attribute",
+ }
+ return {"success": True, "data": data}
+
+ def download_bitmap(self, url: str) -> Union[io.BytesIO, None]:
+ """Download a picture of the part from the API."""
+ content = requests.get(url, headers=self.headers, timeout=10).content
+ return io.BytesIO(content)
+
+ def download_datasheet(self, url: str, path: Path):
+ """Download and save a datasheet from the API."""
+ r = requests.get(url, stream=True, headers=self.headers, timeout=10)
+ if r.status_code != requests.codes.ok: # pylint: disable=no-member
+ return {"success": False, "msg": "non-OK HTTP response status"}
+ if not r:
+ return {"success": False, "msg": "Failed to download datasheet!"}
+ with open(path, "wb") as f:
+ f.write(r.content)
+ return {"success": True, "msg": "Successfully downloaded datasheet!"}
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/library.py b/third_party/Bouni/kicad-jlcpcb-tools/library.py
new file mode 100644
index 00000000..eb1e4ef2
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/library.py
@@ -0,0 +1,865 @@
+"""Handle the JLCPCB parts database."""
+
+import contextlib
+from enum import Enum
+import logging
+import os
+from pathlib import Path
+import sqlite3
+from threading import Lock, Thread
+import time
+from typing import NamedTuple, Optional
+
+import requests # pylint: disable=import-error
+import wx # pylint: disable=import-error
+
+from .dblib import DEFAULT_LIBRARY, LIBRARY_CONFIGS
+from .events import (
+ DownloadCompletedEvent,
+ DownloadProgressEvent,
+ DownloadStartedEvent,
+ MessageEvent,
+)
+from .helpers import PLUGIN_PATH, dict_factory, natural_sort_collation
+from .partselector_columns import DB_FIELDS, SORTABLE_COLUMN_INDEX_TO_DB
+from .search_escape import escape_fts_phrase, escape_like_term
+from .unzip_parts import unzip_parts
+
+
+class PartsDatabaseInfo(NamedTuple):
+ """Information about the parts database."""
+
+ last_update: str
+ size: int
+ part_count: int
+
+
+class LibraryState(Enum):
+ """The various states of the library."""
+
+ INITIALIZED = 0
+ UPDATE_NEEDED = 1
+ DOWNLOAD_RUNNING = 2
+
+
+class Library:
+ """A storage class to get data from a sqlite database and write it back."""
+
+ def __init__(self, parent):
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+ self.order_by = "LCSC Part"
+ self.order_dir = "ASC"
+ self.datadir = ""
+ self.selected_library = DEFAULT_LIBRARY
+ self.partsdb_file = ""
+ self.rotationsdb_file = ""
+ self.localcorrectionsdb_file = ""
+ self.globalcorrectionsdb_file = ""
+ self.correctionsdb_file = ""
+ self.mappingsdb_file = ""
+ self.state = None
+ self.download_lock = Lock()
+ self.category_map = {}
+
+ self.refresh_library_config()
+
+ self.logger.debug("partsdb_file %s", self.partsdb_file)
+ self.logger.debug("sqlite.sqlite_version %s", sqlite3.sqlite_version)
+
+ def _resolve_data_directory(self):
+ """Resolve the directory where global database files are stored."""
+ configured = self.parent.settings.get("library", {}).get("data_path", "")
+ if isinstance(configured, str) and configured.strip():
+ return os.path.abspath(os.path.expanduser(configured.strip()))
+ return os.path.join(PLUGIN_PATH, "jlcpcb")
+
+ def refresh_library_config(self):
+ """Refresh library configuration from settings."""
+ self.datadir = self._resolve_data_directory()
+
+ # Get selected library from settings, default to all-parts
+ selected_library = self.parent.settings.get("library", {}).get(
+ "selected_library", DEFAULT_LIBRARY
+ )
+ if selected_library not in LIBRARY_CONFIGS:
+ selected_library = DEFAULT_LIBRARY
+
+ self.selected_library = selected_library
+ library_config = LIBRARY_CONFIGS[selected_library]
+ self.partsdb_file = os.path.join(self.datadir, library_config.name)
+ self.rotationsdb_file = os.path.join(self.datadir, "rotations.db")
+ self.localcorrectionsdb_file = os.path.join(
+ self.parent.project_path, "jlcpcb", "project.db"
+ )
+ self.globalcorrectionsdb_file = os.path.join(self.datadir, "corrections.db")
+ self.correctionsdb_file = (
+ self.globalcorrectionsdb_file
+ if self.uses_global_correction_database()
+ else self.localcorrectionsdb_file
+ )
+ self.mappingsdb_file = os.path.join(self.datadir, "mappings.db")
+ self.category_map = {}
+
+ self.setup()
+ self.check_library()
+
+ self.logger.debug(
+ "Library configuration refreshed. Selected: %s, Data directory: %s, Database: %s",
+ self.selected_library,
+ self.datadir,
+ self.partsdb_file,
+ )
+
+ def setup(self):
+ """Check if folders and database exist, setup if not."""
+ if not os.path.isdir(self.datadir):
+ self.logger.info(
+ "Data directory '%s' does not exist and will be created.", self.datadir
+ )
+ Path(self.datadir).mkdir(parents=True, exist_ok=True)
+ else:
+ self.logger.info("Data directory '%s' exists, not creating", self.datadir)
+
+ def check_library(self):
+ """Check if the database files exists, if not trigger update / create database."""
+ if (
+ not os.path.isfile(self.partsdb_file)
+ or os.path.getsize(self.partsdb_file) == 0
+ ):
+ self.state = LibraryState.UPDATE_NEEDED
+ else:
+ self.state = LibraryState.INITIALIZED
+ if (
+ not os.path.isfile(self.correctionsdb_file)
+ or os.path.getsize(self.correctionsdb_file) == 0
+ ):
+ self.create_correction_table()
+ self.migrate_corrections()
+ if (
+ not os.path.isfile(self.mappingsdb_file)
+ or os.path.getsize(self.mappingsdb_file) == 0
+ ):
+ self.create_mapping_table()
+ self.migrate_mappings()
+
+ def uses_global_correction_database(self):
+ """Check if there is a board specific corrections database or not.
+
+ Returns True if the global database is used.
+ """
+
+ try:
+ with (
+ contextlib.closing(
+ sqlite3.connect(self.localcorrectionsdb_file)
+ ) as ldb,
+ ldb as lcur,
+ ):
+ result = lcur.execute(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction')"
+ ).fetchone()
+ if not result:
+ return True
+
+ return result[0] != 1
+ except sqlite3.OperationalError:
+ return True
+
+ return True
+
+ def switch_to_global_correction_database(self, use_global):
+ """Switches to global or board local database."""
+
+ currently_using_global = (
+ self.correctionsdb_file == self.globalcorrectionsdb_file
+ )
+ if currently_using_global == use_global:
+ return
+
+ if use_global:
+ try:
+ with (
+ contextlib.closing(
+ sqlite3.connect(self.localcorrectionsdb_file)
+ ) as con,
+ con as cur,
+ ):
+ cur.execute("DROP TABLE IF EXISTS correction")
+ cur.commit()
+ self.correctionsdb_file = self.globalcorrectionsdb_file
+ except OSError:
+ self.logger.warning("Failed to remove board local corrections file.")
+ else:
+ global_corrections = self.get_all_correction_data()
+ self.correctionsdb_file = self.localcorrectionsdb_file
+ self.create_correction_table()
+ for regex, rotation, offset in global_corrections:
+ self.insert_correction_data(regex, rotation, offset)
+
+ def set_order_by(self, n):
+ """Set which value we want to order by when getting data from the database."""
+ column = SORTABLE_COLUMN_INDEX_TO_DB.get(n)
+ if column is None:
+ return
+ if self.order_by == column and self.order_dir == "ASC":
+ self.order_dir = "DESC"
+ else:
+ self.order_by = column
+ self.order_dir = "ASC"
+
+ def search(self, parameters):
+ """Search the database for parts that meet the given parameters."""
+
+ # skip searching if there are no keywords and the part number
+ # field is empty as there are too many parts for the search
+ # to reasonbly show the desired part
+ if parameters["keyword"] == "" and (
+ "part_no" not in parameters or parameters["part_no"] == ""
+ ):
+ return []
+
+ # Note: must match the shared part selector column definitions.
+ s = ",".join(f'"{c}"' for c in DB_FIELDS)
+ query = f"SELECT {s} FROM parts WHERE "
+
+ match_chunks = []
+ like_chunks = []
+
+ query_chunks = []
+
+ # Build 'match_chunks' and 'like_chunks' arrays
+ #
+ # FTS5 (https://www.sqlite.org/fts5.html) has a substring limit of
+ # at least 3 characters.
+ # 'Substrings consisting of fewer than 3 unicode characters do not
+ # match any rows when used with a full-text query'
+ #
+ # However, they will still match with a LIKE.
+ #
+ # So extract out the <3 character strings and add a 'LIKE' term
+ # for each of those.
+ if parameters["keyword"] != "":
+ keywords = parameters["keyword"].split(" ")
+ match_keywords_intermediate = []
+ for w in keywords:
+ # skip over empty keywords
+ if w != "":
+ if len(w) < 3: # LIKE entry
+ escaped = escape_like_term(w)
+ kw = f"description LIKE '%{escaped}%' ESCAPE '\\'"
+ like_chunks.append(kw)
+ else: # MATCH entry
+ escaped = escape_fts_phrase(w)
+ kw = f'"{escaped}"'
+ match_keywords_intermediate.append(kw)
+ if match_keywords_intermediate:
+ match_entry = " AND ".join(match_keywords_intermediate)
+ match_chunks.append(f"{match_entry}")
+
+ if "manufacturer" in parameters and parameters["manufacturer"] != "":
+ p = escape_fts_phrase(parameters["manufacturer"])
+ match_chunks.append(f'"Manufacturer":"{p}"')
+ if "package" in parameters and parameters["package"] != "":
+ p = escape_fts_phrase(parameters["package"])
+ match_chunks.append(f'"Package":"{p}"')
+ if (
+ "category" in parameters
+ and parameters["category"] != ""
+ and parameters["category"] != "All"
+ ):
+ p = escape_fts_phrase(parameters["category"])
+ match_chunks.append(f'"First Category":"{p}"')
+ if "subcategory" in parameters and parameters["subcategory"] != "":
+ p = escape_fts_phrase(parameters["subcategory"])
+ match_chunks.append(f'"Second Category":"{p}"')
+ if "part_no" in parameters and parameters["part_no"] != "":
+ p = escape_fts_phrase(parameters["part_no"])
+ match_chunks.append(f'"MFR.Part":"{p}"')
+ if "solder_joints" in parameters and parameters["solder_joints"] != "":
+ p = escape_fts_phrase(parameters["solder_joints"])
+ match_chunks.append(f'"Solder Joint":"{p}"')
+
+ library_types = []
+ if parameters["basic"]:
+ library_types.append('"Basic"')
+ if parameters["extended"]:
+ library_types.append('"Extended"')
+ if parameters["preferred"]:
+ library_types.append('"Preferred"')
+ if library_types:
+ query_chunks.append(f'"Library Type" IN ({",".join(library_types)})')
+
+ if parameters["stock"]:
+ query_chunks.append('"Stock" > "0"')
+
+ if not match_chunks and not like_chunks and not query_chunks:
+ return []
+
+ if match_chunks:
+ query += "parts MATCH '"
+ query += " AND ".join(match_chunks)
+ query += "'"
+
+ if like_chunks:
+ if match_chunks:
+ query += " AND "
+ query += " AND ".join(like_chunks)
+
+ if query_chunks:
+ if match_chunks or like_chunks:
+ query += " AND "
+ query += " AND ".join(query_chunks)
+
+ query += f' ORDER BY "{self.order_by}" COLLATE naturalsort {self.order_dir}'
+ query += " LIMIT 1000"
+
+ self.logger.debug("query '%s'", query)
+
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con:
+ con.create_collation("naturalsort", natural_sort_collation)
+ with con as cur:
+ return cur.execute(query).fetchall()
+
+ def delete_parts_table(self):
+ """Delete the parts table."""
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur:
+ cur.execute("DROP TABLE IF EXISTS parts")
+ cur.commit()
+
+ def create_meta_table(self):
+ """Create the meta table."""
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur:
+ cur.execute(
+ "CREATE TABLE IF NOT EXISTS meta ('filename', 'size', 'partcount', 'date', 'last_update')"
+ )
+ cur.commit()
+
+ def create_correction_table(self):
+ """Create the correction table."""
+ self.logger.debug("Create SQLite table for corrections")
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ "CREATE TABLE IF NOT EXISTS correction ('regex', 'rotation', 'offset_x', 'offset_y')"
+ )
+ cur.commit()
+
+ def get_correction_data(self, regex):
+ """Get the correction data by its regex."""
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ return cur.execute(
+ f"SELECT * FROM correction WHERE regex = '{regex}'"
+ ).fetchone()
+
+ def delete_correction_data(self, regex):
+ """Delete a correction from the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(f"DELETE FROM correction WHERE regex = '{regex}'")
+ cur.commit()
+
+ def update_correction_data(self, regex, rotation, offset):
+ """Update a correction in the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ f"UPDATE correction SET rotation = '{rotation}', offset_x = '{offset[0]}', offset_y = '{offset[1]}' WHERE regex = '{regex}'"
+ )
+ cur.commit()
+
+ def insert_correction_data(self, regex, rotation, offset):
+ """Insert a correction into the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ "INSERT INTO correction VALUES (?, ?, ?, ?)",
+ (regex, rotation, offset[0], offset[1]),
+ )
+ cur.commit()
+
+ def get_all_correction_data(self):
+ """Get all corrections from the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as con,
+ con as cur,
+ ):
+ try:
+ result = cur.execute(
+ "SELECT * FROM correction ORDER BY regex ASC"
+ ).fetchall()
+ return [(c[0], int(c[1]), (float(c[2]), float(c[3]))) for c in result]
+ except sqlite3.OperationalError:
+ return []
+
+ def create_mapping_table(self):
+ """Create the mapping table."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ "CREATE TABLE IF NOT EXISTS mapping ('footprint', 'value', 'LCSC')"
+ )
+ cur.commit()
+
+ def get_mapping_data(self, footprint, value):
+ """Get the mapping data by its regex."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ return cur.execute(
+ f"SELECT * FROM mapping WHERE footprint = '{footprint}' AND value = '{value}'"
+ ).fetchone()
+
+ def delete_mapping_data(self, footprint, value):
+ """Delete a mapping from the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ f"DELETE FROM mapping WHERE footprint = '{footprint}' AND value = '{value}'"
+ )
+ cur.commit()
+
+ def update_mapping_data(self, footprint, value, LCSC):
+ """Update a mapping in the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ f"UPDATE mapping SET LCSC = '{LCSC}' WHERE footprint = '{footprint}' AND value = '{value}'"
+ )
+ cur.commit()
+
+ def insert_mapping_data(self, footprint, value, LCSC):
+ """Insert a mapping into the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ cur.execute(
+ "INSERT INTO mapping VALUES (?, ?, ?)",
+ (footprint, value, LCSC),
+ )
+ cur.commit()
+
+ def get_all_mapping_data(self):
+ """Get all mapping from the database."""
+ with (
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as con,
+ con as cur,
+ ):
+ return [
+ list(c)
+ for c in cur.execute(
+ "SELECT * FROM mapping ORDER BY footprint ASC"
+ ).fetchall()
+ ]
+
+ def create_parts_table(self, columns):
+ """Create the parts table."""
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur:
+ cols = ",".join([f" '{c}'" for c in columns])
+ cur.execute(f"CREATE TABLE IF NOT EXISTS parts ({cols})")
+ cur.commit()
+
+ def get_part_details(self, number: str) -> dict:
+ """Get the part details for a LCSC number using optimized FTS5 querying."""
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con:
+ con.row_factory = dict_factory
+ cur = con.cursor()
+ query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type,
+ "MFR.Part" as part_no, "Description" as description, "Package" as package,
+ "First Category" as category
+ FROM parts WHERE parts MATCH :number"""
+ cur.execute(query, {"number": number})
+ return next((n for n in cur.fetchall() if n["lcsc"] == number), {})
+
+ def update(self):
+ """Update the sqlite parts database from the JLCPCB CSV."""
+ with self.download_lock:
+ if self.state == LibraryState.DOWNLOAD_RUNNING:
+ self.logger.info(
+ "Download already running, ignoring duplicate request."
+ )
+ return
+ self.state = LibraryState.DOWNLOAD_RUNNING
+ try:
+ Thread(target=self._download_wrapper).start()
+ except Exception:
+ with self.download_lock:
+ self.state = LibraryState.INITIALIZED
+ raise
+
+ def _download_wrapper(self):
+ """Run the download worker with guaranteed state cleanup."""
+ try:
+ self.download()
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ self.logger.exception("Unexpected error while downloading parts database")
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Download Error",
+ text=f"Unexpected error while downloading parts database: {exc}",
+ style="error",
+ ),
+ )
+ finally:
+ with self.download_lock:
+ self.state = LibraryState.INITIALIZED
+
+ def download(self):
+ """Actual worker thread that downloads and imports the parts data."""
+ start = time.time()
+ wx.PostEvent(self.parent, DownloadStartedEvent())
+
+ # Get library configuration for selected library
+ library_config = LIBRARY_CONFIGS[self.selected_library]
+
+ # Define basic variables
+ url_stub = "https://bouni.github.io/kicad-jlcpcb-tools/"
+ cnt_file = library_config.chunk_file_name
+ progress_file = os.path.join(self.datadir, f"{library_config.name}.progress")
+ chunk_file_stub = library_config.name.replace(".db", ".db.zip.")
+ completed_chunks = set()
+
+ self.logger.debug("Starting download of JLCPCB parts database...")
+ self.logger.debug(
+ "Using library: %s (basefile %s)",
+ self.selected_library,
+ chunk_file_stub,
+ )
+
+ # Check if there is a progress file
+ if os.path.exists(progress_file):
+ with open(progress_file) as f:
+ # Read completed chunk indices from the progress file
+ completed_chunks = {int(line.strip()) for line in f.readlines()}
+
+ # Get the total number of chunks to download
+ try:
+ r = requests.get(
+ url_stub + cnt_file, allow_redirects=True, stream=True, timeout=300
+ )
+ if r.status_code != requests.codes.ok:
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="HTTP GET Error",
+ text=f"Failed to fetch count of database parts, error code {r.status_code}\n"
+ + "URL was:\n"
+ f"'{url_stub + cnt_file}'",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ total_chunks = int(r.text)
+ except Exception as e:
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Download Error",
+ text=f"Failed to fetch database chunk count, {e}",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ # Re-download incomplete or missing chunks
+ for i in range(total_chunks):
+ chunk_index = i + 1
+ chunk_file = chunk_file_stub + f"{chunk_index:03}"
+ chunk_path = os.path.join(self.datadir, chunk_file)
+
+ # Check if the chunk is logged as completed but the file might be incomplete
+ if chunk_index in completed_chunks:
+ if os.path.exists(chunk_path):
+ # Validate the size of the chunk file
+ try:
+ expected_size = int(
+ requests.head(
+ url_stub + chunk_file, timeout=300
+ ).headers.get("Content-Length", 0)
+ )
+ actual_size = os.path.getsize(chunk_path)
+ if actual_size == expected_size:
+ self.logger.debug(
+ "Skipping already downloaded and validated chunk %d.",
+ chunk_index,
+ )
+ continue
+ else:
+ self.logger.warning(
+ "Chunk %d is incomplete, re-downloading.", chunk_index
+ )
+ except Exception as e:
+ self.logger.warning(
+ "Unable to validate chunk %d, re-downloading. Error: %s",
+ chunk_index,
+ e,
+ )
+ else:
+ self.logger.warning(
+ "Chunk %d marked as completed but file is missing, re-downloading.",
+ chunk_index,
+ )
+
+ # Download the chunk
+ try:
+ with open(chunk_path, "wb") as f:
+ r = requests.get(
+ url_stub + chunk_file,
+ allow_redirects=True,
+ stream=True,
+ timeout=300,
+ )
+ if r.status_code != requests.codes.ok:
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Download Error",
+ text=f"Failed to download chunk {chunk_index}, error code {r.status_code}\n"
+ + "URL was:\n"
+ f"'{url_stub + chunk_file}'",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ size = int(r.headers.get("Content-Length", 0))
+ self.logger.debug(
+ "Downloading chunk %d/%d (%.2f MB)",
+ chunk_index,
+ total_chunks,
+ size / 1024 / 1024,
+ )
+ for data in r.iter_content(chunk_size=4096):
+ f.write(data)
+ progress = f.tell() / size * 100
+ wx.PostEvent(self.parent, DownloadProgressEvent(value=progress))
+ self.logger.debug("Chunk %d downloaded successfully.", chunk_index)
+
+ # Update progress file after successful download
+ with open(progress_file, "a") as f:
+ f.write(f"{chunk_index}\n")
+
+ except Exception as e:
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Download Error",
+ text=f"Failed to download chunk {chunk_index}, {e}",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ # Delete progress file to indicate the download is complete
+ if os.path.exists(progress_file):
+ os.remove(progress_file)
+
+ # Combine and extract downloaded files
+ self.logger.debug("Combining and extracting zip part files...")
+ try:
+ unzip_parts(self.parent, self.datadir, library_config.name + ".zip")
+ except Exception as e:
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Extract Error",
+ text=f"Failed to combine and extract the JLCPCB database, {e}",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ # Check if the database file was successfully extracted
+ if not os.path.exists(self.partsdb_file):
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Download Error",
+ text="Failed to extract the database file from the downloaded zip.",
+ style="error",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+ return
+
+ wx.PostEvent(self.parent, DownloadCompletedEvent())
+ end = time.time()
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title="Success",
+ text=f"Successfully downloaded and imported the JLCPCB database in {end - start:.2f} seconds!",
+ style="info",
+ ),
+ )
+ self.state = LibraryState.INITIALIZED
+
+ def create_tables(self, headers):
+ """Create all tables."""
+ self.create_meta_table()
+ self.delete_parts_table()
+ self.create_parts_table(headers)
+ self.create_correction_table()
+ self.create_mapping_table()
+
+ @property
+ def categories(self):
+ """The primary categories in the database.
+
+ Caching the relatively small set of category and subcategory maps
+ gives a noticeable speed improvement over repeatedly reading the
+ information from the on-disk database.
+ """
+ if not self.category_map:
+ self.category_map.setdefault("", [])
+
+ # Populate the cache.
+ with (
+ contextlib.closing(sqlite3.connect(self.partsdb_file)) as con,
+ con as cur,
+ ):
+ for row in cur.execute(
+ 'SELECT * from categories ORDER BY UPPER("First Category"), UPPER("Second Category")'
+ ):
+ self.category_map.setdefault(row[0], []).append(row[1])
+ tmp = list(self.category_map.keys())
+ tmp.insert(0, "All")
+ return tmp
+
+ def get_subcategories(self, category):
+ """Get the subcategories associated with the given category."""
+ return self.category_map[category]
+
+ def migrate_corrections_from_rotation(self):
+ """Migrate existing rotations from rotation db to correction db."""
+ if not os.path.exists(self.rotationsdb_file):
+ return
+ with (
+ contextlib.closing(sqlite3.connect(self.rotationsdb_file)) as rdb,
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as cdb,
+ rdb as rcur,
+ cdb as ccur,
+ ):
+ try:
+ result = rcur.execute(
+ "SELECT * FROM rotation ORDER BY regex ASC"
+ ).fetchall()
+ if not result:
+ return
+ for r in result:
+ ccur.execute(
+ "INSERT INTO correction VALUES (?, ?, 0, 0)",
+ (r[0], r[1]),
+ )
+ ccur.commit()
+ self.logger.debug(
+ "Migrated %d rotations to corrections database.", len(result)
+ )
+ os.remove(self.rotationsdb_file)
+ self.logger.debug("Deleted rotations database.")
+ except sqlite3.OperationalError:
+ return
+ except OSError:
+ return
+
+ def migrate_corrections_from_parts(self):
+ """Migrate existing rotations from parts db to correction db."""
+ with (
+ contextlib.closing(sqlite3.connect(self.partsdb_file)) as pdb,
+ contextlib.closing(sqlite3.connect(self.correctionsdb_file)) as rdb,
+ pdb as pcur,
+ rdb as rcur,
+ ):
+ try:
+ result = pcur.execute(
+ "SELECT * FROM rotation ORDER BY regex ASC"
+ ).fetchall()
+ if not result:
+ return
+ for r in result:
+ rcur.execute(
+ "INSERT INTO correction VALUES (?, ?, 0, 0)",
+ (r[0], r[1]),
+ )
+ rcur.commit()
+ self.logger.debug(
+ "Migrated %d rotations to separate database.", len(result)
+ )
+ pcur.execute("DROP TABLE IF EXISTS rotation")
+ pcur.commit()
+ self.logger.debug("Droped rotations table from parts database.")
+ except sqlite3.OperationalError:
+ return
+
+ def migrate_corrections(self):
+ """Migrate existing rotations from old rotation db and parts db to correction db."""
+ self.migrate_corrections_from_rotation()
+ self.migrate_corrections_from_parts()
+
+ def migrate_mappings(self):
+ """Migrate existing mappings from parts db to mappings db."""
+ with (
+ contextlib.closing(sqlite3.connect(self.partsdb_file)) as pdb,
+ contextlib.closing(sqlite3.connect(self.mappingsdb_file)) as mdb,
+ pdb as pcur,
+ mdb as mcur,
+ ):
+ try:
+ result = pcur.execute(
+ "SELECT * FROM mapping ORDER BY footprint ASC"
+ ).fetchall()
+ if not result:
+ return
+ for r in result:
+ mcur.execute(
+ "INSERT INTO mapping VALUES (?, ?)",
+ (r[0], r[1]),
+ )
+ mcur.commit()
+ self.logger.debug(
+ "Migrated %d mappings to sepetrate database.", len(result)
+ )
+ pcur.execute("DROP TABLE IF EXISTS mapping")
+ pcur.commit()
+ self.logger.debug("Droped mappings table from parts database.")
+ except sqlite3.OperationalError:
+ return
+
+ def get_parts_db_info(self) -> Optional[PartsDatabaseInfo]: # noqa: UP045
+ """Retrieve the database information."""
+ with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con, con as cur:
+ try:
+ meta = cur.execute(
+ "SELECT last_update, size, partcount FROM meta"
+ ).fetchone()
+ if meta:
+ return PartsDatabaseInfo(meta[0], meta[1], meta[2])
+ return None
+ except sqlite3.OperationalError:
+ return None
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py b/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py
new file mode 100644
index 00000000..5631ab39
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/mainwindow.py
@@ -0,0 +1,1343 @@
+"""Contains the main window of the plugin."""
+
+from contextlib import suppress
+from datetime import datetime as dt
+import json
+import logging
+import os
+import re
+import sys
+import time
+
+import pcbnew as kicad_pcbnew
+import wx # pylint: disable=import-error
+from wx import adv # pylint: disable=import-error
+import wx.dataview as dv # pylint: disable=import-error
+
+from .corrections import CorrectionManagerDialog
+from .datamodel import PartListDataModel
+from .derive_params import params_for_part
+from .events import (
+ EVT_ASSIGN_PARTS_EVENT,
+ EVT_DOWNLOAD_COMPLETED_EVENT,
+ EVT_DOWNLOAD_PROGRESS_EVENT,
+ EVT_DOWNLOAD_STARTED_EVENT,
+ EVT_LOGBOX_APPEND_EVENT,
+ EVT_MESSAGE_EVENT,
+ EVT_POPULATE_FOOTPRINT_LIST_EVENT,
+ EVT_UNZIP_COMBINING_PROGRESS_EVENT,
+ EVT_UNZIP_COMBINING_STARTED_EVENT,
+ EVT_UNZIP_EXTRACTING_COMPLETED_EVENT,
+ EVT_UNZIP_EXTRACTING_PROGRESS_EVENT,
+ EVT_UNZIP_EXTRACTING_STARTED_EVENT,
+ EVT_UPDATE_SETTING,
+ LogboxAppendEvent,
+)
+from .fabrication import Fabrication
+from .helpers import (
+ PLUGIN_PATH,
+ GetScaleFactor,
+ HighResWxSize,
+ get_is_dnp,
+ getVersion,
+ loadBitmapScaled,
+ set_lcsc_value,
+ toggle_exclude_from_bom,
+ toggle_exclude_from_pos,
+)
+from .kicad_cli import DRCViolationCounter
+from .library import Library, LibraryState
+from .partdetails import PartDetailsDialog
+from .partmapper import PartMapperManagerDialog
+from .partselector import PartSelectorDialog
+from .schematicexport import SchematicExport
+from .settings import SettingsDialog
+from .store import Store
+
+logging.getLogger("requests").setLevel(logging.WARNING)
+logging.getLogger("urllib3").setLevel(logging.WARNING)
+
+ID_GENERATE = 0
+ID_LAYERS = 1
+ID_CORRECTIONS = 2
+ID_MAPPINGS = 3
+ID_DOWNLOAD = 4
+ID_SETTINGS = 5
+ID_SELECT_PART = 6
+ID_REMOVE_LCSC_NUMBER = 7
+ID_SELECT_ALIKE = 8
+ID_TOGGLE_BOM_POS = 9
+ID_TOGGLE_BOM = 10
+ID_TOGGLE_POS = 11
+ID_PART_DETAILS = 12
+ID_HIDE_BOM = 13
+ID_HIDE_POS = 14
+ID_SAVE_MAPPINGS = 15
+ID_EXPORT_TO_SCHEMATIC = 16
+ID_CONTEXT_MENU_COPY_LCSC = wx.NewIdRef()
+ID_CONTEXT_MENU_PASTE_LCSC = wx.NewIdRef()
+ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE = wx.NewIdRef()
+ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE = wx.NewIdRef()
+ID_CONTEXT_MENU_ADD_ROT_BY_NAME = wx.NewIdRef()
+ID_CONTEXT_MENU_FIND_MAPPING = wx.NewIdRef()
+ID_CONTEXT_MENU_ADD_MAPPING = wx.NewIdRef()
+
+
+class KicadProvider:
+ """KiCad implementation of the provider, see standalone_impl.py for the stub version."""
+
+ def get_pcbnew(self):
+ """Get the pcbnew instance."""
+ return kicad_pcbnew
+
+
+class JLCPCBTools(wx.Dialog):
+ """JLCPCBTools main dialog."""
+
+ def __init__(self, parent, kicad_provider=KicadProvider()):
+ while not wx.GetApp():
+ time.sleep(1)
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title=f"JLCPCB Tools [ {getVersion()} ]",
+ pos=wx.DefaultPosition,
+ size=wx.Size(1300, 800),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
+ )
+ self.pcbnew = kicad_provider.get_pcbnew()
+ self.window = wx.GetTopLevelParent(self)
+ self.SetSize(HighResWxSize(self.window, wx.Size(1300, 800)))
+ self.scale_factor = GetScaleFactor(self.window)
+ self.project_path = os.path.split(self.pcbnew.GetBoard().GetFileName())[0]
+ self.board_name = os.path.split(self.pcbnew.GetBoard().GetFileName())[1]
+ self.schematic_name = f"{self.board_name.split('.')[0]}.kicad_sch"
+ self.hide_bom_parts = False
+ self.hide_pos_parts = False
+ self.library: Library
+ self.store: Store
+ self.settings = {}
+ self.load_settings()
+ self.auto_select_alike = bool(
+ self.settings.get("general", {}).get("select_alike_auto", False)
+ )
+ self.select_alike_in_progress = False
+ self.Bind(wx.EVT_CLOSE, self.quit_dialog)
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+
+ # ---------------------------------------------------------------------
+ # -------------------- Horizontal top buttons -------------------------
+ # ---------------------------------------------------------------------
+
+ self.upper_toolbar = wx.ToolBar(
+ self,
+ wx.ID_ANY,
+ wx.DefaultPosition,
+ wx.Size(1300, -1),
+ wx.TB_HORIZONTAL | wx.TB_TEXT | wx.TB_NODIVIDER,
+ )
+
+ self.generate_button = self.upper_toolbar.AddTool(
+ ID_GENERATE,
+ "Generate",
+ loadBitmapScaled("fabrication.png", self.scale_factor),
+ "Generate fabrication files for JLCPCB",
+ )
+
+ self.upper_toolbar.AddSeparator()
+
+ self.layer_selection = adv.BitmapComboBox(
+ self.upper_toolbar, ID_LAYERS, style=wx.CB_READONLY
+ )
+
+ layer_options = [
+ "Auto",
+ "1 Layer",
+ "2 Layer",
+ "4 Layer",
+ "6 Layer",
+ "8 Layer",
+ "10 Layer",
+ "12 Layer",
+ "14 Layer",
+ "16 Layer",
+ "18 Layer",
+ "20 Layer",
+ ]
+
+ for option in layer_options:
+ self.layer_selection.Append(
+ option,
+ loadBitmapScaled(
+ "mdi-layers-triple-outline.png", self.scale_factor, True
+ ),
+ )
+
+ self.layer_selection.SetSelection(0)
+
+ self.upper_toolbar.AddControl(self.layer_selection)
+
+ self.upper_toolbar.AddStretchableSpace()
+
+ self.correction_button = self.upper_toolbar.AddTool(
+ ID_CORRECTIONS,
+ "Corrections",
+ loadBitmapScaled("mdi-format-rotate-90.png", self.scale_factor),
+ "Manage part corrections",
+ )
+
+ self.mapping_button = self.upper_toolbar.AddTool(
+ ID_MAPPINGS,
+ "Mappings",
+ loadBitmapScaled("mdi-selection.png", self.scale_factor),
+ "Manage part mappings",
+ )
+
+ self.upper_toolbar.AddSeparator()
+
+ self.download_button = self.upper_toolbar.AddTool(
+ ID_DOWNLOAD,
+ "Download",
+ loadBitmapScaled("mdi-cloud-download-outline.png", self.scale_factor),
+ "Download latest JLCPCB parts database",
+ )
+
+ self.settings_button = self.upper_toolbar.AddTool(
+ ID_SETTINGS,
+ "Settings",
+ loadBitmapScaled("mdi-cog-outline.png", self.scale_factor),
+ "Manage settings",
+ )
+
+ self.upper_toolbar.Realize()
+
+ self.Bind(wx.EVT_TOOL, self.generate_fabrication_data, self.generate_button)
+ self.Bind(wx.EVT_TOOL, self.manage_corrections, self.correction_button)
+ self.Bind(wx.EVT_TOOL, self.manage_mappings, self.mapping_button)
+ self.Bind(wx.EVT_TOOL, self.update_library, self.download_button)
+ self.Bind(wx.EVT_TOOL, self.manage_settings, self.settings_button)
+
+ # ---------------------------------------------------------------------
+ # ------------------ Right side toolbar List --------------------------
+ # ---------------------------------------------------------------------
+
+ self.right_toolbar = wx.ToolBar(
+ self,
+ wx.ID_ANY,
+ wx.DefaultPosition,
+ wx.Size(int(self.scale_factor * 128), -1),
+ wx.TB_VERTICAL | wx.TB_TEXT | wx.TB_NODIVIDER,
+ )
+
+ self.select_part_button = self.right_toolbar.AddTool(
+ ID_SELECT_PART,
+ "Assign LCSC number",
+ loadBitmapScaled(
+ "mdi-database-search-outline.png",
+ self.scale_factor,
+ ),
+ "Assign a LCSC number to a footprint",
+ )
+
+ self.remove_lcsc_number_button = self.right_toolbar.AddTool(
+ ID_REMOVE_LCSC_NUMBER,
+ "Remove LCSC number",
+ loadBitmapScaled(
+ "mdi-close-box-outline.png",
+ self.scale_factor,
+ ),
+ "Remove a LCSC number from a footprint",
+ )
+
+ self.select_alike_button = self.right_toolbar.AddCheckTool(
+ ID_SELECT_ALIKE,
+ "Auto-select alike",
+ loadBitmapScaled(
+ "mdi-checkbox-multiple-marked.png",
+ self.scale_factor,
+ ),
+ wx.NullBitmap,
+ "Automatically select footprints with the same value and footprint",
+ )
+
+ self.toggle_bom_pos_button = self.right_toolbar.AddTool(
+ ID_TOGGLE_BOM_POS,
+ "Toggle BOM & POS",
+ loadBitmapScaled(
+ "bom-pos.png",
+ self.scale_factor,
+ ),
+ "Toggle exclud from BOM and POS attribute",
+ )
+
+ self.toggle_bom_button = self.right_toolbar.AddTool(
+ ID_TOGGLE_BOM,
+ "Toggle BOM",
+ loadBitmapScaled(
+ "mdi-format-list-bulleted.png",
+ self.scale_factor,
+ ),
+ "Toggle exclude from BOM attribute",
+ )
+
+ self.toggle_pos_button = self.right_toolbar.AddTool(
+ ID_TOGGLE_POS,
+ "Toggle POS",
+ loadBitmapScaled(
+ "mdi-crosshairs-gps.png",
+ self.scale_factor,
+ ),
+ "Toggle exclude from POS attribute",
+ )
+
+ self.part_details_button = self.right_toolbar.AddTool(
+ ID_PART_DETAILS,
+ "Part details",
+ loadBitmapScaled(
+ "mdi-text-box-search-outline.png",
+ self.scale_factor,
+ ),
+ "Show details of an assigned LCSC part",
+ )
+
+ self.hide_bom_button = self.right_toolbar.AddCheckTool(
+ ID_HIDE_BOM,
+ "Hide excluded BOM",
+ loadBitmapScaled(
+ "mdi-eye-off-outline.png",
+ self.scale_factor,
+ ),
+ wx.NullBitmap,
+ "Hide excluded BOM parts",
+ )
+
+ self.hide_pos_button = self.right_toolbar.AddCheckTool(
+ ID_HIDE_POS,
+ "Hide excluded POS",
+ loadBitmapScaled(
+ "mdi-eye-off-outline.png",
+ self.scale_factor,
+ ),
+ wx.NullBitmap,
+ "Hide excluded POS parts",
+ )
+
+ self.save_all_button = self.right_toolbar.AddTool(
+ ID_SAVE_MAPPINGS,
+ "Save mappings",
+ loadBitmapScaled(
+ "mdi-content-save-settings.png",
+ self.scale_factor,
+ ),
+ "Save all mappings",
+ )
+
+ self.export_schematic_button = self.right_toolbar.AddTool(
+ ID_EXPORT_TO_SCHEMATIC,
+ "Export to schematic",
+ loadBitmapScaled(
+ "mdi-application-export.png",
+ self.scale_factor,
+ ),
+ "Export mappings to schematic",
+ )
+
+ self.Bind(wx.EVT_TOOL, self.select_part, self.select_part_button)
+ self.Bind(wx.EVT_TOOL, self.remove_lcsc_number, self.remove_lcsc_number_button)
+ self.Bind(wx.EVT_TOOL, self.toggle_select_alike, self.select_alike_button)
+ self.Bind(wx.EVT_TOOL, self.toggle_bom_pos, self.toggle_bom_pos_button)
+ self.Bind(wx.EVT_TOOL, self.toggle_bom, self.toggle_bom_button)
+ self.Bind(wx.EVT_TOOL, self.toggle_pos, self.toggle_pos_button)
+ self.Bind(wx.EVT_TOOL, self.get_part_details, self.part_details_button)
+ self.Bind(wx.EVT_TOOL, self.OnBomHide, self.hide_bom_button)
+ self.Bind(wx.EVT_TOOL, self.OnPosHide, self.hide_pos_button)
+ self.Bind(wx.EVT_TOOL, self.save_all_mappings, self.save_all_button)
+ self.Bind(wx.EVT_TOOL, self.export_to_schematic, self.export_schematic_button)
+
+ self.right_toolbar.ToggleTool(ID_SELECT_ALIKE, self.auto_select_alike)
+
+ self.right_toolbar.Realize()
+
+ # ---------------------------------------------------------------------
+ # ----------------------- Footprint List ------------------------------
+ # ---------------------------------------------------------------------
+
+ table_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600)))
+
+ table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL)
+ table_scroller.SetScrollRate(20, 20)
+
+ self.footprint_list = dv.DataViewCtrl(
+ table_scroller,
+ style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE,
+ )
+
+ reference = self.footprint_list.AppendTextColumn(
+ "Ref", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
+ )
+ value = self.footprint_list.AppendTextColumn(
+ "Value (Name)",
+ 1,
+ width=150,
+ mode=dv.DATAVIEW_CELL_INERT,
+ align=wx.ALIGN_CENTER,
+ )
+ footprint = self.footprint_list.AppendTextColumn(
+ "Footprint",
+ 2,
+ width=250,
+ mode=dv.DATAVIEW_CELL_INERT,
+ align=wx.ALIGN_CENTER,
+ )
+ params = self.footprint_list.AppendTextColumn(
+ "LCSC Params",
+ 11,
+ width=150,
+ mode=dv.DATAVIEW_CELL_INERT,
+ align=wx.ALIGN_CENTER,
+ )
+ lcsc = self.footprint_list.AppendTextColumn(
+ "LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
+ )
+ type = self.footprint_list.AppendTextColumn(
+ "Type", 4, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
+ )
+ stock = self.footprint_list.AppendTextColumn(
+ "Stock", 5, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER
+ )
+ bom = self.footprint_list.AppendIconTextColumn(
+ "BOM", 6, width=50, mode=dv.DATAVIEW_CELL_INERT
+ )
+ pos = self.footprint_list.AppendIconTextColumn(
+ "POS", 7, width=50, mode=dv.DATAVIEW_CELL_INERT
+ )
+ dnp = self.footprint_list.AppendIconTextColumn(
+ "POP", 8, width=50, mode=dv.DATAVIEW_CELL_INERT
+ )
+ correction = self.footprint_list.AppendTextColumn(
+ "Correction",
+ 9,
+ width=120,
+ mode=dv.DATAVIEW_CELL_INERT,
+ align=wx.ALIGN_CENTER,
+ )
+ side = self.footprint_list.AppendIconTextColumn(
+ "Side", 10, width=50, mode=dv.DATAVIEW_CELL_INERT
+ )
+
+ reference.SetSortable(True)
+ value.SetSortable(True)
+ footprint.SetSortable(True)
+ lcsc.SetSortable(True)
+ type.SetSortable(True)
+ stock.SetSortable(True)
+ bom.SetSortable(True)
+ pos.SetSortable(False)
+ dnp.SetSortable(True)
+ correction.SetSortable(True)
+ side.SetSortable(True)
+ params.SetSortable(True)
+
+ scrolled_sizer = wx.BoxSizer(wx.VERTICAL)
+ scrolled_sizer.Add(self.footprint_list, 1, wx.EXPAND)
+ table_scroller.SetSizer(scrolled_sizer)
+
+ table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5)
+
+ self.footprint_list.Bind(
+ dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected
+ )
+
+ self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part)
+
+ self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown)
+
+ table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5)
+ # ---------------------------------------------------------------------
+ # --------------------- Bottom Logbox and Gauge -----------------------
+ # ---------------------------------------------------------------------
+ self.logbox = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ wx.EmptyString,
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ wx.TE_MULTILINE | wx.TE_READONLY,
+ )
+ self.logbox.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 150)))
+ self.gauge = wx.Gauge(
+ self,
+ wx.ID_ANY,
+ 100,
+ wx.DefaultPosition,
+ HighResWxSize(self.window, wx.Size(100, -1)),
+ wx.GA_HORIZONTAL,
+ )
+ self.gauge.SetValue(0)
+ self.gauge.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 5)))
+
+ # ---------------------------------------------------------------------
+ # ---------------------- Main Layout Sizer ----------------------------
+ # ---------------------------------------------------------------------
+
+ self.SetSizeHints(HighResWxSize(self.window, wx.Size(1000, -1)), wx.DefaultSize)
+ layout = wx.BoxSizer(wx.VERTICAL)
+ layout.Add(self.upper_toolbar, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5)
+ layout.Add(self.logbox, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(self.gauge, 0, wx.ALL | wx.EXPAND, 5)
+
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Custom Events ------------------------------
+ # ---------------------------------------------------------------------
+
+ self.Bind(EVT_MESSAGE_EVENT, self.display_message)
+ self.Bind(EVT_ASSIGN_PARTS_EVENT, self.assign_parts)
+ self.Bind(EVT_POPULATE_FOOTPRINT_LIST_EVENT, self.populate_footprint_list)
+ self.Bind(EVT_UPDATE_SETTING, self.update_settings)
+
+ self.Bind(EVT_DOWNLOAD_STARTED_EVENT, self.download_started)
+ self.Bind(EVT_DOWNLOAD_PROGRESS_EVENT, self.download_progress)
+ self.Bind(EVT_DOWNLOAD_COMPLETED_EVENT, self.download_completed)
+
+ self.Bind(EVT_UNZIP_COMBINING_STARTED_EVENT, self.unzip_combining_started)
+ self.Bind(EVT_UNZIP_COMBINING_PROGRESS_EVENT, self.unzip_combining_progress)
+ self.Bind(EVT_UNZIP_EXTRACTING_STARTED_EVENT, self.unzip_extracting_started)
+ self.Bind(EVT_UNZIP_EXTRACTING_PROGRESS_EVENT, self.unzip_extracting_progress)
+ self.Bind(EVT_UNZIP_EXTRACTING_COMPLETED_EVENT, self.unzip_extracting_completed)
+
+ self.Bind(EVT_LOGBOX_APPEND_EVENT, self.logbox_append)
+
+ self.enable_part_specific_toolbar_buttons(False)
+
+ self.init_logger()
+ self.partlist_data_model = PartListDataModel(self.scale_factor)
+ self.footprint_list.AssociateModel(self.partlist_data_model)
+
+ self.init_data()
+
+ def init_data(self):
+ """Initialize the library and populate the main window."""
+ self.init_library()
+ self.init_fabrication()
+ if self.library.state == LibraryState.UPDATE_NEEDED:
+ self.library.update()
+ else:
+ self.init_store()
+ self.library.create_mapping_table()
+
+ self.logger.debug("kicad version: %s", kicad_pcbnew.GetBuildVersion())
+
+ def quit_dialog(self, *_):
+ """Destroy dialog on close."""
+ self.logger.info("quit_dialog()")
+ root = logging.getLogger()
+ with suppress(AttributeError):
+ root.removeHandler(self.logging_handler1)
+ with suppress(AttributeError):
+ root.removeHandler(self.logging_handler2)
+
+ self.Destroy()
+ self.EndModal(0)
+
+ def init_library(self):
+ """Initialize the parts library."""
+ self.library = Library(self)
+ meta = self.library.get_parts_db_info()
+ if meta is not None:
+ last_update = dt.fromisoformat(meta.last_update).strftime("%Y-%m-%d %H:%M")
+ self.SetTitle(
+ f"JLCPCB Tools [ {getVersion()} ] | Last database update: {last_update}",
+ )
+ self.logger.debug(
+ "JLCPCB version %s, last database update %s, part count %d, size (bytes) %d",
+ getVersion(),
+ meta.last_update,
+ meta.part_count,
+ meta.size,
+ )
+ else:
+ self.SetTitle(
+ f"JLCPCB Tools [ {getVersion()} ] | Last database update: No DB found",
+ )
+ self.logger.debug("JLCPCB version %s, no parts db info found", getVersion())
+
+ def init_store(self):
+ """Initialize the store of part assignments."""
+ self.store = Store(self, self.project_path, self.pcbnew.GetBoard())
+ if self.library.state == LibraryState.INITIALIZED:
+ self.populate_footprint_list()
+
+ def init_fabrication(self):
+ """Initialize the fabrication."""
+ self.fabrication = Fabrication(self, self.pcbnew.GetBoard())
+
+ def reset_gauge(self, *_):
+ """Initialize the gauge."""
+ self.gauge.SetRange(100)
+ self.gauge.SetValue(0)
+
+ def download_started(self, *_):
+ """Initialize the gauge."""
+ self.reset_gauge()
+
+ def download_progress(self, e):
+ """Update the gauge."""
+ self.gauge.SetValue(int(e.value))
+
+ def download_completed(self, *_):
+ """Populate the footprint list."""
+ self.populate_footprint_list()
+
+ def unzip_combining_started(self, *_):
+ """Initialize the gauge."""
+ self.reset_gauge()
+
+ def unzip_combining_progress(self, e):
+ """Update the gauge."""
+ self.gauge.SetValue(int(e.value))
+
+ def unzip_extracting_started(self, *_):
+ """Initialize the gauge."""
+ self.reset_gauge()
+
+ def unzip_extracting_progress(self, e):
+ """Update the gauge."""
+ self.gauge.SetValue(int(e.value))
+
+ def unzip_extracting_completed(self, *_):
+ """Update the gauge."""
+ self.reset_gauge()
+ self.init_data()
+
+ def assign_parts(self, e):
+ """Assign a selected LCSC number to parts."""
+ for reference in e.references:
+ self.store.set_lcsc(reference, e.lcsc)
+ self.store.set_stock(reference, int(e.stock))
+ board = self.pcbnew.GetBoard()
+ fp = board.FindFootprintByReference(reference)
+ set_lcsc_value(fp, e.lcsc)
+ params = params_for_part(self.library.get_part_details(e.lcsc))
+ self.partlist_data_model.set_lcsc(
+ reference, e.lcsc, e.type, e.stock, params
+ )
+
+ def display_message(self, e):
+ """Dispaly a message with the data from the event."""
+ styles = {
+ "info": wx.ICON_INFORMATION,
+ "warning": wx.ICON_WARNING,
+ "error": wx.ICON_ERROR,
+ }
+ wx.MessageBox(e.text, e.title, style=styles.get(e.style, wx.ICON_INFORMATION))
+
+ def get_correction(self, part: dict, corrections: list) -> str:
+ """Try to find correction data for a given part."""
+ # First check if the part name matches
+ for regex, rotation, offset in corrections:
+ if re.search(regex, str(part["reference"])):
+ return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (ref)"
+ # Then try to match by value
+ for regex, rotation, offset in corrections:
+ if re.search(regex, str(part["value"])):
+ return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (val)"
+ # If there was no match for the part name or value, check if the package matches
+ for regex, rotation, offset in corrections:
+ if re.search(regex, str(part["footprint"])):
+ return f"{str(rotation)}°, {str(offset[0])}/{str(offset[1])} (fpt)"
+ return "0°, 0.0/0.0"
+
+ def populate_footprint_list(self, *_):
+ """Populate list of footprints."""
+ if not self.store:
+ self.init_store()
+ self.partlist_data_model.RemoveAll()
+ details = {}
+ corrections = self.library.get_all_correction_data()
+ for part in self.store.read_all():
+ fp = self.pcbnew.GetBoard().FindFootprintByReference(part["reference"])
+ is_dnp = get_is_dnp(fp)
+ # Get part stock and type from library, skip if part number was already looked up before
+ if part["lcsc"] and part["lcsc"] not in details:
+ details[part["lcsc"]] = self.library.get_part_details(part["lcsc"])
+ # don't show the part if hide BOM is set
+ if self.hide_bom_parts and part["exclude_from_bom"]:
+ continue
+ # don't show the part if hide POS is set
+ if self.hide_pos_parts and part["exclude_from_pos"]:
+ continue
+ self.partlist_data_model.AddEntry(
+ [
+ part["reference"],
+ part["value"],
+ part["footprint"],
+ part["lcsc"],
+ details.get(part["lcsc"], {}).get("type", ""), # type
+ details.get(part["lcsc"], {}).get("stock", ""), # stock
+ part["exclude_from_bom"],
+ part["exclude_from_pos"],
+ int(is_dnp),
+ str(self.get_correction(part, corrections)),
+ str(fp.GetLayer()),
+ params_for_part(details.get(part["lcsc"], {})),
+ ]
+ )
+
+ def OnBomHide(self, *_):
+ """Hide all parts from the list that have 'in BOM' set to No."""
+ self.hide_bom_parts = not self.hide_bom_parts
+ if self.hide_bom_parts:
+ self.hide_bom_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "",
+ self.scale_factor,
+ )
+ )
+ self.hide_bom_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "mdi-eye-outline.png",
+ self.scale_factor,
+ )
+ )
+ self.hide_bom_button.SetLabel("Show excluded BOM")
+ else:
+ self.hide_bom_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "",
+ self.scale_factor,
+ )
+ )
+ self.hide_bom_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "mdi-eye-off-outline.png",
+ self.scale_factor,
+ )
+ )
+ self.hide_bom_button.SetLabel("Hide excluded BOM")
+ self.populate_footprint_list()
+
+ def OnPosHide(self, *_):
+ """Hide all parts from the list that have 'in pos' set to No."""
+ self.hide_pos_parts = not self.hide_pos_parts
+ if self.hide_pos_parts:
+ self.hide_pos_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "",
+ self.scale_factor,
+ )
+ )
+ self.hide_pos_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "mdi-eye-outline.png",
+ self.scale_factor,
+ )
+ )
+ self.hide_pos_button.SetLabel("Show excluded POS")
+ else:
+ self.hide_pos_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "",
+ self.scale_factor,
+ )
+ )
+ self.hide_pos_button.SetNormalBitmap(
+ loadBitmapScaled(
+ "mdi-eye-off-outline.png",
+ self.scale_factor,
+ )
+ )
+ self.hide_pos_button.SetLabel("Hide excluded POS")
+ self.populate_footprint_list()
+
+ def OnFootprintSelected(self, *_):
+ """Enable the toolbar buttons when a selection was made."""
+ if self.select_alike_in_progress:
+ return
+
+ self.enable_part_specific_toolbar_buttons(
+ self.footprint_list.GetSelectedItemsCount() > 0
+ )
+
+ if self.auto_select_alike and self.footprint_list.GetSelectedItemsCount() == 1:
+ self.select_alike_parts()
+
+ # clear the present selections
+ selection = self.pcbnew.GetCurrentSelection()
+ for selected in selection:
+ selected.ClearSelected()
+
+ # select all of the selected items in the footprint_list
+ if self.footprint_list.GetSelectedItemsCount() > 0:
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ fp = self.pcbnew.GetBoard().FindFootprintByReference(ref)
+ fp.SetSelected()
+ # cause pcbnew to refresh the board with the changes to the selected footprint(s)
+ self.pcbnew.Refresh()
+
+ def enable_part_specific_toolbar_buttons(self, state):
+ """Control the state of all the buttons that relate to parts in toolbar on the right side."""
+ for button in (
+ ID_SELECT_PART,
+ ID_REMOVE_LCSC_NUMBER,
+ ID_TOGGLE_BOM_POS,
+ ID_TOGGLE_BOM,
+ ID_TOGGLE_POS,
+ ID_PART_DETAILS,
+ ID_HIDE_BOM,
+ ID_HIDE_POS,
+ ):
+ self.right_toolbar.EnableTool(button, state)
+
+ def toggle_bom_pos(self, *_):
+ """Toggle the exclude from BOM/POS attribute of a footprint."""
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ board = self.pcbnew.GetBoard()
+ fp = board.FindFootprintByReference(ref)
+ bom = toggle_exclude_from_bom(fp)
+ pos = toggle_exclude_from_pos(fp)
+ self.store.set_bom(ref, int(bom))
+ self.store.set_pos(ref, int(pos))
+ self.partlist_data_model.toggle_bom_pos(item)
+
+ def toggle_bom(self, *_):
+ """Toggle the exclude from BOM attribute of a footprint."""
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ board = self.pcbnew.GetBoard()
+ fp = board.FindFootprintByReference(ref)
+ bom = toggle_exclude_from_bom(fp)
+ self.store.set_bom(ref, int(bom))
+ self.partlist_data_model.toggle_bom(item)
+
+ def toggle_pos(self, *_):
+ """Toggle the exclude from POS attribute of a footprint."""
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ board = self.pcbnew.GetBoard()
+ fp = board.FindFootprintByReference(ref)
+ pos = toggle_exclude_from_pos(fp)
+ self.store.set_pos(ref, int(pos))
+ self.partlist_data_model.toggle_pos(item)
+
+ def remove_lcsc_number(self, *_):
+ """Remove an assigned a LCSC Part number to a footprint."""
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ self.store.set_lcsc(ref, "")
+ self.store.set_stock(ref, None)
+ board = self.pcbnew.GetBoard()
+ fp = board.FindFootprintByReference(ref)
+ set_lcsc_value(fp, "")
+ self.partlist_data_model.remove_lcsc_number(item)
+
+ def select_alike_parts(self, *_):
+ """Select all alike parts, starting from a single selected part."""
+ if self.footprint_list.GetSelectedItemsCount() > 1:
+ self.logger.warning("Select only one component, please.")
+ return
+ selected_item = self.footprint_list.GetSelection()
+ self.select_alike_in_progress = True
+ try:
+ for alike_item in self.partlist_data_model.select_alike(selected_item):
+ if not self.footprint_list.IsSelected(alike_item):
+ self.footprint_list.Select(alike_item)
+ finally:
+ self.select_alike_in_progress = False
+
+ def toggle_select_alike(self, e):
+ """Toggle auto-selecting alike parts on selection."""
+ self.auto_select_alike = bool(e.IsChecked())
+ self.settings.setdefault("general", {})["select_alike_auto"] = (
+ self.auto_select_alike
+ )
+ self.save_settings()
+ if self.auto_select_alike and self.footprint_list.GetSelectedItemsCount() == 1:
+ self.select_alike_parts()
+
+ def get_part_details(self, *_):
+ """Fetch part details from LCSC and show them one after another each in a modal."""
+ for item in self.footprint_list.GetSelections():
+ if lcsc := self.partlist_data_model.get_lcsc(item):
+ self.show_part_details_dialog(lcsc)
+
+ def show_part_details_dialog(self, part):
+ """Show the part details modal dialog."""
+ wx.BeginBusyCursor()
+ try:
+ dialog = PartDetailsDialog(self, part)
+ dialog.ShowModal()
+ finally:
+ wx.EndBusyCursor()
+
+ def update_library(self, *_):
+ """Update the library from the JLCPCB CSV file."""
+ self.library.update()
+
+ def manage_corrections(self, *_):
+ """Manage corrections."""
+ CorrectionManagerDialog(self, "").ShowModal()
+
+ def manage_mappings(self, *_):
+ """Manage footprint mappings."""
+ PartMapperManagerDialog(self).ShowModal()
+
+ def manage_settings(self, *_):
+ """Manage settings."""
+ SettingsDialog(self).ShowModal()
+
+ def update_settings(self, e):
+ """Update the settings on change."""
+ if e.section not in self.settings:
+ self.settings[e.section] = {}
+ self.settings[e.section][e.setting] = e.value
+ self.save_settings()
+
+ # Refresh library configuration if relevant library settings changed
+ if e.section == "library" and e.setting in ["selected_library", "data_path"]:
+ self.library.refresh_library_config()
+
+ def logbox_append(self, e):
+ """Write text to the logbox."""
+ self.logbox.WriteText(e.msg)
+
+ def load_settings(self):
+ """Load settings from settings.json."""
+ with open(os.path.join(PLUGIN_PATH, "settings.json"), encoding="utf-8") as j:
+ self.settings = json.load(j)
+
+ gerber_settings = self.settings.setdefault("gerber", {})
+ if gerber_settings.get("force_drc", False) and not gerber_settings.get(
+ "fill_zones", True
+ ):
+ gerber_settings["fill_zones"] = True
+ self.save_settings()
+
+ def save_settings(self):
+ """Save settings to settings.json."""
+ with open(
+ os.path.join(PLUGIN_PATH, "settings.json"), "w", encoding="utf-8"
+ ) as j:
+ json.dump(self.settings, j)
+
+ def select_part(self, *_):
+ """Select a part from the library and assign it to the selected footprint(s)."""
+ selection = {}
+ for item in self.footprint_list.GetSelections():
+ ref = self.partlist_data_model.get_reference(item)
+ value = self.partlist_data_model.get_value(item)
+ footprint = self.partlist_data_model.get_footprint(item)
+ if ref.startswith("R"):
+ """ Auto remove alphabet unit if applicable """
+ if value.endswith("R") or value.endswith("r") or value.endswith("o"):
+ value = value[:-1]
+ value += "Ω"
+ m = re.search(r"_(\d+)_\d+Metric", footprint)
+ if m:
+ value += f" {m.group(1)}"
+ selection[ref] = value
+ PartSelectorDialog(self, selection).ShowModal()
+
+ def count_order_number_placeholders(self):
+ """Count the JLC order/serial number placeholders."""
+ count = 0
+ for drawing in self.pcbnew.GetBoard().GetDrawings():
+ if drawing.IsOnLayer(kicad_pcbnew.F_SilkS) or drawing.IsOnLayer(
+ kicad_pcbnew.B_SilkS
+ ):
+ if isinstance(drawing, kicad_pcbnew.PCB_TEXT):
+ if drawing.GetText().strip() == "JLCJLCJLCJLC":
+ self.logger.info(
+ "Found placeholder for order number at %.1f/%.1f.",
+ kicad_pcbnew.ToMM(drawing.GetCenter().x),
+ kicad_pcbnew.ToMM(drawing.GetCenter().y),
+ )
+ count += 1
+
+ if (
+ isinstance(drawing, kicad_pcbnew.PCB_SHAPE)
+ and drawing.GetShape() == kicad_pcbnew.S_RECT
+ and ((hasattr(drawing, "IsFilled") and drawing.IsFilled())
+ or (hasattr(drawing, "IsSolidFill") and drawing.IsSolidFill()))
+ ):
+ corners = drawing.GetRectCorners()
+
+ top_left_x = min([p.x for p in corners], default=0)
+ top_left_y = min([p.y for p in corners], default=0)
+ bottom_right_x = max([p.x for p in corners], default=0)
+ bottom_right_y = max([p.y for p in corners], default=0)
+ width = kicad_pcbnew.ToMM(bottom_right_x - top_left_x)
+ height = kicad_pcbnew.ToMM(bottom_right_y - top_left_y)
+
+ if (
+ (width == 5 and height == 5)
+ or (width == 8 and height == 8)
+ or (width == 10 and height == 10)
+ ):
+ self.logger.info(
+ "Found placeholder for 2D barcode (%dmm x %dmm) at %.1f/%.1f.",
+ width,
+ height,
+ kicad_pcbnew.ToMM(drawing.GetCenter().x),
+ kicad_pcbnew.ToMM(drawing.GetCenter().y),
+ )
+ count += 1
+
+ if (width == 10 and height == 2) or (width == 2 and height == 10):
+ self.logger.info(
+ "Found placeholder for serial number at %.1f/%.1f.",
+ kicad_pcbnew.ToMM(drawing.GetCenter().x),
+ kicad_pcbnew.ToMM(drawing.GetCenter().y),
+ )
+ count += 1
+
+ return count
+
+ def generate_fabrication_data(self, *_):
+ """Generate fabrication data."""
+ warnings = self.fabrication.get_part_consistency_warnings()
+ if warnings:
+ result = wx.MessageBox(
+ "There are items with identical LCSC number but different values in the list:\n"
+ + warnings
+ + "Continue?",
+ "Plausibility check",
+ wx.OK | wx.CANCEL | wx.CENTER,
+ )
+ if result == wx.CANCEL:
+ return
+
+ if self.settings.get("general", {}).get("order_number"):
+ count = self.count_order_number_placeholders()
+ if count == 0:
+ result = wx.MessageBox(
+ "JLC order/serial number placeholder not present! Continue?",
+ "JLC order/serial number placeholder",
+ wx.OK | wx.CANCEL | wx.CENTER,
+ )
+ if result == wx.CANCEL:
+ return
+ elif count > 1:
+ result = wx.MessageBox(
+ "Multiple order/serial number placeholders present! Continue?",
+ "JLC order/serial number placeholder",
+ wx.OK | wx.CANCEL | wx.CENTER,
+ )
+ if result == wx.CANCEL:
+ return
+
+ self.fabrication.fill_zones()
+
+ if not self.run_drc_before_gerber_export():
+ return
+
+ layer_selection = self.layer_selection.GetSelection()
+ number = re.search(r"\d+", self.layer_selection.GetString(layer_selection))
+ if number:
+ layer_count = int(number.group(0))
+ else:
+ layer_count = None
+ self.fabrication.generate_geber(layer_count)
+ self.fabrication.generate_excellon()
+ self.fabrication.zip_gerber_excellon()
+ self.fabrication.generate_cpl()
+ self.fabrication.generate_bom()
+
+ def save_board_for_cli(self):
+ """Save the current board so CLI-based checks operate on latest board state."""
+ board = self.pcbnew.GetBoard()
+ board_filename = board.GetFileName()
+ if not board_filename:
+ raise RuntimeError("Board must be saved before running DRC checks")
+
+ if hasattr(board, "Save"):
+ try:
+ board.Save(board_filename)
+ except TypeError:
+ board.Save()
+ return
+
+ if hasattr(self.pcbnew, "SaveBoard"):
+ self.pcbnew.SaveBoard(board_filename, board)
+ return
+
+ raise RuntimeError("Unable to save board using current KiCad API")
+
+ def run_drc_before_gerber_export(self):
+ """Run optional DRC via kicad-cli and prompt when violations exist."""
+ if not self.settings.get("gerber", {}).get("force_drc", False):
+ return True
+
+ board_filename = self.pcbnew.GetBoard().GetFileName()
+ if not board_filename:
+ wx.MessageBox(
+ "Board must be saved before DRC can be run.",
+ "DRC check",
+ style=wx.ICON_ERROR,
+ )
+ return False
+
+ try:
+ self.save_board_for_cli()
+ except Exception as exc:
+ wx.MessageBox(
+ f"Failed to save board before DRC: {exc}",
+ "DRC check",
+ style=wx.ICON_ERROR,
+ )
+ return False
+
+ try:
+ drc_counter = DRCViolationCounter(
+ pcbnew_module=self.pcbnew,
+ working_dir=self.project_path,
+ )
+ violation_count = drc_counter.get_violation_count(board_filename)
+
+ if violation_count > 0:
+ dialog = wx.MessageDialog(
+ self,
+ f"DRC found {violation_count} error violation(s).\n\n"
+ "Resolve or exclude DRC errors before manufacturing whenever possible.",
+ "DRC violations found",
+ wx.YES_NO
+ | wx.NO_DEFAULT
+ | wx.ICON_WARNING
+ | wx.CENTER,
+ )
+ dialog.SetYesNoLabels("Continue Anyway", "Cancel Export")
+ result = dialog.ShowModal()
+ dialog.Destroy()
+ if result != wx.ID_YES:
+ return False
+
+ return True
+ except Exception as exc:
+ self.logger.exception("Unexpected error while running forced DRC")
+ wx.MessageBox(
+ f"Unexpected error while running DRC: {exc}",
+ "DRC check",
+ style=wx.ICON_ERROR,
+ )
+ return False
+
+ def copy_part_lcsc(self, *_):
+ """Fetch part details from LCSC and show them in a modal."""
+ for item in self.footprint_list.GetSelections():
+ if lcsc := self.partlist_data_model.get_lcsc(item):
+ if wx.TheClipboard.Open():
+ wx.TheClipboard.SetData(wx.TextDataObject(lcsc))
+ wx.TheClipboard.Close()
+
+ def paste_part_lcsc(self, *_):
+ """Paste a lcsc number from the clipboard to the current part."""
+ text_data = wx.TextDataObject()
+ if wx.TheClipboard.Open():
+ success = wx.TheClipboard.GetData(text_data)
+ wx.TheClipboard.Close()
+ if success:
+ if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "":
+ for item in self.footprint_list.GetSelections():
+ details = self.library.get_part_details(lcsc)
+ params = params_for_part(details)
+ reference = self.partlist_data_model.get_reference(item)
+ self.partlist_data_model.set_lcsc(
+ reference, lcsc, details["type"], details["stock"], params
+ )
+ self.store.set_lcsc(reference, lcsc)
+
+ def add_correction(self, e):
+ """Add part correction for the current part."""
+ for item in self.footprint_list.GetSelections():
+ if e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE:
+ if reference := self.partlist_data_model.get_reference(item):
+ CorrectionManagerDialog(
+ self, "^" + re.escape(reference) + "$"
+ ).ShowModal()
+ elif e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE:
+ if footprint := self.partlist_data_model.get_footprint(item):
+ CorrectionManagerDialog(
+ self, "^" + re.escape(footprint)
+ ).ShowModal()
+ elif e.GetId() == ID_CONTEXT_MENU_ADD_ROT_BY_NAME:
+ if value := self.partlist_data_model.get_value(item):
+ CorrectionManagerDialog(self, re.escape(value)).ShowModal()
+
+ def save_all_mappings(self, *_):
+ """Save all mappings."""
+ for item in self.partlist_data_model.get_all():
+ value = item[1]
+ footprint = item[2]
+ lcsc = item[3]
+ if footprint != "" and value != "" and lcsc != "":
+ if self.library.get_mapping_data(footprint, value):
+ self.library.update_mapping_data(footprint, value, lcsc)
+ else:
+ self.library.insert_mapping_data(footprint, value, lcsc)
+ self.logger.info("All mappings saved")
+
+ def export_to_schematic(self, *_):
+ """Dialog to select schematics."""
+ with wx.FileDialog(
+ self,
+ "Select Schematics",
+ self.project_path,
+ self.schematic_name,
+ "KiCad V6 Schematics (*.kicad_sch)|*.kicad_sch",
+ wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE,
+ ) as openFileDialog:
+ if openFileDialog.ShowModal() == wx.CANCEL:
+ return
+ paths = openFileDialog.GetPaths()
+ SchematicExport(self).load_schematic(paths)
+
+ def add_foot_mapping(self, *_):
+ """Add a footprint mapping."""
+ for item in self.footprint_list.GetSelections():
+ footprint = self.partlist_data_model.get_footprint(item)
+ value = self.partlist_data_model.get_value(item)
+ lcsc = self.partlist_data_model.get_lcsc(item)
+ if footprint != "" and value != "" and lcsc != "":
+ if self.library.get_mapping_data(footprint, value):
+ self.library.update_mapping_data(footprint, value, lcsc)
+ else:
+ self.library.insert_mapping_data(footprint, value, lcsc)
+
+ def search_foot_mapping(self, *_):
+ """Search for a footprint mapping."""
+ for item in self.footprint_list.GetSelections():
+ reference = self.partlist_data_model.get_reference(item)
+ footprint = self.partlist_data_model.get_footprint(item)
+ value = self.partlist_data_model.get_value(item)
+ if footprint != "" and value != "":
+ if self.library.get_mapping_data(footprint, value):
+ lcsc = self.library.get_mapping_data(footprint, value)[2]
+ self.store.set_lcsc(reference, lcsc)
+ self.logger.info("Found %s", lcsc)
+ details = self.library.get_part_details(lcsc)
+ params = params_for_part(self.library.get_part_details(lcsc))
+ self.partlist_data_model.set_lcsc(
+ reference, lcsc, details["type"], details["stock"], params
+ )
+
+ def sanitize_lcsc(self, lcsc_PN):
+ """Sanitize a given LCSC number using a regex."""
+ m = re.search("C\\d+", lcsc_PN, re.IGNORECASE)
+ if m:
+ return m.group(0)
+ return ""
+
+ def OnRightDown(self, *_):
+ """Right click context menu for action on parts table."""
+ right_click_menu = wx.Menu()
+
+ copy_lcsc = wx.MenuItem(
+ right_click_menu, ID_CONTEXT_MENU_COPY_LCSC, "Copy LCSC"
+ )
+ right_click_menu.Append(copy_lcsc)
+ right_click_menu.Bind(wx.EVT_MENU, self.copy_part_lcsc, copy_lcsc)
+
+ paste_lcsc = wx.MenuItem(
+ right_click_menu, ID_CONTEXT_MENU_PASTE_LCSC, "Paste LCSC"
+ )
+ right_click_menu.Append(paste_lcsc)
+ right_click_menu.Bind(wx.EVT_MENU, self.paste_part_lcsc, paste_lcsc)
+
+ correction_by_reference = wx.MenuItem(
+ right_click_menu,
+ ID_CONTEXT_MENU_ADD_ROT_BY_REFERENCE,
+ "Add Correction by reference",
+ )
+ right_click_menu.Append(correction_by_reference)
+ right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_reference)
+
+ correction_by_package = wx.MenuItem(
+ right_click_menu,
+ ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE,
+ "Add Correction by package",
+ )
+ right_click_menu.Append(correction_by_package)
+ right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_package)
+
+ correction_by_name = wx.MenuItem(
+ right_click_menu, ID_CONTEXT_MENU_ADD_ROT_BY_NAME, "Add Correction by name"
+ )
+ right_click_menu.Append(correction_by_name)
+ right_click_menu.Bind(wx.EVT_MENU, self.add_correction, correction_by_name)
+
+ find_mapping = wx.MenuItem(
+ right_click_menu, ID_CONTEXT_MENU_FIND_MAPPING, "Find LCSC from Mappings"
+ )
+ right_click_menu.Append(find_mapping)
+ right_click_menu.Bind(wx.EVT_MENU, self.search_foot_mapping, find_mapping)
+
+ add_mapping = wx.MenuItem(
+ right_click_menu, ID_CONTEXT_MENU_ADD_MAPPING, "Add Footprint Mapping"
+ )
+ right_click_menu.Append(add_mapping)
+ right_click_menu.Bind(wx.EVT_MENU, self.add_foot_mapping, add_mapping)
+
+ self.footprint_list.PopupMenu(right_click_menu)
+ right_click_menu.Destroy() # destroy to avoid memory leak
+
+ def init_logger(self):
+ """Initialize logger to log into textbox."""
+ root = logging.getLogger()
+ # Clear any existing handlers that might be problematic
+ root.handlers.clear()
+ root.setLevel(logging.DEBUG)
+
+ formatter = logging.Formatter(
+ "%(asctime)s - %(levelname)s - %(funcName)s - %(message)s",
+ datefmt="%Y.%m.%d %H:%M:%S",
+ )
+ # Only add stderr handler if stderr is available
+ if sys.stderr is not None:
+ self.logging_handler1 = logging.StreamHandler(sys.stderr)
+ self.logging_handler1.setLevel(logging.DEBUG)
+ self.logging_handler1.setFormatter(formatter)
+ root.addHandler(self.logging_handler1)
+
+ self.logging_handler2 = LogBoxHandler(self)
+ self.logging_handler2.setLevel(logging.DEBUG)
+ self.logging_handler2.setFormatter(formatter)
+ root.addHandler(self.logging_handler2)
+
+ self.logger = logging.getLogger(__name__)
+
+ def __del__(self):
+ """Cleanup."""
+ pass
+
+
+class LogBoxHandler(logging.StreamHandler):
+ """Logging class for the logging textbox at th ebottom of the mainwindow."""
+
+ def __init__(self, event_destination):
+ logging.StreamHandler.__init__(self)
+ self.event_destination = event_destination
+
+ def emit(self, record):
+ """Marshal the event over to the main thread."""
+ msg = self.format(record)
+ wx.QueueEvent(self.event_destination, LogboxAppendEvent(msg=f"{msg}\n"))
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py b/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py
new file mode 100644
index 00000000..9dbe37a3
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/partdetails.py
@@ -0,0 +1,315 @@
+"""Contains the part details modal dialog."""
+
+import logging
+from pathlib import Path
+import webbrowser
+
+import wx # pylint: disable=import-error
+import wx.dataview # pylint: disable=import-error
+
+from .events import MessageEvent
+from .helpers import HighResWxSize, loadBitmapScaled
+from .lcsc_api import LCSC_API
+
+
+class PartDetailsDialog(wx.Dialog):
+ """The part details dialog class."""
+
+ def __init__(self, parent, part):
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title="JLCPCB Part Details",
+ pos=wx.DefaultPosition,
+ size=HighResWxSize(parent.window, wx.Size(1000, 800)),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP,
+ )
+
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+ self.part = part
+ self.datasheet_path = Path(self.parent.project_path) / "datasheets"
+ self.lcsc_api = LCSC_API()
+ self.pdfurl = ""
+ self.pageurl = ""
+ self.picture = None
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+
+ # ---------------------------------------------------------------------
+ # ----------------------- Properties List -----------------------------
+ # ---------------------------------------------------------------------
+ self.data_list = wx.dataview.DataViewListCtrl(
+ self,
+ wx.ID_ANY,
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ style=wx.dataview.DV_SINGLE,
+ )
+ self.property = self.data_list.AppendTextColumn(
+ "Property",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(self.parent.scale_factor * 200),
+ align=wx.ALIGN_LEFT,
+ )
+ self.value = self.data_list.AppendTextColumn(
+ "Value",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(self.parent.scale_factor * 300),
+ align=wx.ALIGN_LEFT,
+ )
+
+ # ---------------------------------------------------------------------
+ # ------------------------- Right side ------------------------------
+ # ---------------------------------------------------------------------
+ self.image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("placeholder.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 200)),
+ 0,
+ )
+ self.savepdf_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Download Datasheet",
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.openpdf_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Open Datasheet",
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.openpage_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Open LCSC page",
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.savepdf_button.Bind(wx.EVT_BUTTON, self.savepdf)
+ self.openpdf_button.Bind(wx.EVT_BUTTON, self.openpdf)
+ self.openpage_button.Bind(wx.EVT_BUTTON, self.openpage)
+
+ self.savepdf_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-cloud-download-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.savepdf_button.SetBitmapMargins((2, 0))
+
+ self.openpdf_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-file-document-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.openpdf_button.SetBitmapMargins((2, 0))
+
+ self.openpage_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-earth.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.openpage_button.SetBitmapMargins((2, 0))
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Layout and Sizers --------------------------
+ # ---------------------------------------------------------------------
+
+ right_side_layout = wx.BoxSizer(wx.VERTICAL)
+ right_side_layout.Add(self.image, 10, wx.ALL | wx.EXPAND, 5)
+ right_side_layout.AddStretchSpacer(50)
+ right_side_layout.Add(self.savepdf_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5)
+ right_side_layout.Add(self.openpdf_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5)
+ right_side_layout.Add(
+ self.openpage_button, 5, wx.LEFT | wx.RIGHT | wx.EXPAND, 5
+ )
+ layout = wx.BoxSizer(wx.HORIZONTAL)
+ layout.Add(self.data_list, 30, wx.ALL | wx.EXPAND, 5)
+ layout.Add(right_side_layout, 10, wx.ALL | wx.EXPAND, 5)
+
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+
+ self.get_part_data()
+
+ def quit_dialog(self, *_):
+ """Close the dialog."""
+ self.Destroy()
+ self.EndModal(0)
+
+ def savepdf(self, *_):
+ """Download a datasheet from The LCSC API."""
+ if self.pdfurl is not None:
+ filename = self.pdfurl.rsplit("/", maxsplit=1)[1]
+ self.logger.info("Save datasheet %s to %s", filename, self.datasheet_path)
+ self.datasheet_path.mkdir(parents=True, exist_ok=True)
+ result = self.lcsc_api.download_datasheet(
+ self.pdfurl, self.datasheet_path / filename
+ )
+ title = "Success" if result["success"] else "Error"
+ style = "info" if result["success"] else "error"
+ resultMsg = result["msg"]
+ else:
+ title = "Error"
+ style = "error"
+ resultMsg = "Undefined URL for datasheet download"
+ wx.PostEvent(
+ self.parent,
+ MessageEvent(
+ title=title,
+ text=resultMsg,
+ style=style,
+ ),
+ )
+
+ def openpdf(self, *_):
+ """Open the linked datasheet PDF on button click."""
+ self.logger.info("opening %s", str(self.pdfurl))
+ webbrowser.open(str(self.pdfurl))
+
+ def openpage(self, *_):
+ """Open the linked LCSC page for the part on button click."""
+ self.logger.info("opening LCSC page for %s", str(self.part))
+ webbrowser.open(str(self.pageurl))
+
+ def get_scaled_bitmap(self, url, width, height):
+ """Download a picture from a URL and convert it into a wx Bitmap."""
+ io_bytes = self.lcsc_api.download_bitmap(url)
+ image = wx.Image(io_bytes)
+ image = image.Scale(width, height, wx.IMAGE_QUALITY_HIGH)
+ result = wx.Bitmap(image)
+ return result
+
+ def get_part_data(self):
+ """Get part data from JLCPCB API and parse it into the table, set picture and PDF link."""
+ result = self.lcsc_api.get_part_data(self.part)
+ if not result["success"]:
+ self.report_part_data_fetch_error(result["msg"])
+ return
+
+ parameters = {
+ "componentCode": "Component Code",
+ "firstTypeNameEn": "Primary Category",
+ "secondTypeNameEn": "Secondary Category",
+ "componentBrandEn": "Brand",
+ "componentName": "Full Name",
+ "componentDesignator": "Designator",
+ "componentModelEn": "Model",
+ "componentSpecificationEn": "Specification",
+ "assemblyProcess": "Assembly Process",
+ "describe": "Description",
+ "matchedPartDetail": "Details",
+ "stockCount": "Stock",
+ "leastNumber": "Minimal Quantity",
+ "leastNumberPrice": "Minimum price",
+ }
+ parttype = result["data"].get("data", {}).get("componentLibraryType")
+ if parttype and parttype == "base":
+ self.data_list.AppendItem(["Type", "Basic"])
+ elif parttype and parttype == "expand":
+ self.data_list.AppendItem(["Type", "Extended"])
+ for k, v in parameters.items():
+ val = result["data"].get("data", {}).get(k)
+ if val:
+ self.data_list.AppendItem([v, str(val)])
+ prices = result["data"].get("data", {}).get("jlcPrices", [])
+ if prices:
+ for price in prices:
+ start = price.get("startNumber")
+ end = price.get("endNumber")
+ if end == -1:
+ self.data_list.AppendItem(
+ [
+ f"JLC Price for >{start}",
+ str(price.get("productPrice")),
+ ]
+ )
+ else:
+ self.data_list.AppendItem(
+ [
+ f"JLC Price for {start}-{end}",
+ str(price.get("productPrice")),
+ ]
+ )
+ prices = result["data"].get("data", {}).get("prices", [])
+ if prices:
+ for price in prices:
+ start = price.get("startNumber")
+ end = price.get("endNumber")
+ if end == -1:
+ self.data_list.AppendItem(
+ [
+ f"LCSC Price for >{start}",
+ str(price.get("productPrice")),
+ ]
+ )
+ else:
+ self.data_list.AppendItem(
+ [
+ f"LCSC Price for {start}-{end}",
+ str(price.get("productPrice")),
+ ]
+ )
+ for attribute in result["data"].get("data", {}).get("attributes", []):
+ self.data_list.AppendItem(
+ [
+ attribute.get("attribute_name_en"),
+ str(attribute.get("attribute_value_name")),
+ ]
+ )
+ picture = result["data"].get("data", {}).get("minImage")
+ if picture:
+ # get the full resolution image instead of the thumbnail
+ picture = picture.replace("96x96", "900x900")
+ else:
+ imageId = result["data"].get("data", {}).get("productBigImageAccessId")
+ picture = (
+ f"https://jlcpcb.com/api/file/downloadByFileSystemAccessId/{imageId}"
+ )
+ self.image.SetBitmap(
+ self.get_scaled_bitmap(
+ picture,
+ int(200 * self.parent.scale_factor),
+ int(200 * self.parent.scale_factor),
+ )
+ )
+
+ self.pdfurl = result["data"].get("data", {}).get("dataManualUrl")
+ self.pageurl = result["data"].get("data", {}).get("lcscGoodsUrl")
+
+ def report_part_data_fetch_error(self, reason):
+ """Spawn a message box with an erro message if the fetch fails."""
+ wx.MessageBox(
+ f"Failed to download part detail from the JLCPCB API ({reason})\r\n"
+ f"We looked for a part named:\r\n{self.part}\r\n[hint: did you fill in the LCSC field correctly?]",
+ "Error",
+ style=wx.ICON_ERROR,
+ )
+ self.EndModal(-1)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py b/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py
new file mode 100644
index 00000000..377ae282
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/partmapper.py
@@ -0,0 +1,258 @@
+"""Contains the part mapper."""
+
+import csv
+import logging
+import os
+
+import wx # pylint: disable=import-error
+import wx.dataview # pylint: disable=import-error
+
+from .helpers import HighResWxSize, loadBitmapScaled
+
+
+class PartMapperManagerDialog(wx.Dialog):
+ """Dialog for managing part mappings."""
+
+ def __init__(self, parent):
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title="Footprint Mapper",
+ pos=wx.DefaultPosition,
+ size=HighResWxSize(parent.window, wx.Size(800, 800)),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
+ )
+
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+
+ self.parent.library.create_mapping_table()
+
+ # ---------------------------------------------------------------------
+ # ------------------------- Mapping list ----------------------------
+ # ---------------------------------------------------------------------
+
+ self.mapping_list = wx.dataview.DataViewListCtrl(
+ self,
+ wx.ID_ANY,
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ style=wx.dataview.DV_MULTIPLE,
+ )
+
+ self.mapping_list.AppendTextColumn(
+ "Footprint",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 150),
+ align=wx.ALIGN_LEFT,
+ )
+ self.mapping_list.AppendTextColumn(
+ "Value",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 100),
+ align=wx.ALIGN_LEFT,
+ )
+ self.mapping_list.AppendTextColumn(
+ "LCSC Part",
+ mode=wx.dataview.DATAVIEW_CELL_INERT,
+ width=int(parent.scale_factor * 100),
+ align=wx.ALIGN_LEFT,
+ )
+
+ self.mapping_list.SetMinSize(HighResWxSize(parent.window, wx.Size(600, 500)))
+
+ self.mapping_list.Bind(
+ wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.on_mapping_selected
+ )
+
+ table_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400)))
+ table_sizer.Add(self.mapping_list, 20, wx.ALL | wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Right side toolbar -------------------------
+ # ---------------------------------------------------------------------
+
+ self.delete_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Delete",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.import_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Import",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.export_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Export",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+
+ self.delete_button.Bind(wx.EVT_BUTTON, self.delete_mapping)
+ self.import_button.Bind(wx.EVT_BUTTON, self.import_mappings_dialog)
+ self.export_button.Bind(wx.EVT_BUTTON, self.export_mappings_dialog)
+
+ self.delete_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-trash-can-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.delete_button.SetBitmapMargins((2, 0))
+
+ self.import_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-database-import-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.import_button.SetBitmapMargins((2, 0))
+
+ self.export_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-database-export-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.export_button.SetBitmapMargins((2, 0))
+
+ tool_sizer = wx.BoxSizer(wx.VERTICAL)
+ tool_sizer.Add(self.delete_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.import_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.export_button, 0, wx.ALL, 5)
+ table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------------ Sizers ------------------------------
+ # ---------------------------------------------------------------------
+
+ layout = wx.BoxSizer(wx.VERTICAL)
+ layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5)
+
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+ self.enable_toolbar_buttons(False)
+ self.populate_mapping_list()
+
+ def quit_dialog(self, *_):
+ """Close this dialog."""
+ self.Destroy()
+ self.EndModal(0)
+
+ def enable_toolbar_buttons(self, state):
+ """Control the state of all the buttons in toolbar on the right side."""
+ for b in [
+ self.delete_button,
+ ]:
+ b.Enable(bool(state))
+
+ def populate_mapping_list(self):
+ """Populate the list with the result of the search."""
+ self.mapping_list.DeleteAllItems()
+
+ if self.parent.library.get_all_mapping_data() is None:
+ self.logger.info("empty")
+ return
+
+ for mapping in self.parent.library.get_all_mapping_data():
+ self.mapping_list.AppendItem([str(m) for m in mapping])
+
+ def delete_mapping(self, *_):
+ """Delete a mapping from the database."""
+ for item in self.mapping_list.GetSelections():
+ row = self.mapping_list.ItemToRow(item)
+ if row == -1:
+ return
+ footprint = self.mapping_list.GetTextValue(row, 0)
+ value = self.mapping_list.GetTextValue(row, 1)
+ self.parent.library.delete_mapping_data(footprint, value)
+ self.populate_mapping_list()
+
+ def on_mapping_selected(self, *_):
+ """Enable the toolbar buttons when a selection was made."""
+ if self.mapping_list.GetSelectedItemsCount() > 0:
+ self.enable_toolbar_buttons(True)
+ else:
+ self.enable_toolbar_buttons(False)
+
+ def import_mappings_dialog(self, *_):
+ """Dialog to import mappings from a CSV file."""
+ with wx.FileDialog(
+ self,
+ "Import Mapping CSV",
+ "",
+ "",
+ "CSV files (*.csv)|*.csv",
+ wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
+ ) as importFileDialog:
+ if importFileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = importFileDialog.GetPath()
+ self._import_mappings(path)
+
+ def export_mappings_dialog(self, *_):
+ """Dialog to export mappings to a CSV file."""
+ with wx.FileDialog(
+ self,
+ "Export Mapping CSV",
+ "",
+ "mapping",
+ "CSV files (*.csv)|*.csv",
+ wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
+ ) as exportFileDialog:
+ if exportFileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = exportFileDialog.GetPath()
+ self._export_mappings(path)
+
+ def _import_mappings(self, path):
+ """Import logic for Mappings."""
+ if os.path.isfile(path):
+ with open(path, encoding="utf-8") as f:
+ csvreader = csv.DictReader(f, fieldnames=("footprint", "value", "lcsc"))
+ next(csvreader)
+ for row in csvreader:
+ if self.parent.library.get_mapping_data(
+ row["footprint"], row["value"]
+ ):
+ self.parent.library.update_mapping_data(
+ row["footprint"], row["value"], row["lcsc"]
+ )
+ else:
+ self.parent.library.insert_mapping_data(
+ row["footprint"], row["value"], row["lcsc"]
+ )
+ self.populate_mapping_list()
+
+ def _export_mappings(self, path):
+ """Export logic for mappings."""
+ with open(path, "w", newline="", encoding="utf-8") as f:
+ csvwriter = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL)
+ csvwriter.writerow(["Footprint", "Part Value", "LCSC Part"])
+ for m in self.parent.library.get_all_mapping_data():
+ csvwriter.writerow([m[0], m[1], m[2]])
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector.py
new file mode 100644
index 00000000..d0000436
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector.py
@@ -0,0 +1,765 @@
+"""Contains the part selector modal window."""
+
+import logging
+import time
+
+import wx # pylint: disable=import-error
+import wx.dataview as dv # pylint: disable=import-error
+
+from .datamodel import PartSelectorDataModel
+from .derive_params import params_for_part # pylint: disable=import-error
+from .events import AssignPartsEvent, UpdateSetting
+from .helpers import HighResWxSize, loadBitmapScaled
+from .partdetails import PartDetailsDialog
+from .partselector_columns import DB_FIELDS, PARAMS_COLUMN_KEY, PARTSELECTOR_COLUMNS
+from .partselector_highlight import HighlightedTextRenderer
+
+HIGHLIGHTED_COLUMN_KEYS = {
+ "lcsc",
+ "mfr_number",
+ "package",
+ "params",
+ "mfr",
+ "description",
+}
+
+
+def _format_duration(seconds: float) -> str:
+ """Format a duration as seconds or milliseconds for UI labels."""
+ return f"{seconds:.2f}s" if seconds > 1 else f"{seconds * 1000.0:.0f}ms"
+
+
+class PartSelectorDialog(wx.Dialog):
+ """The part selector window."""
+
+ def __init__(self, parent, parts):
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title="JLCPCB Library",
+ pos=wx.DefaultPosition,
+ size=HighResWxSize(parent.window, wx.Size(1400, 800)),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
+ )
+
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+ self.parts = parts
+ lcsc_selection = self.get_existing_selection(parts)
+
+ self.search_timer = wx.Timer(self)
+ self.Bind(wx.EVT_TIMER, self.search)
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+
+ # ---------------------------------------------------------------------
+ # --------------------------- Search bar ------------------------------
+ # ---------------------------------------------------------------------
+
+ keyword_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Keywords",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ style=wx.ALIGN_RIGHT,
+ )
+ self.keyword = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ lcsc_selection,
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(800, 24)),
+ wx.TE_PROCESS_ENTER,
+ )
+ self.keyword.SetHint("e.g. 10k 0603")
+
+ self.ohm_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Ω",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(20, -1)),
+ 0,
+ )
+ self.ohm_button.SetToolTip("Append the Ω symbol to the search string")
+
+ self.micro_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "µ",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(20, -1)),
+ 0,
+ )
+ self.micro_button.SetToolTip("Append the µ symbol to the search string")
+
+ manufacturer_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Manufacturer",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.manufacturer = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ wx.TE_PROCESS_ENTER,
+ )
+ self.manufacturer.SetHint("e.g. Vishay")
+
+ package_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Package",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.package = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ wx.TE_PROCESS_ENTER,
+ )
+ self.package.SetHint("e.g. 0603")
+
+ category_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Category",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.category = wx.ComboBox(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ choices=parent.library.categories,
+ style=wx.CB_READONLY,
+ )
+ self.category.SetHint("e.g. Resistors")
+
+ part_no_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Part number",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.part_no = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ wx.TE_PROCESS_ENTER,
+ )
+ self.part_no.SetHint("e.g. DS2411")
+
+ solder_joints_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Solder joints",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.solder_joints = wx.TextCtrl(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ wx.TE_PROCESS_ENTER,
+ )
+ self.solder_joints.SetHint("e.g. 2")
+
+ subcategory_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Subcategory",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.subcategory = wx.ComboBox(
+ self,
+ wx.ID_ANY,
+ "",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ style=wx.CB_READONLY,
+ )
+ self.subcategory.SetHint("e.g. Variable Resistors")
+
+ basic_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Include basic parts",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.basic_checkbox = wx.CheckBox(
+ self,
+ wx.ID_ANY,
+ "Basic",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ 0,
+ name="basic",
+ )
+ preferred_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Include preferred parts",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.preferred_checkbox = wx.CheckBox(
+ self,
+ wx.ID_ANY,
+ "Preferred",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ 0,
+ name="preferred",
+ )
+ extended_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Include extended parts",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.extended_checkbox = wx.CheckBox(
+ self,
+ wx.ID_ANY,
+ "Extended",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ 0,
+ name="extended",
+ )
+ stock_label = wx.StaticText(
+ self,
+ wx.ID_ANY,
+ "Only show parts in stock",
+ size=HighResWxSize(parent.window, wx.Size(150, 15)),
+ )
+ self.assert_stock_checkbox = wx.CheckBox(
+ self,
+ wx.ID_ANY,
+ "in Stock",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(200, 24)),
+ 0,
+ name="stock",
+ )
+
+ self.basic_checkbox.SetValue(
+ self.parent.settings.get("partselector", {}).get("basic", True)
+ )
+ self.extended_checkbox.SetValue(
+ self.parent.settings.get("partselector", {}).get("extended", True)
+ )
+ self.preferred_checkbox.SetValue(
+ self.parent.settings.get("partselector", {}).get("preferred", True)
+ )
+ self.assert_stock_checkbox.SetValue(
+ self.parent.settings.get("partselector", {}).get("stock", False)
+ )
+
+ self.basic_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings)
+ self.extended_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings)
+ self.preferred_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings)
+ self.assert_stock_checkbox.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ help_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Help",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(100, -1)),
+ 0,
+ )
+
+ keyword_search_row1 = wx.BoxSizer(wx.HORIZONTAL)
+ keyword_search_row1.Add(keyword_label, 0, wx.ALL, 5)
+ keyword_search_row1.Add(
+ self.keyword,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ keyword_search_row1.Add(
+ self.ohm_button,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ keyword_search_row1.Add(
+ self.micro_button,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ search_sizer_one = wx.BoxSizer(wx.VERTICAL)
+ search_sizer_one.Add(manufacturer_label, 0, wx.ALL, 5)
+ search_sizer_one.Add(
+ self.manufacturer,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_one.Add(package_label, 0, wx.ALL, 5)
+ search_sizer_one.Add(
+ self.package,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ search_sizer_two = wx.BoxSizer(wx.VERTICAL)
+ search_sizer_two.Add(category_label, 0, wx.ALL, 5)
+ search_sizer_two.Add(
+ self.category,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_two.Add(part_no_label, 0, wx.ALL, 5)
+ search_sizer_two.Add(
+ self.part_no,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_two.Add(solder_joints_label, 0, wx.ALL, 5)
+ search_sizer_two.Add(
+ self.solder_joints,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ search_sizer_three = wx.BoxSizer(wx.VERTICAL)
+ search_sizer_three.Add(subcategory_label, 0, wx.ALL, 5)
+ search_sizer_three.Add(
+ self.subcategory,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ search_sizer_four = wx.BoxSizer(wx.VERTICAL)
+ search_sizer_four.Add(basic_label, 0, wx.ALL, 5)
+ search_sizer_four.Add(
+ self.basic_checkbox,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_four.Add(preferred_label, 0, wx.ALL, 5)
+ search_sizer_four.Add(
+ self.preferred_checkbox,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_four.Add(extended_label, 0, wx.ALL, 5)
+ search_sizer_four.Add(
+ self.extended_checkbox,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+ search_sizer_four.Add(stock_label, 0, wx.ALL, 5)
+ search_sizer_four.Add(
+ self.assert_stock_checkbox,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ search_sizer_five = wx.BoxSizer(wx.VERTICAL)
+ search_sizer_five.Add(
+ help_button,
+ 0,
+ wx.LEFT | wx.RIGHT | wx.BOTTOM,
+ 5,
+ )
+
+ help_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-help-circle-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ help_button.SetBitmapMargins((2, 0))
+
+ search_sizer = wx.StaticBoxSizer(wx.VERTICAL, self, "Search")
+
+ search_sizer.Add(keyword_search_row1)
+
+ search_sizer_row2 = wx.StaticBoxSizer(wx.HORIZONTAL, self)
+ search_sizer_row2.Add(search_sizer_one, 0, wx.RIGHT, 20)
+ search_sizer_row2.Add(search_sizer_two, 0, wx.RIGHT, 20)
+ search_sizer_row2.Add(search_sizer_three, 0, wx.RIGHT, 20)
+ search_sizer_row2.Add(search_sizer_four, 0, wx.RIGHT, 20)
+ search_sizer_row2.Add(search_sizer_five, 0, wx.RIGHT, 20)
+ # search_sizer.Add(help_button, 0, wx.RIGHT, 20)
+
+ search_sizer.Add(search_sizer_row2)
+
+ self.keyword.Bind(wx.EVT_TEXT, self.search_dwell)
+ self.ohm_button.Bind(wx.EVT_BUTTON, self.add_ohm_symbol)
+ self.micro_button.Bind(wx.EVT_BUTTON, self.add_micro_symbol)
+ self.manufacturer.Bind(wx.EVT_TEXT, self.search_dwell)
+ self.package.Bind(wx.EVT_TEXT, self.search_dwell)
+ self.category.Bind(wx.EVT_COMBOBOX, self.update_subcategories)
+ self.category.Bind(wx.EVT_TEXT, self.update_subcategories)
+ self.part_no.Bind(wx.EVT_TEXT, self.search_dwell)
+ self.solder_joints.Bind(wx.EVT_TEXT, self.search_dwell)
+ help_button.Bind(wx.EVT_BUTTON, self.help)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Result status line -------------------------
+ # ---------------------------------------------------------------------
+
+ self.result_count = wx.StaticText(
+ self, wx.ID_ANY, "0 Results", wx.DefaultPosition, wx.DefaultSize
+ )
+
+ result_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ result_sizer.Add(self.result_count, 0, wx.LEFT | wx.TOP, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------- Result Part list --------------------------
+ # ---------------------------------------------------------------------
+
+ table_sizer = wx.BoxSizer(wx.HORIZONTAL)
+
+ table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL)
+ table_scroller.SetScrollRate(20, 20)
+
+ self.part_list = dv.DataViewCtrl(
+ table_scroller,
+ style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_SINGLE,
+ )
+ align_map = {
+ "left": wx.ALIGN_LEFT,
+ "center": wx.ALIGN_CENTER,
+ }
+ for idx, column in enumerate(PARTSELECTOR_COLUMNS):
+ if column.key in HIGHLIGHTED_COLUMN_KEYS:
+ renderer = HighlightedTextRenderer(
+ highlight_text_getter=self.get_highlight_text,
+ align=align_map[column.align],
+ )
+ view_col = dv.DataViewColumn(
+ column.label,
+ renderer,
+ idx,
+ width=int(parent.scale_factor * column.width),
+ align=align_map[column.align],
+ )
+ self.part_list.AppendColumn(view_col)
+ else:
+ view_col = self.part_list.AppendTextColumn(
+ column.label,
+ idx,
+ width=int(parent.scale_factor * column.width),
+ mode=dv.DATAVIEW_CELL_INERT,
+ align=align_map[column.align],
+ )
+ if column.sortable:
+ view_col.SetSortable(True)
+
+ self.part_list.Bind(dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnPartSelected)
+ self.part_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part)
+ scrolled_sizer = wx.BoxSizer(wx.VERTICAL)
+ scrolled_sizer.Add(self.part_list, 1, wx.EXPAND)
+ table_scroller.SetSizer(scrolled_sizer)
+
+ table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------ Right side toolbar -------------------------
+ # ---------------------------------------------------------------------
+
+ self.select_part_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Select part",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+ self.part_details_button = wx.Button(
+ self,
+ wx.ID_ANY,
+ "Show part details",
+ wx.DefaultPosition,
+ HighResWxSize(parent.window, wx.Size(150, -1)),
+ 0,
+ )
+
+ self.select_part_button.Bind(wx.EVT_BUTTON, self.select_part)
+ self.part_details_button.Bind(wx.EVT_BUTTON, self.get_part_details)
+
+ self.select_part_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-check.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.select_part_button.SetBitmapMargins((2, 0))
+
+ self.part_details_button.SetBitmap(
+ loadBitmapScaled(
+ "mdi-text-box-search-outline.png",
+ self.parent.scale_factor,
+ )
+ )
+ self.part_details_button.SetBitmapMargins((2, 0))
+
+ tool_sizer = wx.BoxSizer(wx.VERTICAL)
+ tool_sizer.Add(self.select_part_button, 0, wx.ALL, 5)
+ tool_sizer.Add(self.part_details_button, 0, wx.ALL, 5)
+ table_sizer.Add(tool_sizer, 3, wx.EXPAND, 5)
+
+ # ---------------------------------------------------------------------
+ # ------------------------------ Sizers ------------------------------
+ # ---------------------------------------------------------------------
+
+ layout = wx.BoxSizer(wx.VERTICAL)
+ layout.Add(search_sizer, 1, wx.ALL, 5)
+ # layout.Add(self.search_button, 5, wx.ALL, 5)
+ layout.Add(result_sizer, 1, wx.LEFT, 5)
+ layout.Add(table_sizer, 20, wx.ALL | wx.EXPAND, 5)
+
+ self.part_list_model = PartSelectorDataModel()
+ self.part_list.AssociateModel(self.part_list_model)
+
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+ self.enable_toolbar_buttons(False)
+
+ # initiate the initial search now that the window has been constructed
+ self.search(None)
+
+ def update_settings(self, event):
+ """Update the settings on change."""
+ wx.PostEvent(
+ self.parent,
+ UpdateSetting(
+ section="partselector",
+ setting=event.GetEventObject().GetName(),
+ value=event.GetEventObject().GetValue(),
+ ),
+ )
+
+ # initiate a search now that settings have changed
+ self.search(None)
+
+ @staticmethod
+ def get_existing_selection(parts):
+ """Check if exactly one LCSC part number is amongst the selected parts."""
+ s = set(parts.values())
+ if len(s) != 1:
+ return ""
+ return list(s)[0]
+
+ def quit_dialog(self, *_):
+ """Close this window."""
+ self.Destroy()
+ self.EndModal(0)
+
+ def OnSortPartList(self, e):
+ """Set order_by to the clicked column and trigger list refresh."""
+ self.parent.library.set_order_by(e.GetColumn())
+ self.search(None)
+
+ def OnPartSelected(self, *_):
+ """Enable the toolbar buttons when a selection was made."""
+ if self.part_list.GetSelectedItemsCount() > 0:
+ self.enable_toolbar_buttons(True)
+ else:
+ self.enable_toolbar_buttons(False)
+
+ def enable_toolbar_buttons(self, state):
+ """Control the state of all the buttons in toolbar on the right side."""
+ for b in [
+ self.select_part_button,
+ self.part_details_button,
+ ]:
+ b.Enable(bool(state))
+
+ def add_ohm_symbol(self, *_):
+ """Append the Ω symbol to the search string."""
+ self.keyword.AppendText("Ω")
+
+ def add_micro_symbol(self, *_):
+ """Append the µ symbol to the search string."""
+ self.keyword.AppendText("µ")
+
+ def search_dwell(self, *_):
+ """Initiate a search once the timeout expires.
+
+ Used to avoid continuous searches
+ when input fields are still being changed by the user.
+ """
+ self.search_timer.StartOnce(750)
+
+ def search(self, *_):
+ """Search the library for parts that meet the search criteria."""
+ parameters = {
+ "keyword": self.keyword.GetValue(),
+ "manufacturer": self.manufacturer.GetValue(),
+ "package": self.package.GetValue(),
+ "category": self.category.GetValue(),
+ "subcategory": self.subcategory.GetValue(),
+ "part_no": self.part_no.GetValue(),
+ "solder_joints": self.solder_joints.GetValue(),
+ "basic": self.basic_checkbox.GetValue(),
+ "extended": self.extended_checkbox.GetValue(),
+ "preferred": self.preferred_checkbox.GetValue(),
+ "stock": self.assert_stock_checkbox.GetValue(),
+ }
+ start = time.time()
+ result = self.parent.library.search(parameters)
+ self.logger.debug("len(result) %d", len(result))
+ search_duration = time.time() - start
+ self.populate_part_list(result, search_duration)
+
+ def get_highlight_text(self) -> str:
+ """Return the active keyword search text for result highlighting."""
+ if not self.parent.settings.get("partselector", {}).get(
+ "highlight_matches", True
+ ):
+ return ""
+ return self.keyword.GetValue()
+
+ def update_subcategories(self, *_):
+ """Update the possible subcategory selection."""
+ self.subcategory.Clear()
+ if self.category.GetSelection() != wx.NOT_FOUND:
+ subcategories = self.parent.library.get_subcategories(
+ self.category.GetValue()
+ )
+ self.subcategory.AppendItems(subcategories)
+
+ # search now that categories might have changed
+ self.search(None)
+
+ def get_price(self, quantity, prices) -> float:
+ """Find the price for the number of selected parts according to the price ranges."""
+ price_ranges = prices.split(",")
+ if not price_ranges[0]:
+ return -1.0
+ min_quantity = int(price_ranges[0].split("-")[0])
+ if quantity <= min_quantity:
+ range, price = price_ranges[0].split(":")
+ return float(price)
+ for p in price_ranges:
+ range, price = p.split(":")
+ lower, upper = range.split("-")
+ if not upper: # upper bound of price ranges
+ return float(price)
+ lower = int(lower)
+ upper = int(upper)
+ if lower <= quantity < upper:
+ return float(price)
+ return -1.0
+
+ def populate_part_list(self, parts, search_duration):
+ """Populate the list with the result of the search."""
+ search_duration_text = _format_duration(search_duration)
+ start = time.time()
+ self.part_list_model.RemoveAll()
+ if parts is None:
+ return
+ limit_text = " (limited)" if len(parts) >= 1000 else ""
+ for p in parts:
+ db_row = {field: str(value) for field, value in zip(DB_FIELDS, p)}
+ price = round(self.get_price(len(self.parts), db_row.get("Price", "")), 3)
+ if price > 0:
+ total_cost = round(price * len(self.parts), 3)
+ db_row["Price"] = (
+ f"{len(self.parts)} parts: ${price} each / ${total_cost} total"
+ )
+ else:
+ db_row["Price"] = "Error in price data"
+ params = params_for_part(
+ {
+ "description": db_row.get("Description", ""),
+ "category": db_row.get("First Category", ""),
+ "package": db_row.get("Package", ""),
+ }
+ )
+ item = []
+ for column in PARTSELECTOR_COLUMNS:
+ if column.key == PARAMS_COLUMN_KEY:
+ item.append(params)
+ elif column.db_field:
+ item.append(db_row.get(column.db_field, ""))
+ else:
+ item.append("")
+ self.part_list_model.AddEntry(item)
+ render_duration = time.time() - start
+ render_duration_text = _format_duration(render_duration)
+ result_count_label = (
+ f"{len(parts)} parts {limit_text}."
+ f"Search in {search_duration_text}, "
+ f"Render in {render_duration_text}."
+ )
+ self.result_count.SetLabel(result_count_label)
+
+ def select_part(self, *_):
+ """Save the selected part number and close the modal."""
+ if self.part_list.GetSelectedItemsCount() > 0:
+ item = self.part_list.GetSelection()
+ wx.PostEvent(
+ self.parent,
+ AssignPartsEvent(
+ lcsc=self.part_list_model.get_lcsc(item),
+ type=self.part_list_model.get_type(item),
+ stock=self.part_list_model.get_stock(item),
+ references=self.parts.keys(),
+ ),
+ )
+ self.EndModal(wx.ID_OK)
+
+ def get_part_details(self, *_):
+ """Fetch part details from LCSC and show them in a modal."""
+ if self.part_list.GetSelectedItemsCount() > 0:
+ item = self.part_list.GetSelection()
+ busy_cursor = wx.BusyCursor()
+ dialog = PartDetailsDialog(self.parent, self.part_list_model.get_lcsc(item))
+ del busy_cursor
+ dialog.ShowModal()
+
+ def help(self, *_):
+ """Show message box with help instructions."""
+ title = "Help"
+ text = """
+ Use % as wildcard selector. \n
+ For example DS24% will match DS2411\n
+ %QFP% will match LQFP-64 as well as TQFP-32\n
+ The keyword search box is automatically post- and prefixed with wildcard operators.
+ The others are not by default.\n
+ The keyword search field is applied to "LCSC Part", "Description", "MFR.Part",
+ "Package" and "Manufacturer".\n
+ Searching occurs as input fields are changed.\n
+ The results are limited to 1000.
+ """
+ wx.MessageBox(text, title, style=wx.ICON_INFORMATION)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py
new file mode 100644
index 00000000..66ccdc94
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector_columns.py
@@ -0,0 +1,51 @@
+"""Shared column definitions for the part selector UI and queries."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class PartSelectorColumn:
+ """Column metadata for the part selector list."""
+
+ key: str
+ label: str
+ db_field: str | None
+ width: int
+ align: str
+ sortable: bool = True
+ model_type: str = "string"
+
+
+PARTSELECTOR_COLUMNS: list[PartSelectorColumn] = [
+ PartSelectorColumn("lcsc", "LCSC", "LCSC Part", 60, "center"),
+ PartSelectorColumn("mfr_number", "MFR Number", "MFR.Part", 140, "left"),
+ PartSelectorColumn("package", "Package", "Package", 100, "left"),
+ PartSelectorColumn("type", "Type", "Library Type", 50, "left"),
+ PartSelectorColumn("params", "Params", None, 150, "center", sortable=False),
+ PartSelectorColumn("stock", "Stock", "Stock", 50, "center"),
+ PartSelectorColumn("mfr", "Manufacturer", "Manufacturer", 100, "left"),
+ PartSelectorColumn("description", "Description", "Description", 300, "left"),
+ PartSelectorColumn("price", "Price", "Price", 100, "left"),
+]
+
+EXTRA_DB_FIELDS: list[str] = ["First Category"]
+
+DB_FIELDS: list[str] = [
+ c.db_field for c in PARTSELECTOR_COLUMNS if c.db_field is not None
+] + EXTRA_DB_FIELDS
+
+COLUMN_INDEX: dict[str, int] = {
+ column.key: idx for idx, column in enumerate(PARTSELECTOR_COLUMNS)
+}
+
+SORTABLE_COLUMN_INDEX_TO_DB: dict[int, str] = {
+ idx: column.db_field
+ for idx, column in enumerate(PARTSELECTOR_COLUMNS)
+ if column.sortable and column.db_field is not None
+}
+
+MODEL_COLUMN_TYPES: list[str] = [column.model_type for column in PARTSELECTOR_COLUMNS]
+
+PARAMS_COLUMN_KEY = "params"
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py b/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py
new file mode 100644
index 00000000..c6517a60
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/partselector_highlight.py
@@ -0,0 +1,201 @@
+"""Highlight helpers and renderer for part selector search results."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+try:
+ import wx # pylint: disable=import-error
+ import wx.dataview as dv # pylint: disable=import-error
+except ImportError: # pragma: no cover - test environments may not have wx
+ wx = None # type: ignore[assignment]
+ dv = None # type: ignore[assignment]
+
+
+_HIGHLIGHT_FG = (180, 120, 0)
+_HIGHLIGHT_FG_SELECTED = (255, 215, 64)
+_MIN_HIGHLIGHT_TERM_LENGTH = 2
+
+
+def normalize_highlight_terms(query: str) -> list[str]:
+ """Split keyword query into normalized terms suitable for highlighting."""
+ terms = []
+ for raw_term in query.split():
+ term = raw_term.strip().strip("%")
+ if term:
+ lowered = term.casefold()
+ if lowered not in terms:
+ terms.append(lowered)
+ return terms
+
+
+def find_highlight_spans(text: str, terms: list[str]) -> list[tuple[int, int]]:
+ """Return merged `(start, end)` spans for all term matches in `text`."""
+ if not text or not terms:
+ return []
+
+ lowered_text = text.casefold()
+ spans: list[tuple[int, int]] = []
+
+ for term in terms:
+ start = 0
+ while True:
+ match_start = lowered_text.find(term, start)
+ if match_start == -1:
+ break
+ match_end = match_start + len(term)
+ spans.append((match_start, match_end))
+ start = match_end
+
+ if not spans:
+ return []
+
+ spans.sort()
+ merged = [spans[0]]
+ for start, end in spans[1:]:
+ last_start, last_end = merged[-1]
+ if start <= last_end:
+ merged[-1] = (last_start, max(last_end, end))
+ else:
+ merged.append((start, end))
+ return merged
+
+
+def filtered_highlight_terms(query: str) -> list[str]:
+ """Return normalized terms that are long enough to highlight."""
+ return [
+ t
+ for t in normalize_highlight_terms(query)
+ if len(t) >= _MIN_HIGHLIGHT_TERM_LENGTH
+ ]
+
+
+class HighlightQueryCache:
+ """Cache normalized query terms and highlight spans for one active query."""
+
+ def __init__(self):
+ self._query = ""
+ self._terms: list[str] = []
+ self._span_cache: dict[str, list[tuple[int, int]]] = {}
+
+ def prepare(self, query: str):
+ """Prepare cache state for a query, resetting cached spans on change."""
+ if query != self._query:
+ self._query = query
+ self._terms = filtered_highlight_terms(query)
+ self._span_cache.clear()
+
+ def clear(self):
+ """Clear all cached query and span data."""
+ self._query = ""
+ self._terms = []
+ self._span_cache.clear()
+
+ def get_terms(self) -> list[str]:
+ """Return terms prepared for the active query."""
+ return self._terms
+
+ def get_spans(self, text: str) -> list[tuple[int, int]]:
+ """Return cached spans for text, computing and storing on cache miss."""
+ spans = self._span_cache.get(text)
+ if spans is None:
+ spans = find_highlight_spans(text, self._terms)
+ self._span_cache[text] = spans
+ return spans
+
+
+if wx is not None and dv is not None: # pragma: no branch
+
+ class HighlightedTextRenderer(dv.DataViewCustomRenderer):
+ """Simple text renderer that highlights keyword matches."""
+
+ def __init__(self, highlight_text_getter: Callable[[], str], align: int):
+ super().__init__("string", dv.DATAVIEW_CELL_INERT, align)
+ self._highlight_text_getter = highlight_text_getter
+ self._value = ""
+ # Cache lifetime is tied to this renderer instance (one dialog/session).
+ # It resets automatically when the query string changes via prepare(query).
+ # A new part selector dialog constructs new renderer instances and caches.
+ self._query_cache = HighlightQueryCache()
+
+ def SetValue(self, value: str) -> bool:
+ """Store value to render for the current cell."""
+ self._value = "" if value is None else str(value)
+ return True
+
+ def GetValue(self) -> str:
+ """Return current cell value."""
+ return self._value
+
+ def GetSize(self):
+ """Return a best-effort size for the current text."""
+ owner = self.GetOwner()
+ font = (
+ owner.GetOwner().GetFont()
+ if owner is not None and owner.GetOwner() is not None
+ else wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT)
+ )
+ dc = wx.ScreenDC()
+ dc.SetFont(font)
+ width, height = dc.GetTextExtent(self._value or "Hg")
+ return wx.Size(width + 8, height + 6)
+
+ def Render(self, rect, dc, state):
+ """Draw the cell text and highlight search-term matches."""
+ selected = bool(state & dv.DATAVIEW_CELL_SELECTED)
+ foreground = wx.SystemSettings.GetColour(
+ wx.SYS_COLOUR_HIGHLIGHTTEXT if selected else wx.SYS_COLOUR_LISTBOXTEXT
+ )
+ highlight = wx.Colour(
+ *(_HIGHLIGHT_FG_SELECTED if selected else _HIGHLIGHT_FG)
+ )
+
+ dc.SetTextForeground(foreground)
+ dc.SetBackgroundMode(wx.TRANSPARENT)
+
+ text = self._value
+ if not text:
+ return True
+
+ query = self._highlight_text_getter()
+ self._query_cache.prepare(query)
+ terms = self._query_cache.get_terms()
+ if not terms:
+ text_height = dc.GetTextExtent("Hg")[1]
+ x = rect.x + 4
+ y = rect.y + max(0, (rect.height - text_height) // 2)
+ dc.SetClippingRegion(rect)
+ try:
+ dc.DrawText(text, x, y)
+ finally:
+ dc.DestroyClippingRegion()
+ return True
+
+ spans = self._query_cache.get_spans(text)
+ text_height = dc.GetTextExtent("Hg")[1]
+ x = rect.x + 4
+ y = rect.y + max(0, (rect.height - text_height) // 2)
+
+ dc.SetClippingRegion(rect)
+ try:
+ cursor = 0
+ for start, end in spans:
+ if start > cursor:
+ segment = text[cursor:start]
+ dc.SetTextForeground(foreground)
+ dc.DrawText(segment, x, y)
+ x += dc.GetTextExtent(segment)[0]
+
+ segment = text[start:end]
+ segment_width, _ = dc.GetTextExtent(segment)
+ dc.SetTextForeground(highlight)
+ dc.DrawText(segment, x, y)
+ x += segment_width
+ cursor = end
+
+ if cursor < len(text):
+ dc.SetTextForeground(foreground)
+ dc.DrawText(text[cursor:], x, y)
+ finally:
+ dc.DestroyClippingRegion()
+ return True
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/plugin.py b/third_party/Bouni/kicad-jlcpcb-tools/plugin.py
new file mode 100644
index 00000000..5072a4b3
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/plugin.py
@@ -0,0 +1,30 @@
+"""Contains the Action Plugin."""
+
+import os
+
+from pcbnew import ActionPlugin # pylint: disable=import-error
+
+from .mainwindow import JLCPCBTools
+
+
+class JLCPCBPlugin(ActionPlugin):
+ """JLCPCBPlugin instance of ActionPlugin."""
+
+ def defaults(self):
+ """Define defaults."""
+ # pylint: disable=attribute-defined-outside-init
+ self.name = "JLCPCB Tools"
+ self.category = "Fabrication data generation"
+ self.description = (
+ "Generate JLCPCB-compatible Gerber, Excellon, BOM and CPL files"
+ )
+ self.show_toolbar_button = True
+ path, _ = os.path.split(os.path.abspath(__file__))
+ self.icon_file_name = os.path.join(path, "jlcpcb-icon.png")
+ self._pcbnew_frame = None
+
+ def Run(self):
+ """Overwrite Run."""
+ dialog = JLCPCBTools(None)
+ dialog.Center()
+ dialog.Show()
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml b/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml
new file mode 100644
index 00000000..5ed3f4ee
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/pyproject.toml
@@ -0,0 +1,156 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "Kicad-jlcpcb-tools"
+version = "2025.09.02"
+license = "MIT"
+description = "Plugin to generate BOM + CPL files for JLCPCB."
+readme = "README.md"
+requires-python = ">=3.10"
+keywords = ["kicad", "jlcpcb", "electronics", "pcb", "bom", "cpl"]
+classifiers = [
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+]
+dependencies = [
+ "click>=8.0",
+ "humanize>=4.0",
+ "requests>=2.28",
+ "cachetools>=5.0",
+ "ratelimit>=2.2.1",
+ "tqdm>=4.0",
+ "retry>=0.9.2",
+ "split_file_reader>=0.1.4",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0",
+ "pytest-cov>=4.0",
+ "ruff>=0.1.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/bouni/kicad-jlcpcb-tools"
+Repository = "https://github.com/bouni/kicad-jlcpcb-tools.git"
+Issues = "https://github.com/bouni/kicad-jlcpcb-tools/issues"
+
+[tool.setuptools]
+packages = ["db_build", "common"]
+
+[tool.ruff.lint]
+
+select = [
+ "B002", # Python does not support the unary prefix increment
+ "B007", # Loop control variable {name} not used within loop body
+ "B014", # Exception handler with duplicate exception
+ "B023", # Function definition does not bind loop variable {name}
+ "B026", # Star-arg unpacking after a keyword argument is strongly discouraged
+ "B904", # Use raise from to specify exception cause
+ "C", # complexity
+ "COM818", # Trailing comma on bare tuple prohibited
+ "D", # docstrings
+ "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
+ "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
+ "E", # pycodestyle
+ "F", # pyflakes/autoflake
+ "G", # flake8-logging-format
+ "I", # isort
+ "ICN001", # import concentions; {name} should be imported as {asname}
+ "N804", # First argument of a class method should be named cls
+ "N805", # First argument of a method should be named self
+ "N815", # Variable {name} in class scope should not be mixedCase
+ "PGH004", # Use specific rule codes when using noqa
+ "PLC0414", # Useless import alias. Import alias does not rename original package.
+ "PLC", # pylint
+ "PLE", # pylint
+ "PLR", # pylint
+ "PLW", # pylint
+ "Q000", # Double quotes found but single quotes preferred
+ "RUF006", # Store a reference to the return value of asyncio.create_task
+ "S102", # Use of exec detected
+ "S103", # bad-file-permissions
+ "S108", # hardcoded-temp-file
+ "S306", # suspicious-mktemp-usage
+ "S307", # suspicious-eval-usage
+ "S313", # suspicious-xmlc-element-tree-usage
+ "S314", # suspicious-xml-element-tree-usage
+ "S315", # suspicious-xml-expat-reader-usage
+ "S316", # suspicious-xml-expat-builder-usage
+ "S317", # suspicious-xml-sax-usage
+ "S318", # suspicious-xml-mini-dom-usage
+ "S319", # suspicious-xml-pull-dom-usage
+ "S601", # paramiko-call
+ "S602", # subprocess-popen-with-shell-equals-true
+ "S604", # call-with-shell-equals-true
+ "S608", # hardcoded-sql-expression
+ "S609", # unix-command-wildcard-injection
+ "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
+ "SIM117", # Merge with-statements that use the same scope
+ "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
+ "SIM201", # Use {left} != {right} instead of not {left} == {right}
+ "SIM208", # Use {expr} instead of not (not {expr})
+ "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
+ "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
+ "SIM401", # Use get from dict with default instead of an if block
+ "T100", # Trace found: {name} used
+ "T20", # flake8-print
+ "TID251", # Banned imports
+ "TRY004", # Prefer TypeError exception for invalid type
+ "TRY203", # Remove exception handler; error is immediately re-raised
+ "UP", # pyupgrade
+ "W", # pycodestyle
+]
+
+ignore = [
+ "C901", # too complex
+ "D107", # Ignore missing Docstring in __init__ methods
+ "D202", # No blank lines allowed after function docstring
+ "D203", # 1 blank line required before class docstring
+ "D213", # Multi-line docstring summary should start at the second line
+ "D406", # Section name should end with a newline
+ "D407", # Section name underlining
+ "E501", # line too long
+ "E722", # Do not use bare except, specify exception instead
+ "E731", # do not assign a lambda expression, use a def
+ "S608", # Ignore Possible SQL injection vector through string-based query construction
+
+ # Ignore ignored, as the rule is now back in preview/nursery, which cannot
+ # be ignored anymore without warnings.
+ # https://github.com/astral-sh/ruff/issues/7491
+ # "PLC1901", # Lots of false positives
+
+ # False positives https://github.com/astral-sh/ruff/issues/5386
+ "PLC0208", # Use a sequence type instead of a `set` when iterating over values
+ "PLR0911", # Too many return statements ({returns} > {max_returns})
+ "PLR0912", # Too many branches ({branches} > {max_branches})
+ "PLR0913", # Too many arguments to function call ({c_args} > {max_args})
+ "PLR0915", # Too many statements ({statements} > {max_statements})
+ "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
+ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
+ "UP006", # keep type annotation style as is
+ "UP007", # keep type annotation style as is
+]
+
+
+[tool.ruff.lint.flake8-pytest-style]
+fixture-parentheses = false
+
+[tool.ruff.lint.isort]
+force-sort-within-sections = true
+combine-as-imports = true
+split-on-trailing-comma = false
+
+[tool.ruff.lint.per-file-ignores]
+"db_build/jlcparts_db_convert.py" = ["T201"]
+"common/filemgr.py" = ["T201"]
+"common/partsdb.py" = ["T201"]
+"common/progress.py" = ["T201"]
+
+[tool.ruff.lint.mccabe]
+max-complexity = 25
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py b/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py
new file mode 100644
index 00000000..c02de2f6
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/schematicexport.py
@@ -0,0 +1,278 @@
+"""Module for exporting LCSC data to schematic."""
+
+import logging
+import os
+import os.path
+import re
+
+from pcbnew import GetBuildVersion # pylint: disable=import-error
+
+from .core.version import is_version6, is_version7
+
+
+class SchematicExport:
+ """A class to export Schematic files."""
+
+ # This only works with KiCad v6/v7/v8 files, if the format changes, this will probably break
+
+ def __init__(self, parent):
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+
+ def load_schematic(self, paths):
+ """Load schematic file."""
+ if is_version6(GetBuildVersion()):
+ self.logger.info("Kicad 6...")
+ for path in paths:
+ self._update_schematic6(path)
+ elif is_version7(GetBuildVersion()):
+ self.logger.info("Kicad 7...")
+ for path in paths:
+ self._update_schematic7(path)
+ else:
+ self.logger.info("Kicad 8+...")
+ for path in paths:
+ self._update_schematic(path)
+
+ def _update_schematic6(self, path):
+ """Only works with KiCad V6 files."""
+ self.logger.info("Reading %s...", path)
+ # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it
+ # keep track of property ids and Reference property location to use with new LCSC property
+ propRx = re.compile(
+ '\\(property\\s\\"(.*)\\"\\s\\"(.*)\\"\\s\\(id\\s(\\d+)\\)\\s\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)'
+ )
+ pinRx = re.compile('\\(pin\\s\\"(.*)\\"\\s\\(')
+
+ store_parts = self.parent.store.read_all()
+
+ lastID = -1
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+
+ lines = []
+ newlines = []
+ with open(path, encoding="utf-8") as f:
+ lines = f.readlines()
+
+ if os.path.exists(path + "_old"):
+ os.remove(path + "_old")
+ os.rename(path, path + "_old")
+ partSection = False
+
+ for line in lines:
+ inLine = line.rstrip()
+ outLine = inLine
+ if "(symbol (lib_id" in inLine: # skip library section
+ partSection = True
+ m = propRx.search(inLine)
+ if m and partSection:
+ key = m.group(1)
+ value = m.group(2)
+ lastID = int(m.group(3))
+
+ # found a LCSC property, so update it if needed
+ if key == "LCSC":
+ lastLcsc = value
+ if newLcsc not in (lastLcsc, ""):
+ self.logger.info("Updating %s on %s", newLcsc, lastRef)
+ outLine = outLine.replace(
+ '"' + lastLcsc + '"', '"' + newLcsc + '"'
+ )
+ lastLcsc = newLcsc
+
+ if key == "Reference":
+ lastLoc = m.group(4)
+ lastRef = value
+ for part in store_parts:
+ if value == part["reference"]:
+ newLcsc = part["lcsc"]
+ break
+ # if we hit the pin section without finding a LCSC property, add it
+ m = pinRx.search(inLine)
+ if m:
+ if lastLcsc == "" and newLcsc != "" and lastLoc != "" and lastID != -1:
+ self.logger.info("added %s to %s", newLcsc, lastRef)
+ newTxt = f' (property "LCSC" "{newLcsc}" (id {lastID + 1}) (at {lastLoc} 0)'
+ newlines.append(newTxt)
+ newlines.append(" (effects (font (size 1.27 1.27)) hide)")
+ newlines.append(" )")
+ lastID = -1
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+ newlines.append(outLine)
+
+ with open(path, "w", encoding="utf-8") as f:
+ for line in newlines:
+ f.write(line + "\n")
+ self.logger.info("Added LCSC's to %s(maybe?)", path)
+
+ def _update_schematic7(self, path):
+ """Only works with KiCad V7 files."""
+ self.logger.info("Reading %s...", path)
+ # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it
+ # keep track of property ids and Reference property location to use with new LCSC property
+ propRx = re.compile(
+ '\\(property\\s\\"(.*)\\"\\s\\"(.*)\\"\\s\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)'
+ )
+ pinRx = re.compile('\\(pin\\s\\"(.*)\\"\\s\\(')
+
+ store_parts = self.parent.store.read_all()
+
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+
+ lines = []
+ newlines = []
+ with open(path, encoding="utf-8") as f:
+ lines = f.readlines()
+
+ if os.path.exists(path + "_old"):
+ os.remove(path + "_old")
+ os.rename(path, path + "_old")
+ partSection = False
+
+ for line in lines:
+ inLine = line.rstrip()
+ outLine = inLine
+ if "(symbol (lib_id" in inLine: # skip library section
+ partSection = True
+ m = propRx.search(inLine)
+ if m and partSection:
+ key = m.group(1)
+ value = m.group(2)
+
+ # found a LCSC property, so update it if needed
+ if key == "LCSC":
+ lastLcsc = value
+ if newLcsc not in (lastLcsc, ""):
+ self.logger.info("Updating %s on %s", newLcsc, lastRef)
+ outLine = outLine.replace(
+ '"' + lastLcsc + '"', '"' + newLcsc + '"'
+ )
+ lastLcsc = newLcsc
+
+ if key == "Reference":
+ lastLoc = m.group(3)
+ lastRef = value
+ for part in store_parts:
+ if value == part["reference"]:
+ newLcsc = part["lcsc"]
+ break
+ # if we hit the pin section without finding a LCSC property, add it
+ m = pinRx.search(inLine)
+ if m:
+ if lastLcsc == "" and newLcsc != "" and lastLoc != "":
+ self.logger.info("added %s to %s", newLcsc, lastRef)
+ newTxt = f' (property "LCSC" "{newLcsc}" (at {lastLoc} 0)'
+ newlines.append(newTxt)
+ newlines.append(" (effects (font (size 1.27 1.27)) hide)")
+ newlines.append(" )")
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+ newlines.append(outLine)
+
+ with open(path, "w", encoding="utf-8") as f:
+ for line in newlines:
+ f.write(line + "\n")
+ self.logger.info("Added LCSC's to %s (maybe?)", path)
+
+ def _update_schematic(self, path):
+ """Only works with KiCad V8+ files."""
+ self.logger.info("Reading %s...", path)
+ # Regex to look through schematic property, if we hit the pin section without finding a LCSC property, add it
+ # keep track of property ids and Reference property location to use with new LCSC property
+ propRx = re.compile('\\(property\\s\\"(.*)\\"\\s"(.*)\\"')
+ atRx = re.compile("\\(at\\s(-?\\d+(?:.\\d+)?\\s-?\\d+(?:.\\d+)?)\\s\\d+\\)")
+ pinRx = re.compile('\\(pin\\s\\"(.*)\\"')
+
+ store_parts = self.parent.store.read_all()
+
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+
+ lines = []
+ newlines = []
+ with open(path, encoding="utf-8") as f:
+ lines = f.readlines()
+
+ partSection = False
+ files_seen = set() # keeps sheet files already processed.
+
+ for i in range(0, len(lines) - 1):
+ inLine = lines[i].rstrip()
+ inLine2 = lines[i + 1].rstrip()
+ outLine = inLine
+
+ if "(symbol" in inLine and "(lib_id" in inLine2: # skip library section
+ partSection = True
+
+ # self.logger.info("line %d", i)
+ m = propRx.search(inLine)
+ m2 = atRx.search(inLine2)
+ if m and m2 and partSection:
+ key = m.group(1)
+ # self.logger.info("key %s", key)
+ # found a LCSC property, so update it if needed
+ if key in {"LCSC", "LCSC_PN", "JLC_PN"}:
+ value = m.group(2)
+ lastLcsc = value
+ if newLcsc not in (lastLcsc, ""):
+ self.logger.info(
+ "Updating %s on %s in %s", newLcsc, lastRef, path
+ )
+ outLine = outLine.replace(
+ '"' + lastLcsc + '"', '"' + newLcsc + '"'
+ )
+ lastLcsc = newLcsc
+
+ if key == "Reference":
+ lastLoc = m2.group(1)
+ value = m.group(2)
+ # self.logger.info("value %s", value)
+ lastRef = value
+ for part in store_parts:
+ if value == part["reference"]:
+ newLcsc = part["lcsc"]
+ break
+ if key == "Sheetfile":
+ file_name = m.group(2)
+ if file_name not in files_seen:
+ files_seen.add(file_name)
+ dir_name = os.path.dirname(path)
+ self._update_schematic(os.path.join(dir_name, file_name))
+ # if we hit the pin section without finding a LCSC property, add it
+ m3 = pinRx.search(inLine)
+ if m3 and partSection:
+ if lastLcsc == "" and newLcsc != "" and lastLoc != "":
+ self.logger.info("added %s to %s", newLcsc, lastRef)
+ newTxt = f'\t\t(property "LCSC" "{newLcsc}"\n\t\t\t(at {lastLoc} 0)'
+ newlines.append(newTxt)
+ newlines.append(
+ "\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t\t(hide yes)"
+ )
+ newlines.append("\t\t\t)")
+ newlines.append("\t\t)")
+ lastLoc = ""
+ lastLcsc = ""
+ newLcsc = ""
+ lastRef = ""
+ newlines.append(outLine)
+ newlines.append(lines[len(lines) - 1].rstrip())
+ if os.path.exists(path + "_old"):
+ os.remove(path + "_old")
+ os.rename(path, path + "_old")
+ with open(path, "w", encoding="utf-8") as f:
+ for line in newlines:
+ f.write(line + "\n")
+ self.logger.info("Added LCSC's to %s (maybe?)", path)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py b/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py
new file mode 100644
index 00000000..5c9b4f50
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/search_escape.py
@@ -0,0 +1,26 @@
+"""SQL escaping helpers for the parts library search."""
+
+
+def escape_like_term(term: str) -> str:
+ r"""Escape a term for use inside a SQL LIKE expression.
+
+ Uses backslash as the escape character, so the caller must append
+ ``ESCAPE '\'`` to the LIKE predicate. Also escapes embedded single
+ quotes so the term is safe to inline into a SQL string literal.
+ """
+ return (
+ term.replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_")
+ .replace("'", "''")
+ )
+
+
+def escape_fts_phrase(term: str) -> str:
+ r"""Escape a term for use inside an FTS5 double-quoted phrase token.
+
+ Inside ``\"...\"`` phrase tokens a literal ``\"`` must be doubled.
+ The whole MATCH expression is embedded in a SQL ``'...'`` string so
+ a literal ``'`` must also be escaped as ``''``.
+ """
+ return term.replace('"', '""').replace("'", "''")
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/settings.json b/third_party/Bouni/kicad-jlcpcb-tools/settings.json
new file mode 100644
index 00000000..03086dc2
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/settings.json
@@ -0,0 +1 @@
+{"partselector": {"basic": false, "extended": false, "preferred": true, "stock": false, "highlight_matches": true}, "gerber": {"tented_vias": true, "fill_zones": true, "force_drc": true, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": false, "order_number": true, "select_alike_auto": false}, "library": {"selected_library": "current-parts", "data_path": ""}}
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/settings.py b/third_party/Bouni/kicad-jlcpcb-tools/settings.py
new file mode 100644
index 00000000..e40e1d46
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/settings.py
@@ -0,0 +1,715 @@
+"""Contains the settings dialog."""
+
+import logging
+
+import wx # pylint: disable=import-error
+
+# Import library configuration to populate choices
+from .dblib import LIBRARY_CONFIGS
+from .events import UpdateSetting
+from .helpers import HighResWxSize, loadBitmapScaled
+
+
+class SettingsDialog(wx.Dialog):
+ """Dialog for plugin settings."""
+
+ def __init__(self, parent):
+ wx.Dialog.__init__(
+ self,
+ parent,
+ id=wx.ID_ANY,
+ title="JLCPCB tools settings",
+ pos=wx.DefaultPosition,
+ size=HighResWxSize(parent.window, wx.Size(1300, 800)),
+ style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
+ )
+
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+
+ # ---------------------------------------------------------------------
+ # ---------------------------- Hotkeys --------------------------------
+ # ---------------------------------------------------------------------
+ quitid = wx.NewId()
+ self.Bind(wx.EVT_MENU, self.quit_dialog, id=quitid)
+
+ entries = [wx.AcceleratorEntry(), wx.AcceleratorEntry(), wx.AcceleratorEntry()]
+ entries[0].Set(wx.ACCEL_CTRL, ord("W"), quitid)
+ entries[1].Set(wx.ACCEL_CTRL, ord("Q"), quitid)
+ entries[2].Set(wx.ACCEL_SHIFT, wx.WXK_ESCAPE, quitid)
+ accel = wx.AcceleratorTable(entries)
+ self.SetAcceleratorTable(accel)
+
+ # ---------------------------------------------------------------------
+ # ------------------------- Change settings ---------------------------
+ # ---------------------------------------------------------------------
+
+ ##### Tented vias #####
+
+ self.tented_vias_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Do not tent vias",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_tented_vias",
+ )
+
+ self.tented_vias_setting.SetToolTip(
+ wx.ToolTip("Whether vias should be coverd by soldermask or not")
+ )
+
+ self.tented_vias_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("tented.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.tented_vias_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ tented_vias_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ tented_vias_sizer.Add(self.tented_vias_image, 10, wx.ALL | wx.EXPAND, 5)
+ tented_vias_sizer.Add(self.tented_vias_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Fill zones #####
+
+ self.fill_zones_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Fill zones",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_fill_zones",
+ )
+
+ self.fill_zones_setting.SetToolTip(
+ wx.ToolTip("Whether zones should be filled on gerber generation")
+ )
+
+ self.fill_zones_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("fill-zones.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.fill_zones_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ fill_zones_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ fill_zones_sizer.Add(self.fill_zones_image, 10, wx.ALL | wx.EXPAND, 5)
+ fill_zones_sizer.Add(self.fill_zones_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Force DRC before Gerber export #####
+
+ self.force_drc_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Force DRC check before Gerber export - Saves board and fills zones!",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_force_drc",
+ )
+
+ self.force_drc_setting.SetToolTip(
+ wx.ToolTip(
+ "Run kicad-cli DRC with error severity before generating Gerbers (Saves board and fills zones!)"
+ )
+ )
+
+ self.force_drc_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("bug-check-outline.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.force_drc_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ force_drc_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ force_drc_sizer.Add(self.force_drc_image, 10, wx.ALL | wx.EXPAND, 5)
+ force_drc_sizer.Add(self.force_drc_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Plot values #####
+
+ self.plot_values_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Plot values",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_plot_values",
+ )
+
+ self.plot_values_setting.SetToolTip(
+ wx.ToolTip("Whether value should be plotted on gerber generation")
+ )
+
+ self.plot_values_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("plot_values.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.plot_values_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ plot_values_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ plot_values_sizer.Add(self.plot_values_image, 10, wx.ALL | wx.EXPAND, 5)
+ plot_values_sizer.Add(self.plot_values_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Plot references #####
+
+ self.plot_references_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Plot references",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_plot_references",
+ )
+
+ self.plot_references_setting.SetToolTip(
+ wx.ToolTip("Whether value should be plotted on gerber generation")
+ )
+
+ self.plot_references_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("plot_refs.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.plot_references_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ plot_references_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ plot_references_sizer.Add(self.plot_references_image, 10, wx.ALL | wx.EXPAND, 5)
+ plot_references_sizer.Add(
+ self.plot_references_setting, 100, wx.ALL | wx.EXPAND, 5
+ )
+
+ ##### LCSC priority #####
+
+ self.lcsc_priority_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="LCSC number priority",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="general_lcsc_priority",
+ )
+
+ self.lcsc_priority_setting.SetToolTip(
+ wx.ToolTip(
+ "Whether LCSC number from schematic should overrule those in the database"
+ )
+ )
+
+ self.lcsc_priority_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("schematic.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.lcsc_priority_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ lcsc_priority_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ lcsc_priority_sizer.Add(self.lcsc_priority_image, 10, wx.ALL | wx.EXPAND, 5)
+ lcsc_priority_sizer.Add(self.lcsc_priority_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Only parts with LCSC number in BOM/CPL #####
+
+ self.lcsc_bom_cpl_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Add parts without LCSC numbers to BOM/CPL",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="gerber_lcsc_bom_cpl",
+ )
+
+ self.lcsc_bom_cpl_setting.SetToolTip(
+ wx.ToolTip("Whether parts wihout LCSC number should be added to BOM/CPL")
+ )
+
+ self.lcsc_bom_cpl_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("bom.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.lcsc_bom_cpl_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ lcsc_bom_cpl_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ lcsc_bom_cpl_sizer.Add(self.lcsc_bom_cpl_image, 10, wx.ALL | wx.EXPAND, 5)
+ lcsc_bom_cpl_sizer.Add(self.lcsc_bom_cpl_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Check if order/serial number placeholder is present #####
+
+ self.order_number_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Check if an order/serial number placeholder is placed",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="general_order_number",
+ )
+
+ self.order_number_setting.SetToolTip(
+ wx.ToolTip("Is an order/serial number placeholder placed")
+ )
+
+ self.order_number_image = wx.StaticBitmap(
+ self,
+ wx.ID_ANY,
+ loadBitmapScaled("order_number.png", self.parent.scale_factor, static=True),
+ wx.DefaultPosition,
+ wx.DefaultSize,
+ 0,
+ )
+
+ self.order_number_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ order_number_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ order_number_sizer.Add(self.order_number_image, 10, wx.ALL | wx.EXPAND, 5)
+ order_number_sizer.Add(self.order_number_setting, 100, wx.ALL | wx.EXPAND, 5)
+
+ ##### Highlight text matches in part selector ######
+
+ highlight_matches_label = wx.StaticText(
+ self,
+ id=wx.ID_ANY,
+ label="Part selector highlighting",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ )
+
+ self.highlight_matches_setting = wx.CheckBox(
+ self,
+ id=wx.ID_ANY,
+ label="Highlight search matches",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=0,
+ name="partselector_highlight_matches",
+ )
+
+ self.highlight_matches_setting.SetToolTip(
+ wx.ToolTip("Highlight keyword matches in the part selector search results")
+ )
+
+ self.highlight_matches_setting.Bind(wx.EVT_CHECKBOX, self.update_settings)
+
+ highlight_matches_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ highlight_matches_sizer.Add(
+ highlight_matches_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5
+ )
+ highlight_matches_sizer.Add(
+ self.highlight_matches_setting, 0, wx.ALL | wx.EXPAND, 5
+ )
+
+ ##### Library Selection #####
+
+ library_label = wx.StaticText(
+ self,
+ id=wx.ID_ANY,
+ label="Parts Library:",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ )
+
+ library_choices = [config.display_name for config in LIBRARY_CONFIGS.values()]
+ self.library_selected_setting = wx.ComboBox(
+ self,
+ id=wx.ID_ANY,
+ value="",
+ choices=library_choices,
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=wx.CB_READONLY,
+ name="library_selected_library",
+ )
+
+ self.library_selected_setting.SetToolTip(
+ wx.ToolTip("Select which parts library to use")
+ )
+
+ self.library_selected_setting.Bind(wx.EVT_COMBOBOX, self.update_settings)
+
+ library_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ library_sizer.Add(library_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
+ library_sizer.Add(self.library_selected_setting, 1, wx.ALL | wx.EXPAND, 5)
+
+ ##### Library Data Directory #####
+
+ library_data_path_label = wx.StaticText(
+ self,
+ id=wx.ID_ANY,
+ label="Database directory:",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ )
+
+ self.library_data_path_setting = wx.DirPickerCtrl(
+ self,
+ id=wx.ID_ANY,
+ path="",
+ message="Choose folder for global library database files",
+ pos=wx.DefaultPosition,
+ size=wx.DefaultSize,
+ style=wx.DIRP_DEFAULT_STYLE | wx.DIRP_USE_TEXTCTRL,
+ name="library_data_path",
+ )
+
+ self.library_data_path_setting.SetToolTip(
+ wx.ToolTip(
+ "Override where the global library database files are stored."
+ " If you change this, you may want to copy existing mapping and"
+ " corrections files from the old location to the new one to avoid"
+ " losing existing mappings and corrections."
+ )
+ )
+
+ self.library_data_path_setting.Bind(
+ wx.EVT_DIRPICKER_CHANGED, self.update_settings
+ )
+
+ library_data_path_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ library_data_path_sizer.Add(
+ library_data_path_label, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5
+ )
+ library_data_path_sizer.Add(
+ self.library_data_path_setting, 1, wx.ALL | wx.EXPAND, 5
+ )
+
+ # ---------------------------------------------------------------------
+ # ---------------------- Main Layout Sizer ----------------------------
+ # ---------------------------------------------------------------------
+
+ layout = wx.GridSizer(12, 2, 0, 0)
+ layout.Add(tented_vias_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(fill_zones_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(force_drc_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(plot_values_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(plot_references_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(lcsc_priority_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(lcsc_bom_cpl_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(order_number_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(highlight_matches_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(library_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ layout.Add(library_data_path_sizer, 0, wx.ALL | wx.EXPAND, 5)
+ self.SetSizer(layout)
+ self.Layout()
+ self.Centre(wx.BOTH)
+
+ self.load_settings()
+
+ def update_tented_vias(self, tented):
+ """Update settings dialog according to the settings."""
+ if tented:
+ self.tented_vias_setting.SetValue(tented)
+ self.tented_vias_setting.SetLabel("Tented vias")
+ self.tented_vias_image.SetBitmap(
+ loadBitmapScaled("tented.png", self.parent.scale_factor, static=True)
+ )
+ else:
+ self.tented_vias_setting.SetValue(tented)
+ self.tented_vias_setting.SetLabel("Untented vias")
+ self.tented_vias_image.SetBitmap(
+ loadBitmapScaled("untented.png", self.parent.scale_factor, static=True)
+ )
+
+ def update_fill_zones(self, fill):
+ """Update settings dialog according to the settings."""
+ if fill:
+ self.fill_zones_setting.SetValue(fill)
+ self.fill_zones_setting.SetLabel("Fill zones")
+ self.fill_zones_image.SetBitmap(
+ loadBitmapScaled(
+ "fill-zones.png", self.parent.scale_factor, static=True
+ )
+ )
+ else:
+ self.fill_zones_setting.SetValue(fill)
+ self.fill_zones_setting.SetLabel("Don't fill zones")
+ self.fill_zones_image.SetBitmap(
+ loadBitmapScaled(
+ "unfill-zones.png", self.parent.scale_factor, static=True
+ )
+ )
+
+ def build_force_drc_bitmap(self, enabled):
+ """Build the Force DRC icon, overlaying a red X when disabled."""
+ bitmap = loadBitmapScaled(
+ "bug-check-outline.png", self.parent.scale_factor, static=True
+ )
+ if enabled:
+ return bitmap
+
+ return self.create_disabled_bitmap(bitmap)
+
+ def create_disabled_bitmap(self, bitmap):
+ """Create a disabled-state bitmap by drawing a red X over it."""
+ disabled_bitmap = bitmap.ConvertToImage().ConvertToBitmap()
+ memory_dc = wx.MemoryDC()
+ memory_dc.SelectObject(disabled_bitmap)
+ try:
+ pen_width = max(2, int(round(self.parent.scale_factor * 2)))
+ margin = max(2, int(round(self.parent.scale_factor * 3)))
+ width, height = disabled_bitmap.GetSize()
+ memory_dc.SetPen(wx.Pen(wx.Colour(220, 0, 0), width=pen_width))
+ memory_dc.DrawLine(margin, margin, width - margin, height - margin)
+ memory_dc.DrawLine(margin, height - margin, width - margin, margin)
+ finally:
+ memory_dc.SelectObject(wx.NullBitmap)
+
+ return disabled_bitmap
+
+ def update_plot_values(self, plot_values):
+ """Update settings dialog according to the settings."""
+ if plot_values:
+ self.plot_values_setting.SetValue(plot_values)
+ self.plot_values_setting.SetLabel("Plot values on silkscreen")
+ self.plot_values_image.SetBitmap(
+ loadBitmapScaled(
+ "plot_values.png", self.parent.scale_factor, static=True
+ )
+ )
+ else:
+ self.plot_values_setting.SetValue(plot_values)
+ self.plot_values_setting.SetLabel("Don't plot values on silkscreen")
+ self.plot_values_image.SetBitmap(
+ loadBitmapScaled("no_values.png", self.parent.scale_factor, static=True)
+ )
+
+ def update_force_drc(self, force_drc):
+ """Update settings dialog according to the settings."""
+ self.force_drc_setting.SetValue(bool(force_drc))
+ self.force_drc_image.SetBitmap(self.build_force_drc_bitmap(bool(force_drc)))
+ if force_drc:
+ self.force_drc_setting.SetLabel(
+ "Force DRC check before Gerber export - Saves board and fills zones!"
+ )
+ self.update_fill_zones(True)
+ self.fill_zones_setting.Disable()
+ else:
+ self.force_drc_setting.SetLabel(
+ "Do not force DRC check before Gerber export"
+ )
+ self.fill_zones_setting.Enable()
+
+ def update_plot_references(self, plot_references):
+ """Update settings dialog according to the settings."""
+ if plot_references:
+ self.plot_references_setting.SetValue(plot_references)
+ self.plot_references_setting.SetLabel("Plot references on silkscreen")
+ self.plot_references_image.SetBitmap(
+ loadBitmapScaled("plot_refs.png", self.parent.scale_factor, static=True)
+ )
+ else:
+ self.plot_references_setting.SetValue(plot_references)
+ self.plot_references_setting.SetLabel("Don't plot references on silkscreen")
+ self.plot_references_image.SetBitmap(
+ loadBitmapScaled("no_refs.png", self.parent.scale_factor, static=True)
+ )
+
+ def update_lcsc_priority(self, priority):
+ """Update settings dialog according to the settings."""
+ if priority:
+ self.lcsc_priority_setting.SetValue(priority)
+ self.lcsc_priority_setting.SetLabel(
+ "LCSC numbers from schematic have priority"
+ )
+ self.lcsc_priority_image.SetBitmap(
+ loadBitmapScaled("schematic.png", self.parent.scale_factor, static=True)
+ )
+ else:
+ self.lcsc_priority_setting.SetValue(priority)
+ self.lcsc_priority_setting.SetLabel(
+ "LCSC numbers from database have priority"
+ )
+ self.lcsc_priority_image.SetBitmap(
+ loadBitmapScaled(
+ "database-outline.png", self.parent.scale_factor, static=True
+ )
+ )
+
+ def update_lcsc_bom_cpl(self, add):
+ """Update settings dialog according to the settings."""
+ if add:
+ self.lcsc_bom_cpl_setting.SetValue(add)
+ self.lcsc_bom_cpl_setting.SetLabel(
+ "Add parts without LCSC number to BOM/POS"
+ )
+ self.lcsc_bom_cpl_image.SetBitmap(
+ loadBitmapScaled("bom.png", self.parent.scale_factor, static=True)
+ )
+ else:
+ self.lcsc_bom_cpl_setting.SetValue(add)
+ self.lcsc_bom_cpl_setting.SetLabel(
+ "Don't add parts without LCSC number to BOM/POS"
+ )
+ self.lcsc_bom_cpl_image.SetBitmap(
+ loadBitmapScaled("no_bom.png", self.parent.scale_factor, static=True)
+ )
+
+ def update_order_number(self, check):
+ """Update settings dialog according to the settings."""
+ self.logger.debug(check)
+ if check:
+ self.order_number_setting.SetValue(check)
+ self.order_number_setting.SetLabel(
+ "Check if an order/serial number placeholder is placed"
+ )
+ self.order_number_image.SetBitmap(
+ loadBitmapScaled(
+ "order_number.png", self.parent.scale_factor, static=True
+ )
+ )
+ else:
+ self.order_number_setting.SetValue(check)
+ self.order_number_setting.SetLabel(
+ "Don't check if an order/serial number placeholder is placed"
+ )
+ self.order_number_image.SetBitmap(
+ loadBitmapScaled(
+ "no_order_number.png", self.parent.scale_factor, static=True
+ )
+ )
+
+ def update_highlight_matches(self, enabled):
+ """Update settings dialog according to the settings."""
+ self.highlight_matches_setting.SetValue(bool(enabled))
+ if enabled:
+ self.highlight_matches_setting.SetLabel("Highlight search matches")
+ else:
+ self.highlight_matches_setting.SetLabel("Do not highlight search matches")
+
+ def load_settings(self):
+ """Load settings and set checkboxes accordingly."""
+ self.update_tented_vias(
+ self.parent.settings.get("gerber", {}).get("tented_vias", True)
+ )
+ self.update_fill_zones(
+ self.parent.settings.get("gerber", {}).get("fill_zones", True)
+ )
+ self.update_force_drc(
+ self.parent.settings.get("gerber", {}).get("force_drc", False)
+ )
+ self.update_plot_values(
+ self.parent.settings.get("gerber", {}).get("plot_values", True)
+ )
+ self.update_plot_references(
+ self.parent.settings.get("gerber", {}).get("plot_references", True)
+ )
+ self.update_lcsc_priority(
+ self.parent.settings.get("general", {}).get("lcsc_priority", True)
+ )
+ self.update_lcsc_bom_cpl(
+ self.parent.settings.get("gerber", {}).get("lcsc_bom_cpl", True)
+ )
+ self.update_order_number(
+ self.parent.settings.get("general", {}).get("order_number", True)
+ )
+ self.update_highlight_matches(
+ self.parent.settings.get("partselector", {}).get("highlight_matches", True)
+ )
+ self.update_selected_library(
+ self.parent.settings.get("library", {}).get(
+ "selected_library", "current-parts"
+ )
+ )
+ self.update_data_path(
+ self.parent.settings.get("library", {}).get("data_path", "")
+ )
+
+ def update_selected_library(self, library_key):
+ """Update settings dialog according to the selected library."""
+ if library_key in LIBRARY_CONFIGS:
+ display_name = LIBRARY_CONFIGS[library_key].display_name
+ self.library_selected_setting.SetStringSelection(display_name)
+
+ def update_data_path(self, data_path):
+ """Update settings dialog according to the configured data path."""
+ value = data_path.strip() if isinstance(data_path, str) else ""
+ effective_path = value if value else self.parent.library.datadir
+ self.library_data_path_setting.SetPath(effective_path)
+
+ def update_settings(self, event):
+ """Update and persist a setting that was changed."""
+ section, name = event.GetEventObject().GetName().split("_", 1)
+ if hasattr(event.GetEventObject(), "GetPath"):
+ value = event.GetEventObject().GetPath()
+ else:
+ value = event.GetEventObject().GetValue()
+ self.logger.debug(section)
+ self.logger.debug(name)
+ self.logger.debug(value)
+
+ # Special handling for library selection: convert display name back to key
+ if section == "library" and name == "selected_library":
+ # Find the key for this display name
+ for key, config in LIBRARY_CONFIGS.items():
+ if config.display_name == value:
+ self.logger.debug("Selected library key: %s", key)
+ value = key
+ break
+
+ # If forced DRC is enabled, fill zones must stay enabled.
+ if (
+ section == "gerber"
+ and name == "fill_zones"
+ and self.force_drc_setting.GetValue()
+ ):
+ value = True
+
+ getattr(self, f"update_{name}")(value)
+
+ # Turning on forced DRC implies enabling fill zones.
+ if section == "gerber" and name == "force_drc" and value:
+ wx.PostEvent(
+ self.parent,
+ UpdateSetting(
+ section="gerber",
+ setting="fill_zones",
+ value=True,
+ ),
+ )
+
+ wx.PostEvent(
+ self.parent,
+ UpdateSetting(
+ section=section,
+ setting=name,
+ value=value,
+ ),
+ )
+
+ def quit_dialog(self, *_):
+ """Close this dialog."""
+ self.Destroy()
+ self.EndModal(0)
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py b/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py
new file mode 100644
index 00000000..f63d7bc1
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/standalone_impl.py
@@ -0,0 +1,134 @@
+"""Stubs for standalone usage of the plugin."""
+
+
+class LIB_ID_Stub:
+ """Implementation of pcbnew.LIB_ID."""
+
+ def __init__(self, item_name):
+ self.item_name = item_name
+
+ def GetLibItemName(self) -> str:
+ """Item name."""
+ return self.item_name
+
+
+class Field_Stub:
+ """Implementation of pcbnew.Field."""
+
+ def __init__(self, name, text):
+ self.name = name
+ self.text = text
+
+ def GetName(self) -> str:
+ """Field name."""
+ return self.name
+
+ def GetText(self) -> str:
+ """Field text."""
+ return self.text
+
+ def SetVisible(self, visible):
+ """Set the field visibility."""
+ pass
+
+
+class Footprint_Stub:
+ """Implementation of pcbnew.Footprint."""
+
+ def __init__(self, reference, value, fpid):
+ self.reference = reference
+ self.value = value
+ self.fpid = fpid
+
+ def GetReference(self) -> str:
+ """Retrieve the reference designator string."""
+ return self.reference
+
+ def GetValue(self) -> str:
+ """Value string."""
+ return self.value
+
+ def GetFPID(self) -> LIB_ID_Stub:
+ """Footprint LIB_ID."""
+ return self.fpid
+
+ def GetProperties(self) -> dict:
+ """Properties."""
+ return {}
+
+ def GetAttributes(self) -> int:
+ """Attributes."""
+ return 0
+
+ def GetFields(self) -> list:
+ """Fields."""
+ return []
+
+ def SetField(self, name, text):
+ """Set a field."""
+ pass
+
+ def GetFieldByName(self, name) -> Field_Stub:
+ """Get a field by name."""
+ return Field_Stub(name, "stub")
+
+ def GetLayer(self) -> int:
+ """Layer number."""
+ # TODO: maybe this is defined in a python module we can import and reuse here?
+ return 3 # F_Cu, see https://docs.kicad.org/doxygen/layer__ids_8h.html#ae0ad6e574332a997f501d1b091c3f53f
+
+ def SetSelected(self):
+ """Select this item."""
+
+
+class BoardStub:
+ """Implementation of pcbnew.Board."""
+
+ def __init__(self):
+ self.footprints = []
+ self.footprints.append(Footprint_Stub("R1", "100", LIB_ID_Stub("resistors")))
+
+ def GetFileName(self):
+ """Board filename."""
+ return "fake_test_board.kicad_pcb"
+
+ def GetFootprints(self):
+ """Footprint list."""
+ return self.footprints
+
+ def FindFootprintByReference(self, reference):
+ """Get a list of footprints that match a reference."""
+ return Footprint_Stub(reference, "stub", 100)
+
+
+class PcbnewStub:
+ """Stub implementation of pcbnew."""
+
+ def __init__(self):
+ self.board = BoardStub()
+
+ def GetBoard(self):
+ """Get the board."""
+ return self.board
+
+ def GetBuildVersion(self):
+ """Get the kicad build version."""
+ return "8.0.1"
+
+ def GetCurrentSelection(self):
+ """Get the currently selected board items."""
+ return []
+
+ def Refresh(self):
+ """Redraw the screen."""
+
+
+class KicadStub:
+ """Stub implementation of Kicad."""
+
+ def __init__(self):
+ self.pcbnew = PcbnewStub()
+
+ def get_pcbnew(self):
+ """Get the pcbnew stub."""
+ return self.pcbnew
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/store.py b/third_party/Bouni/kicad-jlcpcb-tools/store.py
new file mode 100644
index 00000000..3111a185
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/store.py
@@ -0,0 +1,262 @@
+"""Contains the data storge for a project."""
+
+import contextlib
+import csv
+import logging
+import os
+from pathlib import Path
+import sqlite3
+from typing import Union
+
+from .helpers import (
+ dict_factory,
+ get_exclude_from_bom,
+ get_exclude_from_pos,
+ get_lcsc_value,
+ get_valid_footprints,
+ natural_sort_collation,
+)
+
+
+class Store:
+ """A storage class to get data from a sqlite database and write it back."""
+
+ def __init__(self, parent, project_path, board):
+ self.logger = logging.getLogger(__name__)
+ self.parent = parent
+ self.project_path = project_path
+ self.board = board
+ self.datadir = os.path.join(self.project_path, "jlcpcb")
+ self.dbfile = os.path.join(self.datadir, "project.db")
+ self.order_by = "reference"
+ self.order_dir = "ASC"
+ self.setup()
+ self.update_from_board()
+
+ def setup(self):
+ """Check if folders and database exist, setup if not."""
+ if not os.path.isdir(self.datadir):
+ self.logger.info(
+ "Data directory 'jlcpcb' does not exist and will be created."
+ )
+ Path(self.datadir).mkdir(parents=True, exist_ok=True)
+ self.create_db()
+
+ def set_order_by(self, n: int):
+ """Set which value we want to order by when getting data from the database."""
+ if n > 7:
+ return
+ # The following two cases are just a temporary hack and will eventually be replaced by
+ # direct sorting via DataViewListCtrl rather than via SQL query
+ if n == 4:
+ return
+ if n > 4:
+ n = n - 1
+ order_by = [
+ "reference",
+ "value",
+ "footprint",
+ "lcsc",
+ "stock",
+ "exclude_from_bom",
+ "exclude_from_pos",
+ ]
+ if self.order_by == order_by[n] and self.order_dir == "ASC":
+ self.order_dir = "DESC"
+ else:
+ self.order_by = order_by[n]
+ self.order_dir = "ASC"
+
+ def create_db(self):
+ """Create the sqlite database tables."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "CREATE TABLE IF NOT EXISTS part_info ("
+ "reference NOT NULL PRIMARY KEY,"
+ "value TEXT NOT NULL,"
+ "footprint TEXT NOT NULL,"
+ "lcsc TEXT,"
+ "stock NUMERIC,"
+ "exclude_from_bom NUMERIC DEFAULT 0,"
+ "exclude_from_pos NUMERIC DEFAULT 0"
+ ")",
+ )
+ cur.commit()
+
+ def read_all(self) -> dict:
+ """Read all parts from the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ con.create_collation("naturalsort", natural_sort_collation)
+ con.row_factory = dict_factory
+ return cur.execute(
+ f"SELECT * FROM part_info ORDER BY {self.order_by} COLLATE naturalsort {self.order_dir}"
+ ).fetchall()
+
+ def read_bom_parts(self) -> dict:
+ """Read all parts that should be included in the BOM."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ con.row_factory = dict_factory
+ # Query all parts that are supposed to be in the BOM an have an lcsc number, group the references together
+ subquery = "SELECT value, reference, footprint, lcsc FROM part_info WHERE exclude_from_bom = '0' AND lcsc != '' ORDER BY lcsc, reference"
+ query = f"SELECT value, GROUP_CONCAT(reference) AS refs, footprint, lcsc FROM ({subquery}) GROUP BY value, lcsc"
+ a = cur.execute(query).fetchall()
+ # Query all parts that are supposed to be in the BOM but have no lcsc number
+ query = "SELECT value, reference AS refs, footprint, lcsc FROM part_info WHERE exclude_from_bom = '0' AND lcsc = ''"
+ b = cur.execute(query).fetchall()
+ return a + b
+
+ def create_part(self, part: dict):
+ """Create a part in the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "INSERT INTO part_info VALUES (:reference, :value, :footprint, :lcsc, '', :exclude_from_bom, :exclude_from_pos)",
+ part,
+ )
+ cur.commit()
+
+ def update_part(self, part: dict):
+ """Update a part in the database, overwrite lcsc if supplied."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "UPDATE part_info set value = :value, footprint = :footprint, lcsc = :lcsc, exclude_from_bom = :exclude_from_bom, exclude_from_pos = :exclude_from_pos WHERE reference = :reference",
+ part,
+ )
+ cur.commit()
+
+ def get_part(self, ref: str) -> dict:
+ """Get a part from the database by its reference."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ con.row_factory = dict_factory
+ return cur.execute(
+ "SELECT * FROM part_info WHERE reference = :reference",
+ {"reference": ref},
+ ).fetchone()
+
+ def set_stock(self, ref: str, stock: Union[int, None]):
+ """Set the stock value for a part in the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "UPDATE part_info SET stock = :stock WHERE reference = :reference",
+ {"reference": ref, "stock": stock},
+ )
+ cur.commit()
+
+ def set_bom(self, ref: str, state: int):
+ """Change the BOM attribute for a part in the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "UPDATE part_info SET exclude_from_bom = :state WHERE reference = :reference",
+ {"reference": ref, "state": state},
+ )
+ cur.commit()
+
+ def set_pos(self, ref: str, state: int):
+ """Change the POS attribute for a part in the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "UPDATE part_info SET exclude_from_pos = :state WHERE reference = :reference",
+ {"reference": ref, "state": state},
+ )
+ cur.commit()
+
+ def set_lcsc(self, ref: str, lcsc: str):
+ """Change the LCSC attribute for a part in the database."""
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ "UPDATE part_info SET lcsc = :lcsc WHERE reference = :reference",
+ {"reference": ref, "lcsc": lcsc},
+ )
+ cur.commit()
+
+ def update_from_board(self):
+ """Read all footprints from the board and insert them into the database if they do not exist."""
+ for fp in get_valid_footprints(self.board):
+ board_part = {
+ "reference": fp.GetReference(),
+ "value": fp.GetValue(),
+ "footprint": str(fp.GetFPID().GetLibItemName()),
+ "lcsc": get_lcsc_value(fp),
+ "exclude_from_bom": get_exclude_from_bom(fp),
+ "exclude_from_pos": get_exclude_from_pos(fp),
+ }
+ db_part = self.get_part(board_part["reference"])
+ # if part is not in the database yet, create it
+ if not db_part:
+ self.logger.debug(
+ "Part %s does not exist in the database and will be created from the board.",
+ board_part["reference"],
+ )
+ self.create_part(board_part)
+ # if the board part matches the db_part except for the LCSC and the stock value
+ elif [
+ board_part["reference"],
+ board_part["value"],
+ board_part["footprint"],
+ board_part["exclude_from_bom"],
+ board_part["exclude_from_pos"],
+ ] == [
+ db_part["reference"],
+ db_part["value"],
+ db_part["footprint"],
+ bool(db_part["exclude_from_bom"]),
+ bool(db_part["exclude_from_pos"]),
+ ]:
+ # if part in the database, has no lcsc value the board part has a lcsc value, update including lcsc
+ if db_part and not db_part["lcsc"] and board_part["lcsc"]:
+ self.logger.debug(
+ "Part %s is already in the database but without lcsc value, so the value supplied from the board will be set.",
+ board_part["reference"],
+ )
+ self.update_part(board_part)
+ # if part in the database, has a lcsc value
+ elif db_part and db_part["lcsc"] and board_part["lcsc"]:
+ # update lcsc value as well if setting is accordingly
+ if not self.parent.settings.get("general", {}).get(
+ "lcsc_priority", True
+ ):
+ self.logger.debug(
+ "Part %s is already in the database and has a lcsc value, the value supplied from the board will be ignored.",
+ board_part["reference"],
+ )
+ board_part["lcsc"] = db_part["lcsc"]
+ else:
+ self.logger.debug(
+ "Part %s is already in the database and has a lcsc value, the value supplied from the board will overwrite that in the database.",
+ board_part["reference"],
+ )
+ self.update_part(board_part)
+ else:
+ # If something changed, we overwrite the part and dump the lcsc value or use the one supplied by the board
+ self.logger.debug(
+ "Part %s is already in the database but value, footprint, bom or pos values changed in the board file, part will be updated, lcsc overwritten/cleared.",
+ board_part["reference"],
+ )
+ self.update_part(board_part)
+ self.import_legacy_assignments()
+ self.clean_database()
+
+ def clean_database(self):
+ """Delete all parts from the database that are no longer present on the board."""
+ refs = [f"'{fp.GetReference()}'" for fp in get_valid_footprints(self.board)]
+ with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
+ cur.execute(
+ f"DELETE FROM part_info WHERE reference NOT IN ({','.join(refs)})"
+ )
+ cur.commit()
+
+ def import_legacy_assignments(self):
+ """Check if assignments of an old version are found and merge them into the database."""
+ csv_file = os.path.join(self.project_path, "jlcpcb", "part_assignments.csv")
+ if os.path.isfile(csv_file):
+ with open(csv_file, encoding="utf-8") as f:
+ csvreader = csv.DictReader(
+ f, fieldnames=("reference", "lcsc", "bom", "pos")
+ )
+ for row in csvreader:
+ self.set_lcsc(row["reference"], row["lcsc"])
+ self.set_bom(row["reference"], int(row["bom"]))
+ self.set_pos(row["reference"], int(row["pos"]))
+ self.logger.debug(
+ "Update %s from legacy 'part_assignments.csv'", row["reference"]
+ )
+ os.rename(csv_file, f"{csv_file}.backup")
diff --git a/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py b/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py
new file mode 100644
index 00000000..0ce7602c
--- /dev/null
+++ b/third_party/Bouni/kicad-jlcpcb-tools/unzip_parts.py
@@ -0,0 +1,66 @@
+#!/bin/env python3
+"""Module for unziping and merging split db zip file."""
+
+import logging
+import os
+from zipfile import ZipFile
+
+import wx # pylint: disable=import-error
+
+from .events import (
+ UnzipCombiningProgressEvent,
+ UnzipCombiningStartedEvent,
+ UnzipExtractingCompletedEvent,
+ UnzipExtractingProgressEvent,
+ UnzipExtractingStartedEvent,
+)
+
+
+def unzip_parts(parent, path, zipfile: str) -> None:
+ """Unzip and merge split zip file."""
+ logger = logging.getLogger(__name__)
+ logger.debug("Combine zip chunks")
+ wx.PostEvent(parent, UnzipCombiningStartedEvent())
+ # unzip (needs to go into download function finally)
+ # Set the name of the original file
+ db_zip_file = os.path.join(path, zipfile)
+
+ # Open the original file for writing
+ with open(db_zip_file, "wb") as db:
+ # Get a list of the split files in the split directory
+ split_files = [f for f in os.listdir(path) if f.startswith(f"{zipfile}.")]
+
+ # Sort the split files by their index
+ split_files.sort(key=lambda f: int(f.split(".")[-1]))
+
+ # Iterate over the split files and append their contents to the original file
+ for i, split_file_name in enumerate(split_files, 1):
+ split_path = os.path.join(path, split_file_name)
+ # Open the split file
+ with open(split_path, "rb") as split_file:
+ # Read the file data
+ while file_data := split_file.read(1024 * 1024):
+ # Append the file data to the original file
+ db.write(file_data)
+
+ # Delete the split file
+ os.unlink(split_path)
+ progress = 100 / len(split_files) * i
+ wx.PostEvent(parent, UnzipCombiningProgressEvent(value=progress))
+
+ with ZipFile(db_zip_file, "r") as zf:
+ logger.debug("Extract zip file")
+ wx.PostEvent(parent, UnzipExtractingStartedEvent())
+ file_info = zf.infolist()[0]
+ file_size = file_info.file_size
+ with (
+ zf.open(file_info) as source,
+ open(os.path.join(path, file_info.filename), "wb") as target,
+ ):
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ target.write(chunk)
+ progress = target.tell() / file_size * 100
+ wx.PostEvent(parent, UnzipExtractingProgressEvent(value=progress))
+
+ os.unlink(db_zip_file)
+ wx.PostEvent(parent, UnzipExtractingCompletedEvent())
diff --git a/tools/third_party_sync/repos.lock.json b/tools/third_party_sync/repos.lock.json
index 25979bfb..822a953e 100644
--- a/tools/third_party_sync/repos.lock.json
+++ b/tools/third_party_sync/repos.lock.json
@@ -1,3 +1,11 @@
{
- "repositories": {}
+ "repositories": {
+ "kicad-jlcpcb-tools": {
+ "published_at": "2026-04-14T07:16:06Z",
+ "resolved_commit": "de777bc9c458070dc6177134825bb6233fcd8aa6",
+ "resolved_release": "2026.04.02",
+ "source": "release",
+ "tarball_url": "https://api.github.com/repos/Bouni/kicad-jlcpcb-tools/tarball/2026.04.02"
+ }
+ }
}