diff --git a/.github/workflows/brandmeister-automation.yml b/.github/workflows/brandmeister-automation.yml new file mode 100644 index 0000000..2713842 --- /dev/null +++ b/.github/workflows/brandmeister-automation.yml @@ -0,0 +1,105 @@ +name: BrandMeister Automation + +on: + # Trigger when filter-brandmeister.csv changes on main branch + push: + branches: [ main, master ] + paths: + - 'filters/filter-brandmeister.csv' + + # Manual trigger + workflow_dispatch: + +permissions: + contents: write + +jobs: + generate-and-release: + name: Generate BrandMeister Contacts and Create Release + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate BrandMeister contacts + id: generate + run: | + # Run the clean task which forces re-download and generates all formats + task generate-brandmeister-clean + + # Capture the generated filenames + echo "files<> $GITHUB_OUTPUT + ls -1 outputs/brandmeister-*.csv >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Get timestamp from first file + first_file=$(ls -1 outputs/brandmeister-*.csv | head -1) + timestamp=$(echo "$first_file" | grep -oE '[0-9]{8}_[0-9]{6}') + echo "timestamp=$timestamp" >> $GITHUB_OUTPUT + echo "Generated files with timestamp: $timestamp" + + - name: Commit generated contacts + id: commit + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git add outputs/ + + if git diff --cached --quiet; then + echo "No changes to commit" + echo "committed=false" >> $GITHUB_OUTPUT + else + timestamp="${{ steps.generate.outputs.timestamp }}" + git commit -m "Update BrandMeister contacts - $timestamp [skip ci]" + git push + echo "committed=true" >> $GITHUB_OUTPUT + echo "timestamp=$timestamp" >> $GITHUB_OUTPUT + fi + + - name: Create BrandMeister Release + if: steps.commit.outputs.committed == 'true' + uses: softprops/action-gh-release@v1 + with: + name: "BrandMeister Contacts ${{ steps.generate.outputs.timestamp }}" + tag_name: "bm-${{ steps.generate.outputs.timestamp }}" + files: outputs/brandmeister-*.csv + generate_release_notes: false + body: | + ## BrandMeister Filtered Contacts + + Generated from `filters/filter-brandmeister.csv` with timestamp `${{ steps.generate.outputs.timestamp }}`. + + ### Files + - **brandmeister-radioid-*.csv** - Standard RadioID.net format + - **brandmeister-dm32uv-*.csv** - Baofeng DM32UV format + - **brandmeister-at890-*.csv** - AnyTone 890 format + + ### Stats + Filter contains $(wc -l < filters/filter-brandmeister.csv | xargs) entries. + + Auto-generated by GitHub Actions. + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/generate-contacts.yml b/.github/workflows/generate-contacts.yml new file mode 100644 index 0000000..31f55bc --- /dev/null +++ b/.github/workflows/generate-contacts.yml @@ -0,0 +1,83 @@ +name: Generate Filtered Contacts + +on: + # Manual trigger + workflow_dispatch: + + # Trigger when filter files change on main branch + push: + branches: [ main, master ] + paths: + - 'filters/**' + +jobs: + generate: + name: Generate Filtered Contact Lists + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Cache RadioID.net contacts + uses: actions/cache@v4 + id: cache-radioid + with: + path: user.csv + key: radioid-contacts-${{ github.run_id }} + restore-keys: | + radioid-contacts- + + - name: Download RadioID.net contacts + if: steps.cache-radioid.outputs.cache-hit != 'true' + run: | + echo "Downloading RadioID.net contacts..." + curl -L -o user.csv "https://radioid.net/static/user.csv" + echo "Downloaded $(wc -l < user.csv) lines" + + - name: Build codeplugs binary + run: go build -o codeplugs main.go + + - name: Create generated directory + run: mkdir -p generated + + - name: Generate filtered contacts + run: | + # Process each filter file in filters/ directory + for filter_file in filters/*.csv; do + if [ -f "$filter_file" ]; then + filename=$(basename "$filter_file" .csv) + echo "Processing filter: $filter_file -> generated/${filename}.csv" + ./codeplugs --generate-contacts \ + --filter-file "$filter_file" \ + --source-file user.csv \ + --output-file "generated/${filename}.csv" + fi + done + + - name: Check for changes + id: check-changes + run: | + git add generated/ + if git diff --cached --quiet; then + echo "No changes to commit" + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "Changes detected" + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push generated contacts + if: steps.check-changes.outputs.changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git commit -m "Update filtered contact lists [skip ci]" + git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1399f72 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Build and Release with GoReleaser + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..503c925 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,100 @@ +name: Test + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test-go: + name: Test Go (${{ matrix.go-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.26.1'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build frontend + run: task frontend-build + + - name: Run tests + run: task test + + - name: Run vet + run: task vet + + - name: Check formatting + run: task fmt-check + + test-frontend: + name: Test Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install frontend dependencies + run: task frontend-install + + - name: Build frontend + run: task frontend-build + + build: + name: Build Binary + runs-on: ubuntu-latest + needs: [test-go, test-frontend] + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + run: task build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: codeplugs-binary + path: codeplugs diff --git a/.gitignore b/.gitignore index 9367b0d..614d619 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,11 @@ frontend/dist/ # Logs *.log + +# Generated outputs +outputs/ +generated/ + +# Cached downloads +user.csv +radioid-net-user.csv diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4a28a08 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,97 @@ +version: "2" + +run: + go: '1.26.1' + +linters: + default: all + disable: + - cyclop + - depguard + - dupl + - embeddedstructfieldcheck + - err113 + - errcheck + - errchkjson + - errorlint + - exhaustive + - exhaustruct + - forbidigo + - forcetypeassert + - funlen + - gochecknoglobals + - gochecknoinits + - gocognit + - goconst + - gocritic + - gocyclo + - godoclint + - godot + - godox + - goheader + - gomoddirectives + - gomodguard + - gosec + - ineffassign + - ireturn + - lll + - maintidx + - mirror + - mnd + - modernize + - musttag + - nestif + - nlreturn + - noctx + - noinlineerr + - nolintlint + - nonamedreturns + - paralleltest + - perfsprint + - prealloc + - predeclared + - recvcheck + - revive + - staticcheck + - tagliatelle + - tagalign + - testpackage + - thelper + - tparallel + - unparam + - usestdlibvars + - usetesting + - varnamelen + - wastedassign + - whitespace + - wrapcheck + - wsl + - wsl_v5 + +linters-settings: + errcheck: + exclude-functions: + - (*os.File).Close + - io.Closer.Close + govet: + settings: + shadow: + strict: true + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck + - dupl + - funlen + - path: cmd/generate_contacts\.go + linters: + - errcheck + - path: . + linters: + - ineffassign + # Exclude defer Close() errors - common pattern + - text: "defer .*Close.*" + linters: + - errcheck diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..dd057dc --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,92 @@ +version: 2 + +project_name: codeplugs + +before: + hooks: + - go mod tidy + - task frontend-build + +builds: + - id: codeplugs + main: ./main.go + binary: codeplugs + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: codeplugs + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE + - outputs/brandmeister-*.csv + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + - '^refactor:' + - 'Merge pull request' + - 'Merge branch' + groups: + - title: Features + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: 'Bug fixes' + regexp: "^.*fix[(\\w)]*:+.*$" + order: 1 + - title: Others + order: 999 + +release: + github: + owner: dbehnke + name: codeplugs + prerelease: auto + draft: false + name_template: "{{ .ProjectName }} {{ .Version }}" + header: | + ## Release {{ .Version }} + + Full changelog: https://github.com/dbehnke/codeplugs/compare/{{ .PreviousTag }}...{{ .Tag }} + footer: | + ## Assets + + - **codeplugs_{{ .Version }}_linux_amd64.tar.gz** - Linux AMD64 + - **codeplugs_{{ .Version }}_linux_arm64.tar.gz** - Linux ARM64 + - **codeplugs_{{ .Version }}_darwin_amd64.tar.gz** - macOS Intel + - **codeplugs_{{ .Version }}_darwin_arm64.tar.gz** - macOS Apple Silicon + - **codeplugs_{{ .Version }}_windows_amd64.zip** - Windows AMD64 + - **checksums.txt** - SHA256 checksums diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..9ba8412 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,198 @@ +# Codeplug Management Tool Context + +## Overview + +This project is a Go-based CLI and web application for managing Amateur Radio codeplugs. It ingests data from various sources (Repeaterbook, RadioID.net, existing radio CSVs), stores it in a central SQLite database, and exports to specific radio formats. + +## Architecture + +- **Language**: Go 1.26.1 +- **Database**: SQLite (using `modernc.org/sqlite` to avoid CGO dependencies) +- **ORM**: GORM (`gorm.io/gorm`) for data modeling and migrations +- **Frontend**: Vue 3 + Vite + Bun + Tailwind CSS v4 +- **Build**: Taskfile (go-task) for task automation +- **CI/CD**: GitHub Actions for testing, releases, and contact generation +- **Linting**: golangci-lint with comprehensive configuration + +## Project Structure + +``` +. +├── cmd/ # CLI commands +│ └── generate_contacts.go # Contact list generation +├── models/ # GORM data models (Channel, Contact, Zone, etc.) +├── importer/ # CSV import logic (DM32UV, AnyTone 890, Chirp, etc.) +├── exporter/ # Radio-specific export formats +├── database/ # DB connection and setup +├── api/ # REST API and WebSocket handlers +├── services/ # Business logic +├── frontend/ # Vue 3 web UI +├── filters/ # Filter files for contact generation +├── generated/ # Generated contact lists (git-ignored) +├── scripts/ # Utility scripts (install-hooks.sh) +├── .github/workflows/ # CI/CD workflows +├── Taskfile.yml # Task definitions +└── .golangci.yml # Linter configuration +``` + +## Supported Radios + +- **Radioddity DB25-D** - Via generic CSV import/export +- **Baofeng DM32UV** - Full import/export support with roaming +- **AnyTone 890** - Export support with roaming and scan lists + +## Current Capabilities + +### Import +- Import channels from CSV files with various formats +- Import from Repeaterbook JSON +- Import from ZIP archives containing radio CSVs +- Import DMR contacts/talkgroups from multiple formats + +### Export +- Export to DM32UV format (channels, talkgroups, zones, roaming) +- Export to AnyTone 890 format (channels, contacts, zones, scan lists, roaming) +- Export to Chirp CSV format +- Export to DB25-D CSV format + +### Contact Generation +Generate filtered contact lists from RadioID.net data: +```bash +./codeplugs --generate-contacts \ + --filter-file filters/my-contacts.csv \ + --source-file user.csv \ + --output-file contacts.csv \ + --contact-format dm32uv # or radioid (default), at890 +``` + +**Supported formats:** +- `radioid` - RadioID.net format (default) +- `dm32uv` - Baofeng DM32UV format +- `at890` - AnyTone 890 format (quoted fields, CRLF) + +## Development Workflow + +### Prerequisites +- Go 1.26.1+ +- Bun (for frontend) +- Task (`brew install go-task` or `npm install -g @go-task/cli`) + +### Build +```bash +task build # Build with frontend +task fast-build # Quick build without frontend +``` + +### Testing +```bash +task test # Run all tests +task test-verbose # Verbose output +task test-race # With race detector +``` + +### Code Quality +```bash +task lint # Run golangci-lint +task vet # Run go vet +task fmt # Format code +task ci # Run all CI checks +``` + +### Git Hooks +Pre-commit hook runs golangci-lint before each commit: +```bash +./scripts/install-hooks.sh # Install hooks +git commit --no-verify # Bypass (emergency only) +``` + +## CI/CD + +### Workflows +- **test.yml** - Runs on PRs/pushes: tests, linting, build verification +- **release.yml** - Triggered on tags: cross-platform binary builds +- **generate-contacts.yml** - Automated contact filtering on filter changes + +### Release Process +1. Tag with version: `git tag v1.0.0` +2. Push tag: `git push origin v1.0.0` +3. Release workflow builds binaries for Linux, macOS, Windows (AMD64/ARM64) + +## Data Models + +- **Channel** - Frequency, mode, power, DMR details (color code, time slot, contact) +- **Contact** - DMR contacts/talkgroups (ID, name, type: Group/Private/All Call) +- **Zone** - Organized groups of channels +- **ScanList** - Custom scan lists +- **RoamingChannel/RoamingZone** - DMR roaming configuration +- **ContactList** - Filter lists for contact generation + +## Development Methodology + +### Test Driven Development (TDD) +- Tests SHOULD be written before implementation when practical +- Maintain high coverage on core logic (importers, exporters, API handlers, services) + +### Code Quality +- All code must pass `golangci-lint` (configured in `.golangci.yml`) +- Pre-commit hook enforces linting +- CI runs full test suite and linting + +### Frontend Standards +- **Framework**: Vue 3 + Vite +- **Runtime**: Bun +- **Styling**: Tailwind CSS v4 +- **Design**: Premium aesthetic (Dark mode, glassmorphism, responsive) + +## CLI Examples + +### Import +```bash +# Import generic CSV +./codeplugs --import channels.csv + +# Import DM32UV directory +./codeplugs --import path/to/dm32uv/ --radio dm32uv + +# Import with zone +./codeplugs --import channels.csv --zone "My Zone" +``` + +### Export +```bash +# Export DM32UV +./codeplugs --export output/ --radio dm32uv + +# Export AnyTone 890 +./codeplugs --export output/ --radio at890 + +# Export with filter +./codeplugs --export contacts.csv --use-list "My Filter List" +``` + +### Web UI +```bash +./codeplugs --serve --port 8080 +# Open http://localhost:8080 +``` + +## Configuration + +### Environment Variables +- `GO111MODULE=on` +- `CGO_ENABLED=0` + +### Filter Files +Create filter files in `filters/` directory: +```csv +Radio ID,Callsign,Name +3100000,W9XXX,John Doe +``` + +CI automatically generates filtered contacts when filter files change. + +## Future Work + +- **Web UI Enhancements** - Drag-and-drop zone/channel management +- **Additional Radios** - Expand radio support (Yaesu System Fusion explicitly OUT OF SCOPE) +- **Repeater Integration** - Direct API integration with repeater databases +- **Contact Sync** - Automated contact list synchronization diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2b98d88 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dave Behnke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 26b5115..0e880dc 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,44 @@ A Go-based tool for managing Amateur Radio codeplugs. It supports importing from ## Installation +Install [go-task](https://taskfile.dev/) (recommended): + +```bash +# macOS +brew install go-task + +# Node.js +npm install -g @go-task/cli +``` + +Build the binary: + ```bash -go build -o codeplugs +task build ``` ## Usage +### Taskfile (Recommended) + +The project uses [go-task](https://taskfile.dev/) to manage development tasks. + +| Command | Description | +|---------|-------------| +| `task build` | Build the Go binary (includes frontend) | +| `task fast-build` | Quick build without rebuilding frontend | +| `task test` | Run all tests | +| `task run` | Build and run the server | +| `task clean` | Remove build artifacts and temporary files | +| `task fmt` | Run `go fmt` | +| `task vet` | Run `go vet` | +| `task lint` | Run linter (golangci-lint or vet) | +| `task check` | Run all checks (fmt, vet, lint, test, build) | +| `task ci` | Run CI checks | +| `task frontend-install` | Install frontend dependencies | +| `task frontend-build` | Build the frontend | +| `task generate-contacts` | Generate filtered contact list (set FORMAT=dm32uv/at890) | + ### CLI Commands #### Baofeng DM32UV @@ -66,7 +98,7 @@ go build -o codeplugs Start the server: ```bash -./codeplugs --server +task run ``` Access the UI at `http://localhost:8080`. @@ -76,5 +108,127 @@ Access the UI at `http://localhost:8080`. Run tests: ```bash -go test ./... +task test +``` + +### Filtered Contact Generation + +Generate filtered contact lists from RadioID.net data for specific radios: + +**Basic usage** (RadioID.net format): +```bash +./codeplugs --generate-contacts \ + --filter-file filters/my-contacts.csv \ + --source-file user.csv \ + --output-file contacts.csv +``` + +**DM32UV format**: +```bash +./codeplugs --generate-contacts \ + --filter-file filters/my-contacts.csv \ + --source-file user.csv \ + --output-file digital_contacts.csv \ + --contact-format dm32uv +``` + +**AnyTone 890 format**: +```bash +./codeplugs --generate-contacts \ + --filter-file filters/my-contacts.csv \ + --source-file user.csv \ + --output-file DMRDigitalContactList.CSV \ + --contact-format at890 +``` + +Or use Taskfile: +```bash +# Default (RadioID format) +task generate-contacts + +# DM32UV format +FORMAT=dm32uv task generate-contacts + +# AnyTone 890 format +FORMAT=at890 task generate-contacts +``` + +See `filters/README.md` for filter file format details. + +#### BrandMeister Contact Generation + +Generate contacts filtered by BrandMeister Last Heard activity (all formats at once): + +```bash +# Generate contacts for active BrandMeister users +# Downloads RadioID.net data, applies filters/filter-brandmeister.csv +# Creates three output files in outputs/ directory +task generate-brandmeister + +# Force re-download of RadioID.net data +task generate-brandmeister-clean +``` + +This creates: +- `outputs/brandmeister-radioid-{timestamp}.csv` - Standard RadioID.net format +- `outputs/brandmeister-dm32uv-{timestamp}.csv` - Baofeng DM32UV format +- `outputs/brandmeister-at890-{timestamp}.csv` - AnyTone 890 format + +### Git Hooks + +A pre-commit hook runs `golangci-lint` before each commit to ensure code quality. + +**Install hooks:** +```bash +./scripts/install-hooks.sh +``` + +**Skip hooks (emergency only):** +```bash +git commit --no-verify +``` + +### Automated Releases + +#### BrandMeister Contacts (Auto-Release) + +When you push changes to `filters/filter-brandmeister.csv` on the main branch: + +1. GitHub Actions automatically runs `task generate-brandmeister-clean` +2. Generated contact files are committed to `outputs/` directory +3. A new release is created with tag `bm-{timestamp}` +4. Release includes all three formats (radioid, dm32uv, at890) + +#### Binary Releases (GoReleaser) + +When you push a version tag (e.g., `v1.2.3`): + +1. GitHub Actions runs GoReleaser +2. Builds binaries for Linux, macOS, and Windows (AMD64 and ARM64) +3. Creates a GitHub Release with changelog +4. Includes checksums and SBOM + +**Create a new release:** +```bash +# Local dry-run + task release + +# Build for current platform only +task release-local + +# Create and push version tag (triggers GitHub release) +task tag-release VERSION=v1.2.3 + +# Or manually +git tag -a v1.2.3 -m "Release v1.2.3" +git push origin v1.2.3 +``` + +### Makefile (Legacy) + +A `Makefile` is provided for backward compatibility but is considered deprecated. Use `task` for the latest features. + +```bash +make build +make test ``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..c642f0a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,238 @@ +version: '3' + +vars: + BINARY_NAME: codeplugs + BUILD_DIR: . + FRONTEND_DIR: frontend + +env: + GO111MODULE: "on" + CGO_ENABLED: "0" + +tasks: + default: + desc: Run lint and tests (default) + deps: [vet, test] + + build: + desc: Build the Go binary (includes frontend) + deps: [frontend-build] + sources: + - "**/*.go" + - "go.mod" + - "go.sum" + generates: + - "{{.BINARY_NAME}}" + cmds: + - go build -o {{.BINARY_NAME}} main.go + + fast-build: + desc: Quick build without rebuilding frontend + sources: + - "**/*.go" + - "go.mod" + - "go.sum" + generates: + - "{{.BINARY_NAME}}" + cmds: + - go build -o {{.BINARY_NAME}} main.go + + frontend-install: + desc: Install frontend dependencies + dir: "{{.FRONTEND_DIR}}" + sources: + - package.json + - bun.lockb + generates: + - node_modules/**/** + cmds: + - bun install + + frontend-build: + desc: Build the frontend + deps: [frontend-install] + dir: "{{.FRONTEND_DIR}}" + sources: + - src/**/** + - public/**/** + - index.html + - vite.config.ts + - package.json + generates: + - dist/**/** + cmds: + - bun run build + + test: + desc: Run all tests + cmds: + - go test ./... + + test-verbose: + desc: Run all tests with verbose output + cmds: + - go test -v ./... + + test-race: + desc: Run all tests with race detector + cmds: + - go test -race ./... + + clean: + desc: Remove build artifacts + cmds: + - go clean + - rm -f {{.BINARY_NAME}} + - rm -f codeplugs.db* + - rm -f *.csv + - rm -f *.txt + - rm -rf {{.FRONTEND_DIR}}/dist + + run: + desc: Build and run the server + deps: [build] + cmds: + - ./{{.BINARY_NAME}} --serve + + vet: + desc: Run go vet + cmds: + - go vet ./... + + fmt: + desc: Run go fmt + cmds: + - go fmt ./... + + fmt-check: + desc: Check if code is formatted + cmds: + - | + if [ -n "$(gofmt -l .)" ]; then + echo "Code is not formatted. Run 'task fmt' to fix." + gofmt -l . + exit 1 + fi + + lint: + desc: Run linter (golangci-lint if available, else vet) + cmds: + - | + if command -v golangci-lint &> /dev/null; then + golangci-lint run ./... + else + echo "golangci-lint not found, running go vet instead" + go vet ./... + fi + + generate-contacts: + desc: Generate filtered contact list from filters/example-filter.csv + deps: [fast-build] + vars: + FILTER_FILE: '{{.FILTER_FILE | default "filters/example-filter.csv"}}' + SOURCE_FILE: '{{.SOURCE_FILE | default "user.csv"}}' + OUTPUT_FILE: '{{.OUTPUT_FILE | default "generated/contacts.csv"}}' + FORMAT: '{{.FORMAT | default "radioid"}}' + cmds: + - | + if [ ! -f '{{.SOURCE_FILE}}' ]; then + echo "Downloading RadioID.net contacts..." + curl -L -o '{{.SOURCE_FILE}}' 'https://radioid.net/static/user.csv' + fi + - ./{{.BINARY_NAME}} --generate-contacts --filter-file '{{.FILTER_FILE}}' --source-file '{{.SOURCE_FILE}}' --output-file '{{.OUTPUT_FILE}}' --contact-format '{{.FORMAT}}' + + ci: + desc: Run all CI checks + deps: [fmt-check, vet, lint, test] + + check: + desc: Run all checks (fmt, vet, lint, test, build) + deps: [fmt-check, vet, lint, test, build] + + generate-brandmeister: + desc: Generate BrandMeister filtered contacts in all formats + deps: [fast-build] + vars: + FILTER_FILE: 'filters/filter-brandmeister.csv' + SOURCE_FILE: 'outputs/radioid-net-user.csv' + TIMESTAMP: + sh: date +%Y%m%d_%H%M%S + cmds: + - | + if [ ! -f '{{.SOURCE_FILE}}' ]; then + echo "Downloading RadioID.net contacts..." + curl -L -o '{{.SOURCE_FILE}}' 'https://radioid.net/static/user.csv' + echo "Downloaded to {{.SOURCE_FILE}}" + else + echo "Using existing source file: {{.SOURCE_FILE}}" + echo "Delete it to force re-download" + fi + - echo "Generating BrandMeister filtered contacts..." + - | + echo "Filter: {{.FILTER_FILE}} ($(wc -l < {{.FILTER_FILE}} | xargs) lines)" + - > + ./{{.BINARY_NAME}} --generate-contacts + --filter-file '{{.FILTER_FILE}}' + --source-file '{{.SOURCE_FILE}}' + --output-file 'outputs/brandmeister-radioid-{{.TIMESTAMP}}.csv' + --contact-format 'radioid' + - > + ./{{.BINARY_NAME}} --generate-contacts + --filter-file '{{.FILTER_FILE}}' + --source-file '{{.SOURCE_FILE}}' + --output-file 'outputs/brandmeister-dm32uv-{{.TIMESTAMP}}.csv' + --contact-format 'dm32uv' + - > + ./{{.BINARY_NAME}} --generate-contacts + --filter-file '{{.FILTER_FILE}}' + --source-file '{{.SOURCE_FILE}}' + --output-file 'outputs/brandmeister-at890-{{.TIMESTAMP}}.csv' + --contact-format 'at890' + - echo "" + - echo "Generated files:" + - ls -lh outputs/brandmeister-*.csv | awk '{print $9, "(" $5 ")"}' + + generate-brandmeister-clean: + desc: Clean and regenerate BrandMeister contacts (force re-download) + cmds: + - rm -f outputs/radioid-net-user.csv + - task generate-brandmeister + + release: + desc: Build release binaries locally with GoReleaser (dry-run) + deps: [frontend-build] + cmds: + - | + if ! command -v goreleaser &> /dev/null; then + echo "Installing goreleaser..." + go install github.com/goreleaser/goreleaser/v2@latest + fi + - goreleaser release --snapshot --clean + + release-local: + desc: Build release binaries for current platform only + deps: [frontend-build] + cmds: + - | + if ! command -v goreleaser &> /dev/null; then + echo "Installing goreleaser..." + go install github.com/goreleaser/goreleaser/v2@latest + fi + - goreleaser build --single-target --snapshot --clean + - echo "Binary built in dist/" + - ls -lh dist/ + + tag-release: + desc: Create a new version tag and push to trigger release + vars: + VERSION: '{{.VERSION | default ""}}' + cmds: + - | + if [ -z "{{.VERSION}}" ]; then + echo "Usage: VERSION=v1.2.3 task tag-release" + exit 1 + fi + - git tag -a "{{.VERSION}}" -m "Release {{.VERSION}}" + - git push origin "{{.VERSION}}" + - echo "Pushed tag {{.VERSION}} - GitHub Actions will build and release" + diff --git a/cmd/generate_contacts.go b/cmd/generate_contacts.go new file mode 100644 index 0000000..b919399 --- /dev/null +++ b/cmd/generate_contacts.go @@ -0,0 +1,246 @@ +package cmd + +import ( + "encoding/csv" + "fmt" + "io" + "os" + "strconv" + "strings" + + "codeplugs/importer" +) + +// GenerateContacts generates a filtered contact list from a source CSV. +// It loads DMR IDs from the filter file and only includes contacts from the +// source file that match those IDs. +// +// Parameters: +// - filterFile: Path to CSV file containing DMR IDs to filter by +// - sourceFile: Path to source CSV (RadioID.net format) +// - outputFile: Path to output filtered CSV +// - format: Output format - "radioid" (default) or "dm32uv" +// +// Source CSV format (RadioID.net): +// +// radio_id,callsign,first_name,last_name,city,state,country,remarks +// +// Output formats: +// - radioid: Same as source format +// - dm32uv: No.,ID,Repeater,Name,City,Province,Country,Remark,Type,Alert Call +// - at890: "No.","Radio ID","Callsign","Name","City","State","Country","Remarks","Call Type","Call Alert" (quoted) +// +// Filter CSV format: +// +// Can be a simple list of IDs or CSV with "Radio ID", "DMR ID", or "id" column +func GenerateContacts(filterFile, sourceFile, outputFile, format string) error { + // Load filter list + allowedIDs, err := importer.LoadFilterList(filterFile) + if err != nil { + return fmt.Errorf("failed to load filter list: %w", err) + } + + if len(allowedIDs) == 0 { + return fmt.Errorf("no IDs found in filter file: %s", filterFile) + } + + fmt.Printf("Loaded %d IDs from filter file\n", len(allowedIDs)) + + // Open source file + srcFile, err := os.Open(sourceFile) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + // Create output file + outFile, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + // Read source CSV + reader := csv.NewReader(srcFile) + reader.FieldsPerRecord = -1 // Allow variable fields + + // Read header + header, err := reader.Read() + if err != nil { + return fmt.Errorf("failed to read header: %w", err) + } + + // Find column indices in source + idCol := -1 + callsignCol := -1 + firstNameCol := -1 + lastNameCol := -1 + cityCol := -1 + stateCol := -1 + countryCol := -1 + remarksCol := -1 + + for i, col := range header { + lowerCol := strings.ToLower(col) + switch lowerCol { + case "radio_id", "radio id", "dmr_id", "dmr id", "id": + idCol = i + case "callsign": + callsignCol = i + case "first_name", "first name": + firstNameCol = i + case "last_name", "last name": + lastNameCol = i + case "city": + cityCol = i + case "state": + stateCol = i + case "country": + countryCol = i + case "remarks", "remark": + remarksCol = i + } + } + + if idCol == -1 { + return fmt.Errorf("could not find ID column in header: %v", header) + } + + // Create writer based on format + var writer *csv.Writer + var writeRecord func([]string) error + + switch format { + case "dm32uv": + writer = csv.NewWriter(outFile) + if err := writer.Write([]string{"No.", "ID", "Repeater", "Name", "City", "Province", "Country", "Remark", "Type", "Alert Call"}); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + writeRecord = func(record []string) error { + return writer.Write(record) + } + case "at890": + // AnyTone 890 uses forced quotes and CRLF + writeRecord = func(record []string) error { + for i, field := range record { + if i > 0 { + if _, err := outFile.WriteString(","); err != nil { + return err + } + } + escaped := strings.ReplaceAll(field, "\"", "\"\"") + if _, err := fmt.Fprintf(outFile, "\"%s\"", escaped); err != nil { + return err + } + } + if _, err := outFile.WriteString("\r\n"); err != nil { + return err + } + return nil + } + // Write header + if err := writeRecord([]string{"No.", "Radio ID", "Callsign", "Name", "City", "State", "Country", "Remarks", "Call Type", "Call Alert"}); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + default: // radioid + writer = csv.NewWriter(outFile) + if err := writer.Write(header); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + writeRecord = func(record []string) error { + return writer.Write(record) + } + } + + // Process records + processed := 0 + included := 0 + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + continue + } + + processed++ + + if len(record) <= idCol { + continue + } + + idStr := strings.TrimSpace(record[idCol]) + id, err := strconv.Atoi(idStr) + if err != nil { + continue + } + + if allowedIDs[id] { + idx := included + 1 + callsign := getColOrDefault(record, callsignCol, "") + firstName := getColOrDefault(record, firstNameCol, "") + lastName := getColOrDefault(record, lastNameCol, "") + name := strings.TrimSpace(firstName + " " + lastName) + if name == "" { + name = callsign + } + + var outputRecord []string + switch format { + case "dm32uv": + outputRecord = []string{ + strconv.Itoa(idx), + idStr, + callsign, + name, + getColOrDefault(record, cityCol, ""), + getColOrDefault(record, stateCol, ""), + getColOrDefault(record, countryCol, ""), + getColOrDefault(record, remarksCol, ""), + "Private Call", + "0", + } + case "at890": + outputRecord = []string{ + strconv.Itoa(idx), + idStr, + callsign, + name, + getColOrDefault(record, cityCol, ""), + getColOrDefault(record, stateCol, ""), + getColOrDefault(record, countryCol, ""), + getColOrDefault(record, remarksCol, ""), + "Private Call", + "None", + } + default: + outputRecord = record + } + + if err := writeRecord(outputRecord); err != nil { + return fmt.Errorf("failed to write record: %w", err) + } + included++ + } + } + + if writer != nil { + writer.Flush() + if err := writer.Error(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + } + + fmt.Printf("Processed %d records, included %d in output\n", processed, included) + fmt.Printf("Filtered contacts written to: %s (format: %s)\n", outputFile, format) + + return nil +} + +func getColOrDefault(record []string, col int, defaultVal string) string { + if col >= 0 && col < len(record) { + return record[col] + } + return defaultVal +} diff --git a/filters/README.md b/filters/README.md new file mode 100644 index 0000000..2a97e38 --- /dev/null +++ b/filters/README.md @@ -0,0 +1,51 @@ +# Filter Files + +This directory contains CSV files that specify which DMR contacts to include in generated contact lists. + +## Format + +Filter files can be in one of these formats: + +### 1. CSV with Header (Recommended) + +```csv +Radio ID,Callsign,Name +1234567,N0XXX,John Doe +7654321,N0YYY,Jane Smith +``` + +The following column headers are recognized: +- `Radio ID` or `Radio_ID` +- `DMR ID` or `DMR_ID` +- `id` (case insensitive) + +### 2. Plain List of IDs + +```csv +1234567 +7654321 +1111111 +``` + +Each line contains one DMR ID. + +## Usage + +1. Create a new filter file in this directory (e.g., `my-contacts.csv`) +2. Add the DMR IDs you want to include +3. Commit and push to the main branch +4. The CI workflow will automatically generate a filtered contact list in the `generated/` directory + +## Automation + +When you push changes to filter files on the main branch, the GitHub Actions workflow will: + +1. Download the latest RadioID.net contacts +2. Filter them based on your filter file +3. Commit the results to the `generated/` directory + +You can also trigger the workflow manually from the Actions tab. + +## Example + +See `example-filter.csv` for a sample filter file. diff --git a/filters/example-filter.csv b/filters/example-filter.csv new file mode 100644 index 0000000..540bb3c --- /dev/null +++ b/filters/example-filter.csv @@ -0,0 +1,4 @@ +Radio ID,Callsign,Name +1234567,N0XXX,John Doe +7654321,N0YYY,Jane Smith +1111111,KF8ABC,Bob Wilson diff --git a/generated/.gitkeep b/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index 9069dee..62d06ac 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module codeplugs -go 1.25.4 +go 1.26.1 require ( github.com/gorilla/websocket v1.5.3 diff --git a/importer/anytone890.go b/importer/anytone890.go index da3a39b..1cf11ba 100644 --- a/importer/anytone890.go +++ b/importer/anytone890.go @@ -378,7 +378,7 @@ func ImportAnyTone890RoamingZones(db *gorm.DB, r io.Reader) error { if name == "" { continue } - + zone, err := models.FindOrCreateRoamingZone(db, name) if err != nil { return err diff --git a/main.go b/main.go index ce73c43..a3090e2 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "strings" "codeplugs/api" + "codeplugs/cmd" "codeplugs/database" "codeplugs/exporter" "codeplugs/importer" @@ -42,8 +43,29 @@ func main() { viewList := flag.String("view-list", "", "View filter list stats (use 'all' for summary, or specify name)") useList := flag.String("use-list", "", "Filter export using this named list from the database") + // Contact Generation Flags + generateContacts := flag.Bool("generate-contacts", false, "Generate filtered contact list from source CSV") + genFilterFile := flag.String("filter-file", "", "Filter file containing DMR IDs (required with --generate-contacts)") + genSourceFile := flag.String("source-file", "", "Source CSV file (RadioID.net format)") + genOutputFile := flag.String("output-file", "filtered_contacts.csv", "Output file for filtered contacts") + genFormat := flag.String("contact-format", "radioid", "Output format for contacts: radioid (default), dm32uv, or at890") + flag.Parse() + // Handle Contact Generation (no DB required) + if *generateContacts { + if *genFilterFile == "" { + log.Fatal("Error: --filter-file is required when using --generate-contacts") + } + if *genSourceFile == "" { + log.Fatal("Error: --source-file is required when using --generate-contacts") + } + if err := cmd.GenerateContacts(*genFilterFile, *genSourceFile, *genOutputFile, *genFormat); err != nil { + log.Fatalf("Error generating contacts: %v", err) + } + return + } + database.Connect(*dbPath) if *fixBandwidth { diff --git a/main_phase3_test.go b/main_phase3_test.go index f2b90f5..0864b62 100644 --- a/main_phase3_test.go +++ b/main_phase3_test.go @@ -100,7 +100,7 @@ func TestPhase3_APIStandardization(t *testing.T) { if len(resp.Data) != 1 { t.Errorf("Expected 1 channel in Data, got %d", len(resp.Data)) } - + if resp.Data[0].Name != "API Test Channel" { t.Errorf("Expected channel name 'API Test Channel', got '%s'", resp.Data[0].Name) } @@ -235,7 +235,7 @@ func TestPhase3_DM32UV_RoundTrip(t *testing.T) { database.DB.Model(&sl).Association("Channels").Append(&ch2) rz := models.RoamingZone{Name: "Roam Zone 1"} - database.DB.Create(&rz) + database.DB.Create(&rz) rc1 := models.RoamingChannel{Name: "Roam Ch 1", RxFrequency: 444.000, TxFrequency: 449.000, ColorCode: 1, TimeSlot: 1} database.DB.Create(&rc1) database.DB.Model(&rz).Association("Channels").Append(&rc1) diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..a61ac1c --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +# Install git hooks for codeplugs project +# Run this script after cloning the repository + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" + +echo "Installing git hooks..." + +# Create pre-commit hook +cat > "$HOOKS_DIR/pre-commit" << 'EOF' +#!/bin/sh + +# Pre-commit hook to run golangci-lint +# This ensures code quality before commits are made + +set -e + +echo "Running golangci-lint..." + +# Check if golangci-lint is installed +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "golangci-lint not found. Installing..." + # Try to install via go install + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest || { + echo "Failed to install golangci-lint. Please install it manually:" + echo "https://golangci-lint.run/usage/install/" + exit 1 + } +fi + +# Run golangci-lint +if ! golangci-lint run ./...; then + echo "" + echo "golangci-lint found issues. Please fix them before committing." + echo "Run 'golangci-lint run ./...' to see details." + exit 1 +fi + +echo "golangci-lint passed!" + +# Optional: Run tests +# Uncomment the following lines if you want tests to run on every commit +# echo "Running tests..." +# if ! go test ./...; then +# echo "Tests failed. Please fix them before committing." +# exit 1 +# fi +# echo "Tests passed!" + +exit 0 +EOF + +chmod +x "$HOOKS_DIR/pre-commit" + +echo "Pre-commit hook installed successfully!" +echo "" +echo "The hook will run golangci-lint before each commit." +echo "To skip the hook in an emergency, use: git commit --no-verify"