Update package version to 0.0.1-beta, add new dependencies including ExcelJS, and refactor export utilities to utilize ExcelJS for Excel file generation. Enhance component JSON files with vendor information for improved asset management.
This commit is contained in:
176
.github/workflows/check-vendor.yml
vendored
Normal file
176
.github/workflows/check-vendor.yml
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
name: Check Vendor Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
# Allow manual triggering
|
||||
|
||||
jobs:
|
||||
check-vendor:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd scripts
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Generate manifest from site data
|
||||
run: |
|
||||
python scripts/generate_manifest_from_site.py \
|
||||
--site-dir website/src/data/components \
|
||||
--manifest manifest/vendor_manifest.json
|
||||
|
||||
- name: Check for updates
|
||||
id: check-updates
|
||||
env:
|
||||
GITHUB_API_TOKEN: ${{ secrets.GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
python scripts/check_updates.py \
|
||||
--manifest manifest/vendor_manifest.json \
|
||||
--output report.json || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Read update report
|
||||
id: read-report
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f report.json ]; then
|
||||
OUT_OF_DATE=$(python -c "import json; r=json.load(open('report.json')); print(r.get('out_of_date', 0))")
|
||||
echo "out_of_date=$OUT_OF_DATE" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=$([ $OUT_OF_DATE -gt 0 ] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "out_of_date=0" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get out-of-date entry IDs
|
||||
id: get-entries
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
python -c "
|
||||
import json
|
||||
with open('report.json') as f:
|
||||
report = json.load(f)
|
||||
entries = [e['id'] for e in report['entries'] if e.get('status') == 'out-of-date']
|
||||
entry_ids = ','.join(entries)
|
||||
print(f'entry_ids={entry_ids}')
|
||||
" >> $GITHUB_OUTPUT || echo "entry_ids=" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create update branch
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
ENTRY_IDS=$(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' '-' | cut -c1-50)
|
||||
BRANCH_NAME="vendor-update/${TIMESTAMP}-${ENTRY_IDS}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Update vendored files
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
env:
|
||||
GITHUB_API_TOKEN: ${{ secrets.GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
ENTRY_IDS="${{ steps.get-entries.outputs.entry_ids }}"
|
||||
for entry_id in $(echo "$ENTRY_IDS" | tr ',' ' '); do
|
||||
echo "Updating entry: $entry_id"
|
||||
python scripts/vendor_update.py \
|
||||
--manifest manifest/vendor_manifest.json \
|
||||
--entry "$entry_id" \
|
||||
--sync-site
|
||||
done
|
||||
|
||||
- name: Run site build (if available)
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
if [ -f website/package.json ]; then
|
||||
cd website
|
||||
npm ci || npm install
|
||||
npm run build || echo "Build failed but continuing..."
|
||||
else
|
||||
echo "No website build step found, skipping..."
|
||||
fi
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
git add manifest/vendor_manifest.json vendor/ website/src/data/components/
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update vendored files
|
||||
|
||||
Updated $(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' ' ' | wc -w) vendored file(s):
|
||||
$(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' '\n' | sed 's/^/ - /')
|
||||
|
||||
Auto-generated by check-vendor workflow"
|
||||
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ env.BRANCH_NAME }}
|
||||
title: "chore: Update vendored files"
|
||||
body: |
|
||||
## Vendor Update
|
||||
|
||||
This PR updates vendored files that have changed upstream.
|
||||
|
||||
**Updated entries:**
|
||||
${{ steps.get-entries.outputs.entry_ids }}
|
||||
|
||||
**Report:**
|
||||
- Total entries checked: ${{ steps.read-report.outputs.out_of_date }}
|
||||
- Out-of-date entries: ${{ steps.read-report.outputs.out_of_date }}
|
||||
|
||||
### Changes
|
||||
- Updated manifest with new commit SHAs
|
||||
- Downloaded latest versions of changed files
|
||||
- Synced vendor metadata to site component JSON files
|
||||
|
||||
### Verification
|
||||
- [ ] Manifest updated correctly
|
||||
- [ ] Files downloaded and checksums verified
|
||||
- [ ] Site JSON files updated with vendor metadata
|
||||
- [ ] Site build passes (if applicable)
|
||||
|
||||
---
|
||||
*This PR was automatically created by the check-vendor workflow.*
|
||||
labels: |
|
||||
automated
|
||||
vendor-update
|
||||
draft: false
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.read-report.outputs.has_updates }}" == "true" ]; then
|
||||
echo "## ✅ Updates Available" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Found ${{ steps.read-report.outputs.out_of_date }} out-of-date entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Created PR: vendor-update/${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ All Up-to-Date" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All vendored files are up-to-date with upstream." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
180
api/github_webhook/index.py
Executable file
180
api/github_webhook/index.py
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitHub webhook handler for detecting upstream changes.
|
||||
|
||||
Receives push events from GitHub and triggers vendor update checks
|
||||
for affected repositories and paths.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def verify_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
|
||||
"""Verify GitHub webhook signature."""
|
||||
if not secret:
|
||||
return False
|
||||
|
||||
hash_object = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
msg=payload_body,
|
||||
digestmod=hashlib.sha256
|
||||
)
|
||||
expected_signature = "sha256=" + hash_object.hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected_signature, signature_header)
|
||||
|
||||
|
||||
def load_manifest(manifest_path: Path) -> List[Dict]:
|
||||
"""Load vendor manifest."""
|
||||
if not manifest_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and 'entries' in data:
|
||||
return data['entries']
|
||||
return []
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return []
|
||||
|
||||
|
||||
def find_affected_entries(push_event: Dict, manifest: List[Dict]) -> List[str]:
|
||||
"""
|
||||
Find manifest entries affected by a push event.
|
||||
Returns list of manifest entry IDs.
|
||||
"""
|
||||
affected = []
|
||||
|
||||
repo_full_name = push_event.get('repository', {}).get('full_name')
|
||||
if not repo_full_name:
|
||||
return affected
|
||||
|
||||
commits = push_event.get('commits', [])
|
||||
changed_files = set()
|
||||
|
||||
for commit in commits:
|
||||
changed_files.update(commit.get('added', []))
|
||||
changed_files.update(commit.get('modified', []))
|
||||
changed_files.update(commit.get('removed', []))
|
||||
|
||||
# Match changed files against manifest entries
|
||||
for entry in manifest:
|
||||
source_repo = entry.get('source_repo')
|
||||
source_path = entry.get('source_path')
|
||||
|
||||
if source_repo == repo_full_name and source_path in changed_files:
|
||||
affected.append(entry['id'])
|
||||
|
||||
return affected
|
||||
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Handle GitHub webhook POST requests."""
|
||||
# Get webhook secret from environment
|
||||
webhook_secret = os.getenv('WEBHOOK_SECRET')
|
||||
|
||||
# Verify signature if secret is configured
|
||||
if webhook_secret:
|
||||
signature = request.headers.get('X-Hub-Signature-256', '')
|
||||
if not verify_signature(request.data, signature, webhook_secret):
|
||||
return jsonify({'error': 'Invalid signature'}), 401
|
||||
|
||||
# Parse event
|
||||
try:
|
||||
event = request.json
|
||||
event_type = request.headers.get('X-GitHub-Event', '')
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Invalid JSON: {e}'}), 400
|
||||
|
||||
# Only process push events
|
||||
if event_type != 'push':
|
||||
return jsonify({'message': f'Ignoring event type: {event_type}'}), 200
|
||||
|
||||
# Load manifest
|
||||
script_dir = Path(__file__).parent.parent.parent
|
||||
manifest_path = script_dir / 'manifest' / 'vendor_manifest.json'
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
# Find affected entries
|
||||
affected_ids = find_affected_entries(event, manifest)
|
||||
|
||||
if not affected_ids:
|
||||
return jsonify({
|
||||
'message': 'No affected manifest entries',
|
||||
'repo': event.get('repository', {}).get('full_name')
|
||||
}), 200
|
||||
|
||||
# Log the event
|
||||
repo_name = event.get('repository', {}).get('full_name', 'unknown')
|
||||
print(f"Webhook: Push event for {repo_name} affects {len(affected_ids)} entries: {affected_ids}")
|
||||
|
||||
# Trigger check/update flow
|
||||
# In a production environment, you might want to enqueue this as a background job
|
||||
# For now, we'll just log and optionally run check_updates.py
|
||||
|
||||
try:
|
||||
# Run check_updates.py to see what needs updating
|
||||
check_script = script_dir / 'scripts' / 'check_updates.py'
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(check_script), '--manifest', str(manifest_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'message': 'Update check completed',
|
||||
'affected_entries': affected_ids,
|
||||
'check_output': result.stdout
|
||||
}), 200
|
||||
else:
|
||||
# Some entries are out-of-date
|
||||
return jsonify({
|
||||
'message': 'Updates available',
|
||||
'affected_entries': affected_ids,
|
||||
'check_output': result.stdout,
|
||||
'stderr': result.stderr
|
||||
}), 200
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'message': 'Update check timed out',
|
||||
'affected_entries': affected_ids
|
||||
}), 500
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': f'Failed to run update check: {e}',
|
||||
'affected_entries': affected_ids
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# For local development
|
||||
port = int(os.getenv('PORT', 5000))
|
||||
app.run(host='0.0.0.0', port=port, debug=os.getenv('FLASK_DEBUG', 'false').lower() == 'true')
|
||||
else:
|
||||
# For serverless/production deployment (e.g., AWS Lambda, Google Cloud Functions)
|
||||
# Export the Flask app
|
||||
pass
|
||||
498
manifest/vendor_manifest.json
Normal file
498
manifest/vendor_manifest.json
Normal file
@@ -0,0 +1,498 @@
|
||||
[
|
||||
{
|
||||
"id": "handle-spacer",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T01:20:58.324330+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "handle-spacer"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"checksum_sha256": "457a71bc09cb53f12026fd829bec8fa5b04fdead0788822935780f42c90b9a7a",
|
||||
"last_checked": "2026-01-07T01:20:58.945151+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-clamping-thread-belt-clamp"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"checksum_sha256": "4860947b201e2e773b295d33bba09423ae40b4adeef3605d62687f2d40277de1",
|
||||
"last_checked": "2026-01-07T01:20:59.854476+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-clamping-thread-end-effector"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-nut-5-sided",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"checksum_sha256": "38630c70b2fb929bba9a705dabf5bbd7b49ec882963e042b7108dc74284dd6ff",
|
||||
"last_checked": "2026-01-07T01:21:00.555525+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-nut-5-sided"
|
||||
},
|
||||
{
|
||||
"id": "ossm-3030-cap",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"checksum_sha256": "56fa9bb318cdeadc6d1698a1e6cef9371e58b0bc9c7729985bf639d8da2f25da",
|
||||
"last_checked": "2026-01-07T01:21:01.205246+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "ossm-3030-cap"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-bottom",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"checksum_sha256": "e7abdb99a7e9b9e7408a7b04a7dd50e42cc74510ea2969016a45a2a1387dcde3",
|
||||
"last_checked": "2026-01-07T01:21:02.027595+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-bottom"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"checksum_sha256": "bbabc742d2f1753d1b4e21e42c197aec31a4a083b5c634e6e825cec69d4e3258",
|
||||
"last_checked": "2026-01-07T01:21:02.767604+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-cover"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"checksum_sha256": "ce6fb769378636c287af788ce42bdab1f2185dcffba929a0c72598742793b48a",
|
||||
"last_checked": "2026-01-07T01:21:03.531342+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-middle"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle-pivot",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "ad39a03b628b8e38549b99036c8dfd4131948545",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"checksum_sha256": "f6403a3c53e0d8c8e63d48bf853ab17c9f283421b1665b5503dbb04d59d0f52d",
|
||||
"last_checked": "2026-01-07T01:21:04.528132+00:00",
|
||||
"upstream_latest_sha": "ad39a03b628b8e38549b99036c8dfd4131948545",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-actuator-body-middle-pivot"
|
||||
},
|
||||
{
|
||||
"id": "ossm-belt-tensioner",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"checksum_sha256": "31c74250c237763b0013ff42cc714ce14c293382a726de363f1686a7559f525f",
|
||||
"last_checked": "2026-01-07T01:21:05.499523+00:00",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-belt-tensioner"
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-handle-spacer"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/pcb.json",
|
||||
"orig_item_id": "ossm-pcb-3030-mount"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/pcb.json",
|
||||
"orig_item_id": "ossm-pcb-3030-mount-cover"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-aio-cover-mount",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/PCB/OSSM - PCB - AIO Cover Mount.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - AIO Cover Mount.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/pcb.json",
|
||||
"orig_item_id": "ossm-pcb-aio-cover-mount"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-42AIM30",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - 42AIM V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - 42AIM V1.1.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-42AIM30"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-57AIM30",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Mounting Ring - PitClamp Mini - 57AIM V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Mounting Ring - PitClamp Mini - 57AIM V1.1.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-57AIM30"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-bolts ",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Bolts.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Bolts.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-dogbone-bolts "
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-nuts",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Nuts.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Nuts.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-dogbone-nuts"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-handle",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Handle.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Handle.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-handle"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-iHSV57",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - iHSV57.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - iHSV57.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-iHSV57"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-lower",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-lower"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-upper",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/mounting.json",
|
||||
"orig_item_id": "ossm-pitclamp-mini-upper"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-body",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-body"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-knurled"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-knurled-with-position-indicator"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-simple"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-simple-with-position-indicator"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-top-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-top-cover"
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "pivot-plate"
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate-right",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"source_ref": "main",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"upstream_latest_sha": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "pivot-plate-right"
|
||||
}
|
||||
]
|
||||
BIN
scripts/__pycache__/check_updates.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/check_updates.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/generate_manifest_from_site.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/generate_manifest_from_site.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/vendor_update.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/vendor_update.cpython-312.pyc
Normal file
Binary file not shown.
268
scripts/check_updates.py
Executable file
268
scripts/check_updates.py
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check for upstream updates to vendored files.
|
||||
|
||||
Queries GitHub API to detect if upstream files have changed since
|
||||
they were pinned. Produces a report of up-to-date and out-of-date entries.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
"""Simple GitHub API client for checking updates."""
|
||||
|
||||
def __init__(self, token: Optional[str] = None, delay: float = 0.5):
|
||||
self.token = token or os.getenv('GITHUB_API_TOKEN') or os.getenv('GITHUB_TOKEN')
|
||||
self.session = requests.Session()
|
||||
if self.token:
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
})
|
||||
self.base_url = 'https://api.github.com'
|
||||
self.delay = delay # Delay between requests in seconds
|
||||
self.last_request_time = 0
|
||||
|
||||
def _wait_for_rate_limit(self, response: requests.Response) -> None:
|
||||
"""Wait if rate limited, using reset time from headers."""
|
||||
if response.status_code == 403:
|
||||
# Check if it's a rate limit error
|
||||
rate_limit_remaining = response.headers.get('X-RateLimit-Remaining', '1')
|
||||
if rate_limit_remaining == '0' or 'rate limit' in response.text.lower():
|
||||
reset_time = response.headers.get('X-RateLimit-Reset')
|
||||
if reset_time:
|
||||
reset_timestamp = int(reset_time)
|
||||
wait_seconds = max(0, reset_timestamp - int(time.time())) + 1
|
||||
print(f" Rate limit exceeded. Waiting {wait_seconds} seconds until reset...", file=sys.stderr)
|
||||
time.sleep(wait_seconds)
|
||||
else:
|
||||
# Fallback: wait 60 seconds
|
||||
print(" Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
|
||||
time.sleep(60)
|
||||
|
||||
def _rate_limit_delay(self) -> None:
|
||||
"""Add delay between requests to avoid hitting rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
if time_since_last < self.delay:
|
||||
time.sleep(self.delay - time_since_last)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _make_request(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
||||
"""Make a request with rate limit handling and retries."""
|
||||
for attempt in range(max_retries):
|
||||
self._rate_limit_delay()
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
|
||||
# Check rate limit
|
||||
if response.status_code == 403:
|
||||
self._wait_for_rate_limit(response)
|
||||
# Retry the request after waiting
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
# Check remaining rate limit
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining:
|
||||
remaining_int = int(remaining)
|
||||
if remaining_int < 10:
|
||||
print(f" Warning: Only {remaining_int} API requests remaining. Adding delay...", file=sys.stderr)
|
||||
time.sleep(2)
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** attempt # Exponential backoff
|
||||
print(f" Request failed, retrying in {wait_time}s... ({e})", file=sys.stderr)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
def get_latest_commit_sha(self, owner: str, repo: str, path: str, ref: str) -> Optional[str]:
|
||||
"""
|
||||
Get the latest commit SHA that modified a file at the given ref.
|
||||
"""
|
||||
commits_url = f"{self.base_url}/repos/{owner}/{repo}/commits"
|
||||
params = {
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', commits_url, params=params)
|
||||
response.raise_for_status()
|
||||
commits = response.json()
|
||||
|
||||
if commits:
|
||||
return commits[0]['sha']
|
||||
|
||||
# If no commits found, try to resolve the ref to a SHA
|
||||
# Check if ref is already a SHA
|
||||
if len(ref) == 40 and all(c in '0123456789abcdef' for c in ref.lower()):
|
||||
return ref
|
||||
|
||||
# Try to resolve branch/tag to SHA
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/heads/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
# Try tag
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/tags/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error checking updates for {owner}/{repo}/{path}@{ref}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def check_entry(entry: Dict, api: GitHubAPI) -> Dict:
|
||||
"""Check a single manifest entry for updates."""
|
||||
source_repo = entry['source_repo']
|
||||
owner, repo = source_repo.split('/', 1)
|
||||
source_path = entry['source_path']
|
||||
source_ref = entry.get('source_ref', 'main')
|
||||
pinned_sha = entry.get('pinned_sha')
|
||||
|
||||
# Get latest commit SHA
|
||||
latest_sha = api.get_latest_commit_sha(owner, repo, source_path, source_ref)
|
||||
|
||||
if not latest_sha:
|
||||
entry['status'] = 'unknown'
|
||||
entry['upstream_latest_sha'] = None
|
||||
return entry
|
||||
|
||||
# Update upstream_latest_sha
|
||||
entry['upstream_latest_sha'] = latest_sha
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Compare with pinned SHA
|
||||
if not pinned_sha:
|
||||
entry['status'] = 'unknown'
|
||||
elif latest_sha == pinned_sha:
|
||||
entry['status'] = 'up-to-date'
|
||||
else:
|
||||
entry['status'] = 'out-of-date'
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Check for upstream updates to vendored files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--manifest',
|
||||
type=Path,
|
||||
default=Path('manifest/vendor_manifest.json'),
|
||||
help='Path to manifest file (default: manifest/vendor_manifest.json)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=Path,
|
||||
help='Path to write report JSON (optional)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=0.5,
|
||||
help='Delay between API requests in seconds (default: 0.5)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve paths
|
||||
script_dir = Path(__file__).parent.parent
|
||||
manifest_path = (script_dir / args.manifest).resolve()
|
||||
|
||||
if not manifest_path.exists():
|
||||
print(f"Error: Manifest file not found: {manifest_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load manifest
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest_data = json.load(f)
|
||||
|
||||
# Convert to list if it's a dict
|
||||
if isinstance(manifest_data, dict):
|
||||
manifest_list = list(manifest_data.values())
|
||||
else:
|
||||
manifest_list = manifest_data
|
||||
|
||||
# Initialize GitHub API with delay
|
||||
api = GitHubAPI(delay=args.delay)
|
||||
|
||||
# Check each entry
|
||||
print("Checking for upstream updates...")
|
||||
updated_entries = []
|
||||
out_of_date_count = 0
|
||||
|
||||
for entry in manifest_list:
|
||||
updated_entry = check_entry(entry, api)
|
||||
updated_entries.append(updated_entry)
|
||||
|
||||
if updated_entry['status'] == 'out-of-date':
|
||||
out_of_date_count += 1
|
||||
print(f" ⚠️ {updated_entry['id']}: OUT-OF-DATE")
|
||||
print(f" Pinned: {updated_entry.get('pinned_sha', 'N/A')[:8]}...")
|
||||
print(f" Latest: {updated_entry.get('upstream_latest_sha', 'N/A')[:8]}...")
|
||||
elif updated_entry['status'] == 'up-to-date':
|
||||
print(f" ✓ {updated_entry['id']}: up-to-date")
|
||||
else:
|
||||
print(f" ? {updated_entry['id']}: {updated_entry['status']}")
|
||||
|
||||
# Create report
|
||||
report = {
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'total_entries': len(updated_entries),
|
||||
'up_to_date': sum(1 for e in updated_entries if e['status'] == 'up-to-date'),
|
||||
'out_of_date': out_of_date_count,
|
||||
'unknown': sum(1 for e in updated_entries if e['status'] == 'unknown'),
|
||||
'entries': updated_entries
|
||||
}
|
||||
|
||||
# Write report if requested
|
||||
if args.output:
|
||||
output_path = (script_dir / args.output).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, indent=2, sort_keys=False)
|
||||
print(f"\nReport written to {output_path}")
|
||||
|
||||
# Print summary
|
||||
print(f"\nSummary:")
|
||||
print(f" Total entries: {report['total_entries']}")
|
||||
print(f" Up-to-date: {report['up_to_date']}")
|
||||
print(f" Out-of-date: {report['out_of_date']}")
|
||||
print(f" Unknown: {report['unknown']}")
|
||||
|
||||
# Exit with non-zero code if any entries are out-of-date
|
||||
if out_of_date_count > 0:
|
||||
print(f"\n⚠️ {out_of_date_count} entries need updates!", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✓ All entries are up-to-date.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
327
scripts/generate_manifest_from_site.py
Executable file
327
scripts/generate_manifest_from_site.py
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate vendor manifest from site component JSON files.
|
||||
|
||||
Scans /src/data/components/*.json for printedParts entries with GitHub URLs
|
||||
and creates or updates manifest/vendor_manifest.json.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
|
||||
|
||||
def parse_github_url(url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Parse GitHub URL to extract owner, repo, path, and ref.
|
||||
|
||||
Supports:
|
||||
- https://github.com/owner/repo/blob/<ref>/path/to/file
|
||||
- https://github.com/owner/repo/raw/<ref>/path/to/file
|
||||
- https://raw.githubusercontent.com/owner/repo/<ref>/path/to/file
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
|
||||
# Check if it's a GitHub URL
|
||||
if 'github.com' not in url:
|
||||
return None
|
||||
|
||||
# Handle raw.githubusercontent.com
|
||||
if 'raw.githubusercontent.com' in url:
|
||||
match = re.match(r'https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)', url)
|
||||
if match:
|
||||
owner, repo, ref, path = match.groups()
|
||||
return {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'ref': ref,
|
||||
'path': unquote(path).split('?')[0] # Remove query params
|
||||
}
|
||||
|
||||
# Handle github.com URLs
|
||||
parsed = urlparse(url)
|
||||
path_parts = parsed.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) < 5:
|
||||
return None
|
||||
|
||||
owner = path_parts[0]
|
||||
repo = path_parts[1]
|
||||
mode = path_parts[2] # 'blob' or 'raw'
|
||||
ref = path_parts[3]
|
||||
|
||||
# Get file path (everything after ref)
|
||||
file_path = '/'.join(path_parts[4:])
|
||||
|
||||
# Remove query params from path
|
||||
file_path = unquote(file_path).split('?')[0]
|
||||
|
||||
# Handle ?raw=true in query params (sometimes used with blob URLs)
|
||||
query_params = parse_qs(parsed.query)
|
||||
if 'raw' in query_params or mode == 'raw':
|
||||
return {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'ref': ref,
|
||||
'path': file_path
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_printed_parts(data: Any, path: str = '') -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Recursively find all printedParts entries in nested JSON structure.
|
||||
Returns list of (part_dict, json_file_path, part_id) tuples.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Check if this dict has a 'printedParts' key
|
||||
if 'printedParts' in data:
|
||||
for part in data['printedParts']:
|
||||
if isinstance(part, dict) and 'id' in part:
|
||||
parts.append({
|
||||
'part': part,
|
||||
'json_path': path,
|
||||
'part_id': part.get('id')
|
||||
})
|
||||
|
||||
# Also check for 'bodyParts', 'knobs', etc. that might contain parts
|
||||
for key in ['bodyParts', 'knobs']:
|
||||
if key in data and isinstance(data[key], list):
|
||||
for part in data[key]:
|
||||
if isinstance(part, dict) and 'id' in part:
|
||||
parts.append({
|
||||
'part': part,
|
||||
'json_path': path,
|
||||
'part_id': part.get('id')
|
||||
})
|
||||
|
||||
# Recursively search nested structures
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
parts.extend(find_printed_parts(value, path))
|
||||
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
parts.extend(find_printed_parts(item, path))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def generate_manifest_id(part_id: str, owner: str, repo: str, path: str) -> str:
|
||||
"""Generate a manifest ID from part ID or create one from repo/path."""
|
||||
if part_id:
|
||||
return part_id
|
||||
|
||||
# Generate slug from owner-repo-path
|
||||
slug = f"{owner}-{repo}-{path.replace('/', '-').replace(' ', '-')}"
|
||||
# Remove special chars
|
||||
slug = re.sub(r'[^a-zA-Z0-9_-]', '', slug)
|
||||
return slug[:100] # Limit length
|
||||
|
||||
|
||||
def generate_local_path(owner: str, repo: str, path: str) -> str:
|
||||
"""Generate local vendor path from owner, repo, and file path."""
|
||||
repo_dir = f"{owner}-{repo}"
|
||||
return f"vendor/{repo_dir}/{path}"
|
||||
|
||||
|
||||
def load_existing_manifest(manifest_path: Path) -> Dict[str, Dict]:
|
||||
"""Load existing manifest or return empty dict."""
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Convert list to dict keyed by id
|
||||
if isinstance(data, list):
|
||||
return {entry['id']: entry for entry in data}
|
||||
elif isinstance(data, dict) and 'entries' in data:
|
||||
return {entry['id']: entry for entry in data['entries']}
|
||||
elif isinstance(data, dict):
|
||||
# Assume it's already keyed by id
|
||||
return data
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
print(f"Warning: Could not parse existing manifest: {e}", file=sys.stderr)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def scan_component_files(site_dir: Path, repo_root: Path) -> List[Dict[str, Any]]:
|
||||
"""Scan all component JSON files and extract printedParts with GitHub URLs."""
|
||||
entries = []
|
||||
|
||||
if not site_dir.exists():
|
||||
print(f"Error: Site directory does not exist: {site_dir}", file=sys.stderr)
|
||||
return entries
|
||||
|
||||
for json_file in site_dir.glob('*.json'):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
parts = find_printed_parts(data, str(json_file))
|
||||
|
||||
for item in parts:
|
||||
part = item['part']
|
||||
url = part.get('url')
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
github_info = parse_github_url(url)
|
||||
if not github_info:
|
||||
print(f"Warning: Skipping non-GitHub URL in {json_file}: {url}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
part_id = item['part_id']
|
||||
manifest_id = generate_manifest_id(
|
||||
part_id,
|
||||
github_info['owner'],
|
||||
github_info['repo'],
|
||||
github_info['path']
|
||||
)
|
||||
|
||||
local_path = generate_local_path(
|
||||
github_info['owner'],
|
||||
github_info['repo'],
|
||||
github_info['path']
|
||||
)
|
||||
|
||||
# Store relative path from repo root
|
||||
try:
|
||||
json_file_rel = json_file.relative_to(repo_root)
|
||||
except ValueError:
|
||||
# If not relative, use absolute path
|
||||
json_file_rel = json_file
|
||||
|
||||
entries.append({
|
||||
'manifest_id': manifest_id,
|
||||
'part_id': part_id,
|
||||
'part': part,
|
||||
'json_file': str(json_file_rel),
|
||||
'github_info': github_info,
|
||||
'local_path': local_path
|
||||
})
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not read {json_file}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def create_or_update_manifest_entry(
|
||||
existing_entry: Optional[Dict],
|
||||
new_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Create new manifest entry or merge with existing."""
|
||||
github_info = new_data['github_info']
|
||||
manifest_id = new_data['manifest_id']
|
||||
|
||||
if existing_entry:
|
||||
# Merge: keep existing pinned data, update source info if changed
|
||||
entry = existing_entry.copy()
|
||||
entry['source_repo'] = f"{github_info['owner']}/{github_info['repo']}"
|
||||
entry['source_path'] = github_info['path']
|
||||
entry['source_ref'] = github_info.get('ref', 'main')
|
||||
entry['local_path'] = new_data['local_path']
|
||||
entry['orig_site_json'] = new_data['json_file']
|
||||
entry['orig_item_id'] = new_data['part_id']
|
||||
# Don't overwrite pinned_sha, checksum, etc. if they exist
|
||||
return entry
|
||||
|
||||
# Create new entry
|
||||
return {
|
||||
'id': manifest_id,
|
||||
'source_repo': f"{github_info['owner']}/{github_info['repo']}",
|
||||
'source_path': github_info['path'],
|
||||
'source_ref': github_info.get('ref', 'main'),
|
||||
'pinned_sha': None,
|
||||
'pinned_raw_url': None,
|
||||
'local_path': new_data['local_path'],
|
||||
'checksum_sha256': None,
|
||||
'last_checked': None,
|
||||
'upstream_latest_sha': None,
|
||||
'status': 'unknown',
|
||||
'license': None,
|
||||
'orig_site_json': new_data['json_file'],
|
||||
'orig_item_id': new_data['part_id']
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Generate vendor manifest from site component JSON files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--site-dir',
|
||||
type=Path,
|
||||
default=Path('website/src/data/components'),
|
||||
help='Directory containing component JSON files (default: website/src/data/components)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--manifest',
|
||||
type=Path,
|
||||
default=Path('manifest/vendor_manifest.json'),
|
||||
help='Path to manifest file (default: manifest/vendor_manifest.json)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve paths relative to script location or current directory
|
||||
script_dir = Path(__file__).parent.parent
|
||||
site_dir = (script_dir / args.site_dir).resolve()
|
||||
manifest_path = (script_dir / args.manifest).resolve()
|
||||
|
||||
# Ensure manifest directory exists
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing manifest
|
||||
existing_manifest = load_existing_manifest(manifest_path)
|
||||
|
||||
# Scan component files
|
||||
print(f"Scanning component files in {site_dir}...")
|
||||
entries = scan_component_files(site_dir, repo_root=script_dir)
|
||||
|
||||
if not entries:
|
||||
print("No GitHub URLs found in component files.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create or update manifest entries
|
||||
updated_manifest = existing_manifest.copy()
|
||||
|
||||
for entry_data in entries:
|
||||
manifest_id = entry_data['manifest_id']
|
||||
existing_entry = updated_manifest.get(manifest_id)
|
||||
|
||||
new_entry = create_or_update_manifest_entry(existing_entry, entry_data)
|
||||
updated_manifest[manifest_id] = new_entry
|
||||
|
||||
# Convert to sorted list for deterministic output
|
||||
manifest_list = sorted(updated_manifest.values(), key=lambda x: x['id'])
|
||||
|
||||
# Write manifest
|
||||
print(f"Writing manifest to {manifest_path}...")
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest_list, f, indent=2, sort_keys=False)
|
||||
|
||||
print(f"Generated {len(manifest_list)} manifest entries.")
|
||||
|
||||
# Show summary
|
||||
new_entries = len(manifest_list) - len(existing_manifest)
|
||||
if new_entries > 0:
|
||||
print(f"Added {new_entries} new entries.")
|
||||
if len(existing_manifest) > 0:
|
||||
print(f"Updated {len(existing_manifest)} existing entries.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
6
scripts/requirements.txt
Normal file
6
scripts/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
requests>=2.31.0
|
||||
PyGithub>=2.1.0
|
||||
pytest>=7.4.0
|
||||
pytest-mock>=3.11.1
|
||||
responses>=0.23.1
|
||||
flask>=3.0.0
|
||||
465
scripts/vendor_update.py
Executable file
465
scripts/vendor_update.py
Executable file
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download and pin external asset files from GitHub.
|
||||
|
||||
Downloads files specified in manifest, pins them to commit SHAs,
|
||||
computes checksums, and optionally syncs vendor metadata back to site JSON files.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
"""Simple GitHub API client with rate limit handling."""
|
||||
|
||||
def __init__(self, token: Optional[str] = None, delay: float = 0.5):
|
||||
self.token = token or os.getenv('GITHUB_API_TOKEN') or os.getenv('GITHUB_TOKEN')
|
||||
self.session = requests.Session()
|
||||
if self.token:
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
})
|
||||
self.base_url = 'https://api.github.com'
|
||||
self.delay = delay # Delay between requests in seconds
|
||||
self.last_request_time = 0
|
||||
|
||||
def _wait_for_rate_limit(self, response: requests.Response) -> None:
|
||||
"""Wait if rate limited, using reset time from headers."""
|
||||
if response.status_code == 403:
|
||||
# Check if it's a rate limit error
|
||||
rate_limit_remaining = response.headers.get('X-RateLimit-Remaining', '1')
|
||||
if rate_limit_remaining == '0' or 'rate limit' in response.text.lower():
|
||||
reset_time = response.headers.get('X-RateLimit-Reset')
|
||||
if reset_time:
|
||||
reset_timestamp = int(reset_time)
|
||||
wait_seconds = max(0, reset_timestamp - int(time.time())) + 1
|
||||
print(f" Rate limit exceeded. Waiting {wait_seconds} seconds until reset...", file=sys.stderr)
|
||||
time.sleep(wait_seconds)
|
||||
else:
|
||||
# Fallback: wait 60 seconds
|
||||
print(" Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
|
||||
time.sleep(60)
|
||||
|
||||
def _rate_limit_delay(self) -> None:
|
||||
"""Add delay between requests to avoid hitting rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
if time_since_last < self.delay:
|
||||
time.sleep(self.delay - time_since_last)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _make_request(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
||||
"""Make a request with rate limit handling and retries."""
|
||||
for attempt in range(max_retries):
|
||||
self._rate_limit_delay()
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
|
||||
# Check rate limit
|
||||
if response.status_code == 403:
|
||||
self._wait_for_rate_limit(response)
|
||||
# Retry the request after waiting
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
# Check remaining rate limit
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining:
|
||||
remaining_int = int(remaining)
|
||||
if remaining_int < 10:
|
||||
print(f" Warning: Only {remaining_int} API requests remaining. Adding delay...", file=sys.stderr)
|
||||
time.sleep(2)
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** attempt # Exponential backoff
|
||||
print(f" Request failed, retrying in {wait_time}s... ({e})", file=sys.stderr)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
def get_default_branch(self, owner: str, repo: str) -> str:
|
||||
"""Get default branch for a repository."""
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}"
|
||||
try:
|
||||
response = self._make_request('GET', url)
|
||||
response.raise_for_status()
|
||||
return response.json().get('default_branch', 'main')
|
||||
except requests.RequestException as e:
|
||||
print(f"Warning: Could not get default branch for {owner}/{repo}: {e}", file=sys.stderr)
|
||||
return 'main'
|
||||
|
||||
def get_file_sha(self, owner: str, repo: str, path: str, ref: str) -> Optional[str]:
|
||||
"""
|
||||
Get the commit SHA that last modified a file at a given ref.
|
||||
Uses Contents API to get file info, then finds the commit.
|
||||
"""
|
||||
# First, try to get file contents to verify it exists
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}/contents/{path}"
|
||||
params = {'ref': ref}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', url, params=params)
|
||||
if response.status_code == 404:
|
||||
# File doesn't exist at this ref, try default branch
|
||||
default_branch = self.get_default_branch(owner, repo)
|
||||
if default_branch != ref:
|
||||
params['ref'] = default_branch
|
||||
response = self._make_request('GET', url, params=params)
|
||||
|
||||
response.raise_for_status()
|
||||
file_info = response.json()
|
||||
|
||||
# Get the commit SHA from the file info
|
||||
# The Contents API returns 'sha' which is the blob SHA, not commit SHA
|
||||
# We need to find the commit that last modified this file
|
||||
commits_url = f"{self.base_url}/repos/{owner}/{repo}/commits"
|
||||
commits_params = {
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
}
|
||||
|
||||
commits_response = self._make_request('GET', commits_url, params=commits_params)
|
||||
commits_response.raise_for_status()
|
||||
commits = commits_response.json()
|
||||
|
||||
if commits:
|
||||
return commits[0]['sha']
|
||||
|
||||
# Fallback: use the ref as-is if it's already a SHA
|
||||
if len(ref) == 40 and all(c in '0123456789abcdef' for c in ref.lower()):
|
||||
return ref
|
||||
|
||||
# Last resort: resolve ref to SHA
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/heads/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
# If ref is a tag
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/tags/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error getting file SHA for {owner}/{repo}/{path}@{ref}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def get_license(self, owner: str, repo: str, sha: str) -> Optional[str]:
|
||||
"""Try to detect license from repository root at given SHA."""
|
||||
license_files = ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'LICENCE', 'LICENCE.txt']
|
||||
|
||||
for license_file in license_files:
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}/contents/{license_file}"
|
||||
params = {'ref': sha}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', url, params=params)
|
||||
if response.status_code == 200:
|
||||
# Found a license file, return URL to it
|
||||
return f"https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{license_file}"
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
# Try to get license from repository info
|
||||
try:
|
||||
repo_url = f"{self.base_url}/repos/{owner}/{repo}"
|
||||
response = self._make_request('GET', repo_url)
|
||||
response.raise_for_status()
|
||||
repo_info = response.json()
|
||||
license_info = repo_info.get('license')
|
||||
if license_info:
|
||||
return license_info.get('spdx_id') or license_info.get('url')
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compute_sha256(file_path: Path) -> str:
|
||||
"""Compute SHA256 checksum of a file."""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
def download_file(url: str, dest_path: Path) -> bool:
|
||||
"""Download a file from URL to destination path."""
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Create parent directories
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download file
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
print(f"Error downloading {url}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def update_manifest_entry(
|
||||
entry: Dict,
|
||||
api: GitHubAPI,
|
||||
repo_root: Path,
|
||||
dry_run: bool = False
|
||||
) -> Dict:
|
||||
"""Update a single manifest entry by downloading and pinning the file."""
|
||||
source_repo = entry['source_repo']
|
||||
owner, repo = source_repo.split('/', 1)
|
||||
source_path = entry['source_path']
|
||||
source_ref = entry.get('source_ref', 'main')
|
||||
|
||||
print(f"Processing {entry['id']} from {source_repo}/{source_path}@{source_ref}...")
|
||||
|
||||
# Get commit SHA for the file
|
||||
commit_sha = api.get_file_sha(owner, repo, source_path, source_ref)
|
||||
if not commit_sha:
|
||||
print(f" Warning: Could not resolve SHA for {source_ref}, skipping", file=sys.stderr)
|
||||
entry['status'] = 'error'
|
||||
return entry
|
||||
|
||||
# Build pinned raw URL
|
||||
pinned_raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{source_path}"
|
||||
|
||||
# Determine local path
|
||||
local_path = Path(entry['local_path'])
|
||||
if not local_path.is_absolute():
|
||||
local_path = repo_root / local_path
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would download to {local_path}")
|
||||
print(f" [DRY RUN] Pinned SHA: {commit_sha}")
|
||||
entry['pinned_sha'] = commit_sha
|
||||
entry['pinned_raw_url'] = pinned_raw_url
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
entry['upstream_latest_sha'] = commit_sha
|
||||
entry['status'] = 'up-to-date'
|
||||
return entry
|
||||
|
||||
# Download file
|
||||
print(f" Downloading from {pinned_raw_url}...")
|
||||
if not download_file(pinned_raw_url, local_path):
|
||||
entry['status'] = 'error'
|
||||
return entry
|
||||
|
||||
# Compute checksum
|
||||
checksum = compute_sha256(local_path)
|
||||
print(f" Checksum: {checksum[:16]}...")
|
||||
|
||||
# Get license info
|
||||
license_info = api.get_license(owner, repo, commit_sha)
|
||||
|
||||
# Update entry
|
||||
entry['pinned_sha'] = commit_sha
|
||||
entry['pinned_raw_url'] = pinned_raw_url
|
||||
entry['checksum_sha256'] = checksum
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
entry['upstream_latest_sha'] = commit_sha
|
||||
entry['status'] = 'up-to-date'
|
||||
if license_info:
|
||||
entry['license'] = license_info
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def sync_to_site_json(entry: Dict, repo_root: Path) -> bool:
|
||||
"""Sync vendor metadata back to the original site JSON file."""
|
||||
orig_json_path = entry.get('orig_site_json')
|
||||
orig_item_id = entry.get('orig_item_id')
|
||||
|
||||
if not orig_json_path or not orig_item_id:
|
||||
return False
|
||||
|
||||
json_path = repo_root / orig_json_path
|
||||
if not json_path.exists():
|
||||
print(f" Warning: Site JSON file not found: {json_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find the printed part in the nested structure
|
||||
def find_and_update_part(obj, target_id, path=''):
|
||||
if isinstance(obj, dict):
|
||||
# Check if this is a printedParts array
|
||||
if 'printedParts' in obj and isinstance(obj['printedParts'], list):
|
||||
for part in obj['printedParts']:
|
||||
if isinstance(part, dict) and part.get('id') == target_id:
|
||||
# Update this part
|
||||
if 'vendor' not in part:
|
||||
part['vendor'] = {}
|
||||
part['vendor'].update({
|
||||
'manifest_id': entry['id'],
|
||||
'local_path': entry['local_path'],
|
||||
'pinned_sha': entry['pinned_sha'],
|
||||
'pinned_raw_url': entry['pinned_raw_url'],
|
||||
'checksum_sha256': entry['checksum_sha256'],
|
||||
'last_checked': entry['last_checked'],
|
||||
'status': entry['status']
|
||||
})
|
||||
return True
|
||||
|
||||
# Check bodyParts, knobs, etc.
|
||||
for key in ['bodyParts', 'knobs']:
|
||||
if key in obj and isinstance(obj[key], list):
|
||||
for part in obj[key]:
|
||||
if isinstance(part, dict) and part.get('id') == target_id:
|
||||
if 'vendor' not in part:
|
||||
part['vendor'] = {}
|
||||
part['vendor'].update({
|
||||
'manifest_id': entry['id'],
|
||||
'local_path': entry['local_path'],
|
||||
'pinned_sha': entry['pinned_sha'],
|
||||
'pinned_raw_url': entry['pinned_raw_url'],
|
||||
'checksum_sha256': entry['checksum_sha256'],
|
||||
'last_checked': entry['last_checked'],
|
||||
'status': entry['status']
|
||||
})
|
||||
return True
|
||||
|
||||
# Recursively search
|
||||
for value in obj.values():
|
||||
if find_and_update_part(value, target_id):
|
||||
return True
|
||||
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
if find_and_update_part(item, target_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
if not find_and_update_part(data, orig_item_id):
|
||||
print(f" Warning: Could not find part with id '{orig_item_id}' in {json_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Write back to file (preserve formatting)
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f" Updated {json_path}")
|
||||
return True
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f" Error updating {json_path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Download and pin external asset files from GitHub'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--manifest',
|
||||
type=Path,
|
||||
default=Path('manifest/vendor_manifest.json'),
|
||||
help='Path to manifest file (default: manifest/vendor_manifest.json)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--entry',
|
||||
type=str,
|
||||
help='Process only a specific manifest entry by ID'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be done without downloading files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--sync-site',
|
||||
action='store_true',
|
||||
help='Sync vendor metadata back to site JSON files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=0.5,
|
||||
help='Delay between API requests in seconds (default: 0.5)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve paths
|
||||
script_dir = Path(__file__).parent.parent
|
||||
manifest_path = (script_dir / args.manifest).resolve()
|
||||
repo_root = script_dir
|
||||
|
||||
if not manifest_path.exists():
|
||||
print(f"Error: Manifest file not found: {manifest_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load manifest
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest_data = json.load(f)
|
||||
|
||||
# Convert to dict if it's a list
|
||||
if isinstance(manifest_data, list):
|
||||
manifest = {entry['id']: entry for entry in manifest_data}
|
||||
else:
|
||||
manifest = manifest_data
|
||||
|
||||
# Filter entries if --entry specified
|
||||
if args.entry:
|
||||
if args.entry not in manifest:
|
||||
print(f"Error: Entry '{args.entry}' not found in manifest", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
entries_to_process = {args.entry: manifest[args.entry]}
|
||||
else:
|
||||
entries_to_process = manifest
|
||||
|
||||
# Initialize GitHub API with delay
|
||||
api = GitHubAPI(delay=args.delay)
|
||||
|
||||
# Process entries
|
||||
updated_count = 0
|
||||
for entry_id, entry in entries_to_process.items():
|
||||
updated_entry = update_manifest_entry(entry, api, repo_root, dry_run=args.dry_run)
|
||||
manifest[entry_id] = updated_entry
|
||||
|
||||
if args.sync_site and not args.dry_run:
|
||||
sync_to_site_json(updated_entry, repo_root)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
# Write updated manifest
|
||||
if not args.dry_run:
|
||||
manifest_list = sorted(manifest.values(), key=lambda x: x['id'])
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest_list, f, indent=2, sort_keys=False)
|
||||
print(f"\nUpdated manifest with {updated_count} entries.")
|
||||
else:
|
||||
print(f"\n[DRY RUN] Would update {updated_count} entries.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
240
tests/test_check_updates.py
Normal file
240
tests/test_check_updates.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for check_updates.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
# Import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))
|
||||
|
||||
from check_updates import GitHubAPI, check_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_api():
|
||||
"""Create a GitHubAPI instance for testing."""
|
||||
return GitHubAPI(token='test-token')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest_entry():
|
||||
"""Sample manifest entry for testing."""
|
||||
return {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': 'pinned-sha-123',
|
||||
'pinned_raw_url': 'https://raw.githubusercontent.com/owner/repo/pinned-sha-123/path/to/file.stl',
|
||||
'local_path': 'vendor/owner-repo/path/to/file.stl',
|
||||
'checksum_sha256': 'abc123',
|
||||
'last_checked': '2024-01-01T00:00:00Z',
|
||||
'upstream_latest_sha': None,
|
||||
'status': 'unknown',
|
||||
'license': None
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_up_to_date(github_api, sample_manifest_entry):
|
||||
"""Test checking an entry that is up-to-date."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
pinned_sha = 'pinned-sha-123'
|
||||
|
||||
# Mock commits API - return same SHA as pinned
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': pinned_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
assert updated_entry['upstream_latest_sha'] == pinned_sha
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_out_of_date(github_api, sample_manifest_entry):
|
||||
"""Test checking an entry that is out-of-date."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
pinned_sha = 'pinned-sha-123'
|
||||
latest_sha = 'latest-sha-456'
|
||||
|
||||
# Mock commits API - return different SHA
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': latest_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'out-of-date'
|
||||
assert updated_entry['upstream_latest_sha'] == latest_sha
|
||||
assert updated_entry['pinned_sha'] == pinned_sha # Pinned SHA unchanged
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_no_pinned_sha(github_api):
|
||||
"""Test checking an entry with no pinned SHA."""
|
||||
entry = {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': None,
|
||||
'status': 'unknown'
|
||||
}
|
||||
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
latest_sha = 'latest-sha-456'
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': latest_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'unknown'
|
||||
assert updated_entry['upstream_latest_sha'] == latest_sha
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_api_error(github_api, sample_manifest_entry):
|
||||
"""Test handling of API errors."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock API error
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
status=500
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'unknown'
|
||||
assert updated_entry['upstream_latest_sha'] is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_file_not_found(github_api, sample_manifest_entry):
|
||||
"""Test handling when file doesn't exist at ref."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock empty commits response (file doesn't exist)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
# Should still update last_checked but status might be unknown
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
def test_github_api_get_latest_commit_sha(github_api):
|
||||
"""Test getting latest commit SHA."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'file.stl'
|
||||
ref = 'main'
|
||||
expected_sha = 'commit-sha-789'
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': expected_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
|
||||
assert sha == expected_sha
|
||||
|
||||
|
||||
def test_github_api_get_latest_commit_sha_ref_is_sha(github_api):
|
||||
"""Test when ref is already a SHA."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'file.stl'
|
||||
ref = 'a' * 40 # Valid SHA format
|
||||
|
||||
# Should return the ref as-is if it's already a SHA
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
|
||||
# Actually, the function tries to get commits first, so it will make an API call
|
||||
# But if ref is a SHA, it should work
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': ref}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
assert sha == ref
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
317
tests/test_vendor_update.py
Normal file
317
tests/test_vendor_update.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for vendor_update.py
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
# Import the module (adjust path as needed)
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))
|
||||
|
||||
from vendor_update import GitHubAPI, compute_sha256, download_file, update_manifest_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest_entry():
|
||||
"""Sample manifest entry for testing."""
|
||||
return {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': None,
|
||||
'pinned_raw_url': None,
|
||||
'local_path': 'vendor/owner-repo/path/to/file.stl',
|
||||
'checksum_sha256': None,
|
||||
'last_checked': None,
|
||||
'upstream_latest_sha': None,
|
||||
'status': 'unknown',
|
||||
'license': None
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_api():
|
||||
"""Create a GitHubAPI instance for testing."""
|
||||
return GitHubAPI(token='test-token')
|
||||
|
||||
|
||||
def test_compute_sha256(temp_dir):
|
||||
"""Test SHA256 computation."""
|
||||
test_file = temp_dir / 'test.txt'
|
||||
test_file.write_text('test content')
|
||||
|
||||
checksum = compute_sha256(test_file)
|
||||
|
||||
# Verify it's a valid SHA256 hex string
|
||||
assert len(checksum) == 64
|
||||
assert all(c in '0123456789abcdef' for c in checksum.lower())
|
||||
|
||||
# Verify it matches expected hash
|
||||
expected = hashlib.sha256(b'test content').hexdigest()
|
||||
assert checksum == expected
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_download_file_success(temp_dir):
|
||||
"""Test successful file download."""
|
||||
test_url = 'https://example.com/file.stl'
|
||||
test_content = b'STL file content'
|
||||
dest_path = temp_dir / 'downloaded.stl'
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
test_url,
|
||||
body=test_content,
|
||||
status=200
|
||||
)
|
||||
|
||||
result = download_file(test_url, dest_path)
|
||||
|
||||
assert result is True
|
||||
assert dest_path.exists()
|
||||
assert dest_path.read_bytes() == test_content
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_download_file_failure():
|
||||
"""Test file download failure."""
|
||||
test_url = 'https://example.com/missing.stl'
|
||||
dest_path = Path('/tmp/test.stl')
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
test_url,
|
||||
status=404
|
||||
)
|
||||
|
||||
result = download_file(test_url, dest_path)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_github_api_get_file_sha(github_api):
|
||||
"""Test getting file SHA from GitHub API."""
|
||||
owner = 'test-owner'
|
||||
repo = 'test-repo'
|
||||
path = 'file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock Contents API response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha-123'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
# Mock Commits API response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': 'commit-sha-456'}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_file_sha(owner, repo, path, ref)
|
||||
|
||||
assert sha == 'commit-sha-456'
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_github_api_get_license(github_api):
|
||||
"""Test getting license information."""
|
||||
owner = 'test-owner'
|
||||
repo = 'test-repo'
|
||||
sha = 'abc123'
|
||||
|
||||
# Mock LICENSE file found
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/LICENSE',
|
||||
json={'type': 'file'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': sha})]
|
||||
)
|
||||
|
||||
license_url = github_api.get_license(owner, repo, sha)
|
||||
|
||||
assert license_url == f'https://raw.githubusercontent.com/{owner}/{repo}/{sha}/LICENSE'
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_dry_run(temp_dir, sample_manifest_entry):
|
||||
"""Test updating manifest entry in dry-run mode."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': 'commit-sha-123'}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert updated_entry['pinned_sha'] == 'commit-sha-123'
|
||||
assert updated_entry['pinned_raw_url'] == f'https://raw.githubusercontent.com/{owner}/{repo}/commit-sha-123/{path}'
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
assert updated_entry['last_checked'] is not None
|
||||
assert updated_entry['upstream_latest_sha'] == 'commit-sha-123'
|
||||
|
||||
# In dry-run, file should not be downloaded
|
||||
local_path = temp_dir / updated_entry['local_path']
|
||||
assert not local_path.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_with_download(temp_dir, sample_manifest_entry):
|
||||
"""Test updating manifest entry with actual download."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
commit_sha = 'commit-sha-123'
|
||||
file_content = b'STL file content here'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': commit_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
# Mock file download
|
||||
pinned_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{path}'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
pinned_url,
|
||||
body=file_content,
|
||||
status=200
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert updated_entry['pinned_sha'] == commit_sha
|
||||
assert updated_entry['checksum_sha256'] is not None
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
|
||||
# Verify file was downloaded
|
||||
local_path = temp_dir / updated_entry['local_path']
|
||||
assert local_path.exists()
|
||||
assert local_path.read_bytes() == file_content
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = hashlib.sha256(file_content).hexdigest()
|
||||
assert updated_entry['checksum_sha256'] == expected_checksum
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_download_failure(temp_dir, sample_manifest_entry):
|
||||
"""Test handling of download failure."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
commit_sha = 'commit-sha-123'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': commit_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
# Mock file download failure
|
||||
pinned_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{path}'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
pinned_url,
|
||||
status=404
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert updated_entry['status'] == 'error'
|
||||
assert updated_entry['pinned_sha'] == commit_sha # SHA was resolved
|
||||
assert updated_entry['checksum_sha256'] is None # File not downloaded
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
38
website/eslint.config.js
Normal file
38
website/eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
2498
website/package-lock.json
generated
2498
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,26 +6,33 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xlsx": "^0.18.5"
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.0.0",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"glob": "^9.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"inflight": "npm:@jsdevtools/inflight@^1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import partsData from '../data/index.js';
|
||||
import { formatPrice, getNumericPrice } from '../utils/priceFormat';
|
||||
import JSZip from 'jszip';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { createShareLink } from '../utils/shareService';
|
||||
import { generateMarkdownOverview, generateExcelBOM, generateExcelPrintList } from '../utils/exportUtils';
|
||||
|
||||
@@ -1411,13 +1410,13 @@ export default function BOMSummary({ config }) {
|
||||
// 2. Generate and add Excel BOM
|
||||
setZipProgress({ current: 20, total: 100, currentFile: 'Generating BOM...' });
|
||||
const bomWorkbook = generateExcelBOM(hardwareParts, printedParts, config);
|
||||
const bomBuffer = XLSX.write(bomWorkbook, { type: 'array', bookType: 'xlsx' });
|
||||
const bomBuffer = await bomWorkbook.xlsx.writeBuffer();
|
||||
zip.file('BOM.xlsx', bomBuffer);
|
||||
|
||||
// 3. Generate and add Excel Print List
|
||||
setZipProgress({ current: 40, total: 100, currentFile: 'Generating print list...' });
|
||||
const printListWorkbook = generateExcelPrintList(printedParts, filamentTotals);
|
||||
const printListBuffer = XLSX.write(printListWorkbook, { type: 'array', bookType: 'xlsx' });
|
||||
const printListBuffer = await printListWorkbook.xlsx.writeBuffer();
|
||||
zip.file('Print_List.xlsx', printListBuffer);
|
||||
|
||||
// 4. Download and organize print files by component and colors
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Bottom.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-bottom",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"checksum_sha256": "e7abdb99a7e9b9e7408a7b04a7dd50e42cc74510ea2969016a45a2a1387dcde3",
|
||||
"last_checked": "2026-01-07T01:21:02.027595+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle",
|
||||
@@ -23,7 +32,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Middle.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-middle",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"checksum_sha256": "ce6fb769378636c287af788ce42bdab1f2185dcffba929a0c72598742793b48a",
|
||||
"last_checked": "2026-01-07T01:21:03.531342+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-cover",
|
||||
@@ -34,7 +52,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"checksum_sha256": "bbabc742d2f1753d1b4e21e42c197aec31a4a083b5c634e6e825cec69d4e3258",
|
||||
"last_checked": "2026-01-07T01:21:02.767604+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-belt-tensioner",
|
||||
@@ -45,7 +72,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Belt Tensioner.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-belt-tensioner",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"checksum_sha256": "31c74250c237763b0013ff42cc714ce14c293382a726de363f1686a7559f525f",
|
||||
"last_checked": "2026-01-07T01:21:05.499523+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
@@ -56,7 +92,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Clamping Thread Belt Clamp.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"checksum_sha256": "457a71bc09cb53f12026fd829bec8fa5b04fdead0788822935780f42c90b9a7a",
|
||||
"last_checked": "2026-01-07T01:20:58.945151+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-end-effector",
|
||||
@@ -67,7 +112,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Clamping Thread End Effector.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"checksum_sha256": "4860947b201e2e773b295d33bba09423ae40b4adeef3605d62687f2d40277de1",
|
||||
"last_checked": "2026-01-07T01:20:59.854476+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-nut-5-sided",
|
||||
@@ -78,7 +132,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - 24mm Nut 5 Sided.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-nut-5-sided",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"checksum_sha256": "38630c70b2fb929bba9a705dabf5bbd7b49ec882963e042b7108dc74284dd6ff",
|
||||
"last_checked": "2026-01-07T01:21:00.555525+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-lower",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-upper",
|
||||
@@ -23,7 +32,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-upper",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-57AIM30",
|
||||
@@ -34,7 +52,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 57AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-57AIM30",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Mounting Ring - PitClamp Mini - 57AIM V1.1.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-42AIM30",
|
||||
@@ -45,7 +72,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 42AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-42AIM30",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - 42AIM V1.1.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-iHSV57",
|
||||
@@ -56,7 +92,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - iHSV57 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-iHSV57",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/Non-standard/OSSM - Mounting Ring - PitClamp Mini - iHSV57.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-handle",
|
||||
@@ -67,7 +112,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Handle - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-handle",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Handle.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-nuts",
|
||||
@@ -79,7 +133,16 @@
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Nuts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-dogbone-nuts",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Nuts.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-bolts ",
|
||||
@@ -91,7 +154,16 @@
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Bolts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pitclamp-mini-dogbone-bolts ",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Dogbone Bolts.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
@@ -114,7 +186,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Middle Pivot.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-middle-pivot",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"pinned_sha": "ad39a03b628b8e38549b99036c8dfd4131948545",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"checksum_sha256": "f6403a3c53e0d8c8e63d48bf853ab17c9f283421b1665b5503dbb04d59d0f52d",
|
||||
"last_checked": "2026-01-07T01:21:04.528132+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
@@ -125,7 +206,16 @@
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Handle Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-handle-spacer",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-3030-mount",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount-cover",
|
||||
@@ -23,7 +32,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount%20Cover.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-3030-mount-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
@@ -58,7 +76,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - AIO Cover Mount.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%20AIO%20Cover%20Mount.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%20AIO%20Cover%20Mount.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-aio-cover-mount",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - AIO Cover Mount.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
|
||||
@@ -17,7 +17,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-body.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-body",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-top-cover",
|
||||
@@ -28,7 +37,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-top-cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-top-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"knobs": [
|
||||
@@ -41,7 +59,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple",
|
||||
@@ -52,7 +79,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob-simple.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-simple",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
@@ -62,7 +98,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-simple-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled",
|
||||
@@ -72,7 +117,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-knurled",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
@@ -82,7 +136,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
|
||||
@@ -19,7 +19,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Left.stl?raw=true",
|
||||
"quantity": 1
|
||||
"quantity": 1,
|
||||
"vendor": {
|
||||
"manifest_id": "pivot-plate",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate-right",
|
||||
@@ -30,7 +39,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Right.stl?raw=true",
|
||||
"quantity": 1
|
||||
"quantity": 1,
|
||||
"vendor": {
|
||||
"manifest_id": "pivot-plate-right",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "handle-spacer",
|
||||
@@ -41,7 +59,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true",
|
||||
"quantity": 8
|
||||
"quantity": 8,
|
||||
"vendor": {
|
||||
"manifest_id": "handle-spacer",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T01:20:58.324330+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
@@ -153,7 +180,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - 3030 Cap.stl",
|
||||
"quantity": 6,
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-3030-cap",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"checksum_sha256": "56fa9bb318cdeadc6d1698a1e6cef9371e58b0bc9c7729985bf639d8da2f25da",
|
||||
"last_checked": "2026-01-07T01:21:01.205246+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
// Generate markdown overview
|
||||
export const generateMarkdownOverview = (config, printedParts, hardwareParts, filamentTotals, totalTime, total) => {
|
||||
@@ -183,23 +183,24 @@ export const generateExcelBOM = (hardwareParts, printedParts, config) => {
|
||||
});
|
||||
|
||||
// Create workbook and worksheet
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('BOM');
|
||||
|
||||
// Add rows
|
||||
worksheet.addRows(rows);
|
||||
|
||||
// Set column widths
|
||||
ws['!cols'] = [
|
||||
{ wch: 30 }, // Item
|
||||
{ wch: 40 }, // Name
|
||||
{ wch: 10 }, // Quantity
|
||||
{ wch: 12 }, // Price
|
||||
{ wch: 50 }, // Link
|
||||
{ wch: 20 }, // Category
|
||||
{ wch: 15 } // Type
|
||||
worksheet.columns = [
|
||||
{ width: 30 }, // Item
|
||||
{ width: 40 }, // Name
|
||||
{ width: 10 }, // Quantity
|
||||
{ width: 12 }, // Price
|
||||
{ width: 50 }, // Link
|
||||
{ width: 20 }, // Category
|
||||
{ width: 15 } // Type
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'BOM');
|
||||
|
||||
return wb;
|
||||
return workbook;
|
||||
};
|
||||
|
||||
// Generate Excel Print List with completion tracker
|
||||
@@ -252,44 +253,48 @@ export const generateExcelPrintList = (printedParts, filamentTotals) => {
|
||||
rows.push(['TOTAL', '', '', printedParts.length, filamentTotals.total.toFixed(2), '', '', '']);
|
||||
|
||||
// Create workbook and worksheet
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('Print List');
|
||||
|
||||
// Add rows
|
||||
worksheet.addRows(rows);
|
||||
|
||||
// Set column widths
|
||||
ws['!cols'] = [
|
||||
{ wch: 40 }, // Part Name
|
||||
{ wch: 20 }, // Category
|
||||
{ wch: 12 }, // Color
|
||||
{ wch: 10 }, // Quantity
|
||||
{ wch: 15 }, // Filament
|
||||
{ wch: 15 }, // Print Time
|
||||
{ wch: 15 }, // Status
|
||||
{ wch: 12 } // Completed
|
||||
worksheet.columns = [
|
||||
{ width: 40 }, // Part Name
|
||||
{ width: 20 }, // Category
|
||||
{ width: 12 }, // Color
|
||||
{ width: 10 }, // Quantity
|
||||
{ width: 15 }, // Filament
|
||||
{ width: 15 }, // Print Time
|
||||
{ width: 15 }, // Status
|
||||
{ width: 12 } // Completed
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Print List');
|
||||
|
||||
// Create a summary sheet with progress calculation
|
||||
// Note: Excel formulas need to reference cells properly
|
||||
const summaryRows = [
|
||||
['Print Progress Summary'],
|
||||
[],
|
||||
['Total Parts', printedParts.length],
|
||||
['Completed Parts', { f: `COUNTIF('Print List'.H:H,"✓")` }],
|
||||
['Progress %', { f: `IF(B3>0, (B4/B3)*100, 0)` }],
|
||||
[],
|
||||
['Filament Summary'],
|
||||
['Total Filament (g)', filamentTotals.total.toFixed(2)],
|
||||
['Primary Color (g)', filamentTotals.primary.toFixed(2)],
|
||||
['Accent Color (g)', (filamentTotals.secondary || 0).toFixed(2)]
|
||||
const summaryWorksheet = workbook.addWorksheet('Summary');
|
||||
|
||||
// Add summary rows
|
||||
summaryWorksheet.getCell('A1').value = 'Print Progress Summary';
|
||||
summaryWorksheet.getCell('A3').value = 'Total Parts';
|
||||
summaryWorksheet.getCell('B3').value = printedParts.length;
|
||||
summaryWorksheet.getCell('A4').value = 'Completed Parts';
|
||||
summaryWorksheet.getCell('B4').formula = `COUNTIF('Print List'.H:H,"✓")`;
|
||||
summaryWorksheet.getCell('A5').value = 'Progress %';
|
||||
summaryWorksheet.getCell('B5').formula = `IF(B3>0, (B4/B3)*100, 0)`;
|
||||
summaryWorksheet.getCell('A7').value = 'Filament Summary';
|
||||
summaryWorksheet.getCell('A8').value = 'Total Filament (g)';
|
||||
summaryWorksheet.getCell('B8').value = filamentTotals.total.toFixed(2);
|
||||
summaryWorksheet.getCell('A9').value = 'Primary Color (g)';
|
||||
summaryWorksheet.getCell('B9').value = filamentTotals.primary.toFixed(2);
|
||||
summaryWorksheet.getCell('A10').value = 'Accent Color (g)';
|
||||
summaryWorksheet.getCell('B10').value = (filamentTotals.secondary || 0).toFixed(2);
|
||||
|
||||
// Set column widths for summary sheet
|
||||
summaryWorksheet.columns = [
|
||||
{ width: 25 },
|
||||
{ width: 15 }
|
||||
];
|
||||
|
||||
const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows);
|
||||
summaryWs['!cols'] = [
|
||||
{ wch: 25 },
|
||||
{ wch: 15 }
|
||||
];
|
||||
XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary');
|
||||
|
||||
return wb;
|
||||
return workbook;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user