diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml index 9c03d618..da9b9138 100644 --- a/.github/workflows/macOSBuild.yml +++ b/.github/workflows/macOSBuild.yml @@ -1,72 +1,374 @@ name: macOS deployment -#on: [push, pull_request] - on: workflow_dispatch: push: - branches: - - master + branches: + - master + tags: + - 'v*' jobs: macos-build: - name: MacOS Build - strategy: - matrix: - os: [macos-12, macos-13] - - runs-on: ${{ matrix.os }} - - steps: - - name: Install Dependencies - run: | - unset HOMEBREW_NO_INSTALL_FROM_API - brew update - brew upgrade || true - brew install qt6 - brew install qt6-webengine - brew link qt6 --force - brew link qt6-webengine --force - brew install hamlib - brew link hamlib --force - brew install qtkeychain - brew install dbus-glib - brew install brotli - brew install icu4c - brew install pkg-config - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get version from tag - run : | - TAGVERSION=$(git describe --tags) - echo "TAGVERSION=${TAGVERSION:1}" >> $GITHUB_ENV - - - name: Configure and compile - run: | - mkdir build - cd build - qmake -config release .. - make -j4 - - name: Build dmg - run: | - cd build - macdeployqt qlog.app -executable=./qlog.app/Contents/MacOS/qlog - cp `brew --prefix`/lib/libhamlib.dylib qlog.app/Contents/Frameworks/libhamlib.dylib - cp `brew --prefix`/lib/libqt6keychain.dylib qlog.app/Contents/Frameworks/libqt6keychain.dylib - cp `brew --prefix`/lib/libdbus-1.dylib qlog.app/Contents/Frameworks/libdbus-1.dylib - cp `brew --prefix brotli`/lib/libbrotlicommon.1.dylib qlog.app/Contents/Frameworks/libbrotlicommon.1.dylib - cp `brew --prefix`/opt/icu4c/lib/libicui18n.74.dylib qlog.app/Contents/Frameworks/libicui18n.74.dylib - install_name_tool -change `brew --prefix`/lib/libhamlib.dylib @executable_path/../Frameworks/libhamlib.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change `brew --prefix`/lib/libqt6keychain.dylib @executable_path/../Frameworks/libqt6keychain.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change @loader_path/libbrotlicommon.1.dylib @executable_path/../Frameworks/libbrotlicommon.1.dylib qlog.app/Contents/MacOS/qlog - install_name_tool -change /usr/local/opt/icu4c/lib/libicui18n.74.dylib @executable_path/../Frameworks/libicui18n.74.dylib qlog.app/Contents/MacOS/qlog - otool -L qlog.app/Contents/MacOS/qlog - macdeployqt qlog.app -dmg - - name: Copy artifact - uses: actions/upload-artifact@v4 - with: - name: QLog-${{ env.TAGVERSION }}-${{ matrix.os }} - path: /Users/runner/work/QLog/QLog/build/qlog.dmg + name: macOS Build + runs-on: macos-latest + + env: + APP_NAME: QLog + BUNDLE_BIN: qlog + QT_PREFIX: /opt/homebrew + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install Dependencies + run: | + set -euo pipefail + unset HOMEBREW_NO_INSTALL_FROM_API + brew update + brew upgrade || true + brew install \ + qt6 \ + hamlib \ + qtkeychain \ + dbus-glib \ + brotli \ + icu4c \ + pkg-config \ + autoconf \ + automake \ + libtool \ + libusb + # qt6 is keg-only; many Qt-aware tools rely on the linkage being visible + brew link qt6 --force || true + brew link hamlib --force || true + + - name: Get version from tag + run: | + set -euo pipefail + # If we're on a tag like v0.50.0, use that; otherwise fall back to describe. + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + TAGVERSION="${GITHUB_REF#refs/tags/v}" + else + RAW="$(git describe --tags --always 2>/dev/null || echo dev)" + TAGVERSION="${RAW#v}" + fi + echo "TAGVERSION=${TAGVERSION}" >> "$GITHUB_ENV" + echo "Version: ${TAGVERSION}" + + - name: Ensure QtSvg module is enabled + run: | + set -euo pipefail + PRO="qlog.pro" + if ! grep -q ' svg' "$PRO"; then + echo "Adding svg to QT modules" + awk ' + BEGIN { added=0 } + /^[[:space:]]*QT[[:space:]]/ && !added { + print $0 " svg"; added=1; next + } + { print } + ' "$PRO" > "$PRO.tmp" && mv "$PRO.tmp" "$PRO" + else + echo "QtSvg already present" + fi + + - name: Configure and compile + run: | + set -euo pipefail + mkdir -p build + cd build + qmake CONFIG+=release .. + make -j"$(sysctl -n hw.ncpu)" + + - name: Run macdeployqt + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + # macdeployqt builds Contents/Frameworks and rewrites Qt links. + macdeployqt "$APP" \ + -always-overwrite \ + -verbose=2 \ + -executable="$APP/Contents/MacOS/$BUNDLE_BIN" \ + -libpath="$QT_PREFIX/opt/qtbase/lib" \ + -libpath="$QT_PREFIX/opt/qtdeclarative/lib" \ + -libpath="$QT_PREFIX/opt/qtwebengine/lib" \ + -libpath="$QT_PREFIX/opt/qtsvg/lib" + + - name: Bundle rigctld and fix linkage + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + MACOS_DIR="$APP/Contents/MacOS" + FRAMEWORKS_DIR="$APP/Contents/Frameworks" + mkdir -p "$MACOS_DIR" "$FRAMEWORKS_DIR" + + RIGCTLD_SRC="$(command -v rigctld)" + [ -n "$RIGCTLD_SRC" ] || { echo "ERROR: rigctld not found"; exit 1; } + echo "Using rigctld from: $RIGCTLD_SRC" + cp -f "$RIGCTLD_SRC" "$MACOS_DIR/rigctld" + chmod 0755 "$MACOS_DIR/rigctld" + + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$MACOS_DIR/rigctld" 2>/dev/null || true + + # Recursively bundle any /opt/homebrew or /usr/local dependency, fix + # install IDs to @rpath, and rewrite the parent's link. + bundle_deps() { + local target="$1" + local dep base + otool -L "$target" | awk 'NR>1 {print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*|/usr/local/*) + base="$(basename "$dep")" + if [ ! -f "$FRAMEWORKS_DIR/$base" ]; then + echo " -> bundling $base (from $dep)" + cp -f "$dep" "$FRAMEWORKS_DIR/$base" + chmod u+w "$FRAMEWORKS_DIR/$base" + install_name_tool -id "@rpath/$base" "$FRAMEWORKS_DIR/$base" + bundle_deps "$FRAMEWORKS_DIR/$base" + fi + install_name_tool -change "$dep" "@rpath/$base" "$target" + ;; + esac + done + } + + bundle_deps "$MACOS_DIR/rigctld" + bundle_deps "$MACOS_DIR/$BUNDLE_BIN" + + echo "rigctld linkage:" + otool -L "$MACOS_DIR/rigctld" + echo "$BUNDLE_BIN linkage:" + otool -L "$MACOS_DIR/$BUNDLE_BIN" + + - name: Fix QtWebEngineProcess rpaths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + QWEP="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + [ -f "$QWEP" ] || { echo "QtWebEngineProcess not found at $QWEP"; exit 1; } + + install_name_tool -add_rpath \ + "@executable_path/../../../../Frameworks" "$QWEP" 2>/dev/null || true + + # Rewrite any remaining hardcoded Qt framework references to @rpath. + otool -L "$QWEP" | awk 'NR>1 {print $1}' \ + | grep '^/opt/homebrew/.*\.framework/' \ + | while read -r dep; do + fw="$(basename "$dep")" + fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")" + install_name_tool -change "$dep" \ + "@rpath/$fwdir/Versions/A/$fw" "$QWEP" || true + done + + echo "QtWebEngineProcess after fixup:" + otool -L "$QWEP" + + - name: Scan for hardcoded Homebrew paths + run: | + set -euo pipefail + cd build + APP="$APP_NAME.app" + BAD=0 + while IFS= read -r -d '' BIN; do + if otool -L "$BIN" 2>/dev/null \ + | awk 'NR>1 {print $1}' \ + | grep -E '^(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' >/dev/null; then + echo "Hardcoded Homebrew path in: $BIN" + otool -L "$BIN" | awk 'NR>1 {print " " $1}' \ + | grep -E '^[[:space:]]+(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' + BAD=1 + fi + done < <(find "$APP" -type f -perm +111 -print0) + if [ "$BAD" -eq 1 ]; then + echo "ERROR: hardcoded Homebrew paths remain. Bundle would fail on user machines." + exit 1 + fi + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + ENT="$GITHUB_WORKSPACE/entitlements.xml" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + + test -f "$ENT" || { echo "Missing entitlements.xml at $ENT"; exit 1; } + + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" + security default-keychain -d user -s "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + + security find-identity -v -p codesigning "$KEYCHAIN" || true + + # 1) Sign every Mach-O inside the bundle (no entitlements), skipping + # QtWebEngineProcess which we sign with entitlements below. + while IFS= read -r -d '' F; do + if file "$F" | grep -q "Mach-O"; then + if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then + continue + fi + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$F" + fi + done < <(find "$APP" -type f -print0) + + # 2) Sign QtWebEngineProcess binaries WITH entitlements + while IFS= read -r -d '' BIN; do + echo "Signing WebEngine helper: $BIN" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$BIN" + done < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f -print0) + + # 3) Sign QtWebEngineProcess.app bundles WITH entitlements + while IFS= read -r -d '' HAPP; do + echo "Signing WebEngine helper app: $HAPP" + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$HAPP" + done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0) + + # 4) Re-seal every framework so the framework's seal includes any + # nested helper apps we just re-signed. Without this, signing the + # outer app fails with "nested code is modified or invalid". + while IFS= read -r -d '' FW; do + echo "Re-signing framework: $FW" + /usr/bin/codesign --force --timestamp --options runtime \ + -s "$MACOS_CERTIFICATE_NAME" "$FW" + done < <(find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0) + + # 5) Sign the outer app bundle WITH entitlements + /usr/bin/codesign --force --timestamp --options runtime \ + --entitlements "$ENT" \ + -s "$MACOS_CERTIFICATE_NAME" "$APP" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + spctl -a -t exec -vv "$APP" || true + + - name: Notarize app bundle + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + APP="$GITHUB_WORKSPACE/build/$APP_NAME.app" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + ditto -c -k --sequesterRsrc --keepParent "$APP" "notarization.zip" + + xcrun notarytool submit "notarization.zip" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_log.json + cat notarization_log.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])') + + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "Notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + + - name: Build DMG + run: | + set -euo pipefail + VERSION="${TAGVERSION%%-*}" + DMG_NAME="$APP_NAME.v${VERSION}.dmg" + echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + STAGING="$GITHUB_WORKSPACE/build/dmg-staging" + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -R "$GITHUB_WORKSPACE/build/$APP_NAME.app" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -volname "$APP_NAME Installer" \ + -srcfolder "$STAGING" \ + -ov -format UDZO \ + "$GITHUB_WORKSPACE/build/$DMG_NAME" + ls -lh "$GITHUB_WORKSPACE/build/$DMG_NAME" + + - name: Codesign DMG + env: + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + /usr/bin/codesign --force --timestamp \ + -s "$MACOS_CERTIFICATE_NAME" "$DMG" + /usr/bin/codesign --verify --verbose=2 "$DMG" + + - name: Notarize DMG + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + set -euo pipefail + DMG="$GITHUB_WORKSPACE/build/$DMG_NAME" + + xcrun notarytool store-credentials "notarytool-profile" \ + --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \ + --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \ + --password "$PROD_MACOS_NOTARIZATION_PWD" + + xcrun notarytool submit "$DMG" \ + --keychain-profile "notarytool-profile" \ + --wait --output-format json > notarization_dmg.json + cat notarization_dmg.json + + NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])') + NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["status"])') + xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true + + if [ "$NOTARY_STATUS" != "Accepted" ]; then + echo "DMG notarization failed: $NOTARY_STATUS" + exit 1 + fi + + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: QLog-${{ env.TAGVERSION }}-macos + path: ${{ github.workspace }}/build/${{ env.DMG_NAME }} + if-no-files-found: error diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..f13cc4e7 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,343 @@ +# ============================================================ +# QLog Windows Build — GitHub Actions +# +# Orignal from HB9VQQ +# +# Builds QLog for Windows (MSVC 2022 x64) and creates: +# - A portable ZIP (QLog-Portable-Windows) +# - A Qt IFW installer (QLog-Installer-Windows) +# +# Triggers: +# - push to master → build + artifacts (for testing) +# - push a tag v* → build + artifacts + GitHub Release +# - manual via Actions tab +# ============================================================ +name: Windows Build + +on: + push: + branches: [master] + tags: ['v*'] + paths-ignore: + - '*.md' + - 'doc/**' + - 'LICENSE' + - '.gitignore' + workflow_dispatch: # manual trigger button in Actions tab + +env: + QT_VERSION: '6.10.2' + HAMLIB_VERSION: '4.7.1' + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 60 + + steps: + # —— 1. Checkout source ——————————————————————————————————— + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # —— 2. MSVC 2022 x64 environment ———————————————————————— + - name: Setup MSVC + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + # —— 3. Install Qt 6 with required modules ——————————————— + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: >- + qtwebengine qtcharts qtserialport + qtwebsockets qtwebchannel qtpositioning + tools: tools_ifw + source: true + src-archives: qtbase + cache: true + + # —— 4. Download Hamlib w64 binary ———————————————————————— + - name: Download Hamlib + shell: pwsh + run: | + $zip = "hamlib-w64-${{ env.HAMLIB_VERSION }}.zip" + $url = "https://github.com/Hamlib/Hamlib/releases/download/${{ env.HAMLIB_VERSION }}/$zip" + Invoke-WebRequest $url -OutFile $zip + Expand-Archive $zip -DestinationPath C:\ + Rename-Item "C:\hamlib-w64-${{ env.HAMLIB_VERSION }}" C:\hamlib + + $msvc = "C:\hamlib\lib\msvc" + if (!(Test-Path $msvc)) { New-Item -ItemType Directory $msvc | Out-Null } + if (!(Test-Path "$msvc\libhamlib-4.lib")) { + $def = Get-ChildItem C:\hamlib -Recurse -Filter "libhamlib-4.def" | Select -First 1 + if ($def) { + $dest = "$msvc\libhamlib-4.def" + if ($def.FullName -ne $dest) { Copy-Item $def.FullName $dest } + Push-Location $msvc + lib /machine:X64 /def:libhamlib-4.def /out:libhamlib-4.lib + Pop-Location + } + } + + # —— 5. pthreads + zlib via fresh vcpkg clone ————————————— + - name: Install vcpkg dependencies + shell: cmd + run: | + git clone --depth 1 https://github.com/microsoft/vcpkg.git C:\vcpkg + cd /d C:\vcpkg + call bootstrap-vcpkg.bat + vcpkg install pthreads:x64-windows zlib:x64-windows openssl:x64-windows + + # —— 6. Build QtKeychain from source —————————————————————— + - name: Build QtKeychain + shell: cmd + run: | + git clone --depth 1 https://github.com/frankosterfeld/qtkeychain.git C:\qtkeychain-src + cd /d C:\qtkeychain-src + cmake -B build -G "NMake Makefiles" ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DBUILD_WITH_QT6=ON ^ + -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" ^ + -DCMAKE_INSTALL_PREFIX=C:\qtkeychain + cmake --build build --config Release + cmake --install build + + # —— 7. Locate OpenSSL ———————————————————————————————————— + - name: Locate OpenSSL + shell: pwsh + run: | + $candidates = @( + "C:\Program Files\OpenSSL-Win64", + "C:\Program Files\OpenSSL", + "C:\OpenSSL-Win64" + ) + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + if (!$ssl) { + choco install openssl -y --no-progress + $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1 + } + if ($ssl) { + echo "OPENSSL_DIR=$ssl" >> $env:GITHUB_ENV + } else { + echo "OPENSSL_DIR=" >> $env:GITHUB_ENV + } + + # —— 7c. Install OmniRig v1 + v2 ————————————————————————— + - name: Install OmniRig + shell: pwsh + run: | + # --- OmniRig v1: InnoSetup installer (works silently) --- + Write-Host "=== OmniRig v1 ===" + Invoke-WebRequest "http://www.dxatlas.com/OmniRig/Files/OmniRig.zip" -OutFile OmniRig.zip + Expand-Archive OmniRig.zip -DestinationPath C:\omnirig-v1-tmp + $setup = Get-ChildItem C:\omnirig-v1-tmp -Recurse -Filter "OmniRigSetup.exe" | Select -First 1 + if ($setup) { + Start-Process -FilePath $setup.FullName -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait + Start-Sleep -Seconds 2 + } + $v1path = "C:\Program Files (x86)\Afreet\OmniRig\OmniRig.exe" + if (Test-Path $v1path) { + Write-Host "v1 OK: $v1path" + } else { + Write-Error "v1 FAILED" + } + + # --- OmniRig v2: try installer with timeout, fall back to v1 .tlb --- + Write-Host "=== OmniRig v2 ===" + $v2installed = $false + Invoke-WebRequest "https://www.hb9ryz.ch/downloads/install_omnirigv21.zip" -OutFile omnirigv2.zip + Expand-Archive omnirigv2.zip -DestinationPath C:\omnirig-v2-tmp + + $installer = Get-ChildItem C:\omnirig-v2-tmp -Recurse -Filter "*.exe" | Select -First 1 + if ($installer) { + Write-Host "Trying /S (NSIS) with 30s timeout..." + $proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" -PassThru + $finished = $proc.WaitForExit(30000) + if (!$finished) { + Write-Host "Installer timed out — killing" + $proc.Kill() + } + $v2path = "C:\Program Files (x86)\Omni-Rig V2\omnirig2.exe" + if (Test-Path $v2path) { + Write-Host "v2 installed via /S" + $v2installed = $true + } + } + + if (!$v2installed) { + # Fallback: use v1 .tlb and patch source to remove v2-only features + Write-Host "v2 installer failed — using v1 .tlb fallback with Rig3/Rig4 patch" + Invoke-WebRequest "https://raw.githubusercontent.com/VE3NEA/OmniRig/master/OmniRig.tlb" -OutFile "C:\omnirig-v1.tlb" + + $v2file = "rig\drivers\Omnirigv2RigDrv.cpp" + $content = Get-Content $v2file -Raw + + # Patch #import to use v1 .tlb + $content = $content.Replace( + 'C:\\Program Files (x86)\\Omni-Rig V2\\omnirig2.exe', + 'C:\\omnirig-v1.tlb') + + # Remove get_Rig3/get_Rig4 calls (v1 only has Rig1+Rig2) + # Change case 3/4 to fall through to default (E_INVALIDARG) + $content = $content.Replace( + 'case 3: hr = omniInterface->get_Rig3(&rig); break;', + 'case 3: /* Rig3 not available in v1 fallback */') + $content = $content.Replace( + 'case 4: hr = omniInterface->get_Rig4(&rig); break;', + 'case 4: /* Rig4 not available in v1 fallback */') + + Set-Content $v2file $content -NoNewline + + Write-Host "Patched v2 source:" + Select-String '#import' $v2file | ForEach-Object { $_.Line.Trim() } + Select-String 'case 3:|case 4:' $v2file | ForEach-Object { $_.Line.Trim() } + } + + # —— 8. Build QLog ———————————————————————————————————————— + - name: Build QLog + shell: cmd + run: | + set "QTKC_INC=C:\qtkeychain\include" + set "VCPKG_INC=C:\vcpkg\installed\x64-windows\include" + set "VCPKG_LIB=C:\vcpkg\installed\x64-windows\lib" + + mkdir build + cd build + qmake ..\QLog.pro -spec win32-msvc ^ + "CONFIG+=release" ^ + "HAMLIBINCLUDEPATH=C:\hamlib\include" ^ + "HAMLIBLIBPATH=C:\hamlib\lib\msvc" ^ + "HAMLIBVERSION_MAJOR=4" ^ + "HAMLIBVERSION_MINOR=7" ^ + "HAMLIBVERSION_PATCH=1" ^ + "QTKEYCHAININCLUDEPATH=%QTKC_INC%" ^ + "QTKEYCHAINLIBPATH=C:\qtkeychain\lib" ^ + "PTHREADINCLUDEPATH=%VCPKG_INC%" ^ + "PTHREADLIBPATH=%VCPKG_LIB%" ^ + "ZLIBINCLUDEPATH=%VCPKG_INC%" ^ + "ZLIBLIBPATH=%VCPKG_LIB%" ^ + "OPENSSLINCLUDEPATH=%VCPKG_INC%" ^ + "OPENSSLLIBPATH=%VCPKG_LIB%" + nmake + + # —— 9. Package with windeployqt —————————————————————————— + - name: Deploy + shell: pwsh + run: | + $deploy = "C:\qlog-deploy" + New-Item -ItemType Directory $deploy -Force | Out-Null + + $exe = Get-ChildItem build -Recurse -Filter "qlog.exe" | Select -First 1 + if (!$exe) { Write-Error "qlog.exe not found!"; exit 1 } + Copy-Item $exe.FullName $deploy\ + + # Copy qt6keychain.dll to Qt bin dir so windeployqt can resolve it + $qtBin = Join-Path $env:QT_ROOT_DIR "bin" + Copy-Item C:\qtkeychain\bin\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue + + Copy-Item C:\hamlib\bin\*.dll $deploy\ + Copy-Item C:\qtkeychain\bin\*.dll $deploy\ -ErrorAction SilentlyContinue + Copy-Item C:\qtkeychain\lib\*.dll $deploy\ -ErrorAction SilentlyContinue + + $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" + if (Test-Path $vcpkgBin) { + Copy-Item "$vcpkgBin\*.dll" $deploy\ -ErrorAction SilentlyContinue + } + + if ($env:OPENSSL_DIR -and (Test-Path $env:OPENSSL_DIR)) { + $sslBin = Join-Path $env:OPENSSL_DIR "bin" + if (Test-Path $sslBin) { + Copy-Item "$sslBin\libssl*.dll" $deploy\ -ErrorAction SilentlyContinue + Copy-Item "$sslBin\libcrypto*.dll" $deploy\ -ErrorAction SilentlyContinue + } + } + + Push-Location $deploy + windeployqt --release --no-translations qlog.exe + Pop-Location + + Write-Host "Deploy contents:" + Get-ChildItem $deploy | Format-Table Name, Length + + # —— 10. Create Qt IFW installer —————————————————————————— + - name: Create Installer + shell: pwsh + run: | + $bc = $null + if ($env:IQTA_TOOLS) { + $bc = Get-ChildItem $env:IQTA_TOOLS -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + $bc = Get-ChildItem "$env:RUNNER_TOOL_CACHE" -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1 + } + if (!$bc) { + Write-Warning "binarycreator not found — skipping installer" + exit 0 + } + + Copy-Item installer -Destination installer-build -Recurse + $pkgData = "installer-build\packages\de.dl2ic.qlog\data" + New-Item -ItemType Directory $pkgData -Force | Out-Null + Copy-Item C:\qlog-deploy\* $pkgData\ -Recurse + + & $bc.FullName -f ` + -c installer-build\config\config.xml ` + -p installer-build\packages ` + qlog-installer.exe + + if (Test-Path qlog-installer.exe) { + Write-Host "Installer created: qlog-installer.exe" + } + + # —— 11. Create portable ZIP —————————————————————————————— + - name: Create Portable ZIP + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + Compress-Archive -Path C:\qlog-deploy\* -DestinationPath "QLog-Portable-Windows-${tag}.zip" + Write-Host "Portable ZIP: QLog-Portable-Windows-${tag}.zip" + + # —— 12. Upload artifacts (always — for testing) —————————— + - name: Upload Installer + if: ${{ hashFiles('qlog-installer.exe') != '' }} + uses: actions/upload-artifact@v4 + with: + name: QLog-Installer-Windows + path: qlog-installer.exe + + - name: Upload Portable + uses: actions/upload-artifact@v4 + with: + name: QLog-Portable-Windows + path: C:\qlog-deploy\ + + # —— 13. Create GitHub Release (only on tag push) ————————— + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: "QLog ${{ github.ref_name }}" + body: | + ## QLog ${{ github.ref_name }} + + **Downloads:** + - **Installer** (recommended) — run `qlog-installer.exe` + - **Portable ZIP** — extract anywhere and run `qlog.exe` + + Built with Qt ${{ env.QT_VERSION }}, Hamlib ${{ env.HAMLIB_VERSION }}, MSVC 2022 x64. + draft: false + prerelease: false + files: | + qlog-installer.exe + QLog-Portable-Windows-${{ github.ref_name }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/QLog.pro b/QLog.pro index 508cdc1f..6eb55a04 100644 --- a/QLog.pro +++ b/QLog.pro @@ -180,6 +180,7 @@ SOURCES += \ ui/ColumnSettingDialog.cpp \ ui/DevToolsDialog.cpp \ ui/DownloadQSLDialog.cpp \ + ui/DuplicateContactsDialog.cpp \ ui/DxFilterDialog.cpp \ ui/DxWidget.cpp \ ui/DxccTableWidget.cpp \ @@ -374,6 +375,7 @@ HEADERS += \ ui/ColumnSettingDialog.h \ ui/DevToolsDialog.h \ ui/DownloadQSLDialog.h \ + ui/DuplicateContactsDialog.h \ ui/DxFilterDialog.h \ ui/DxWidget.h \ ui/DxccTableWidget.h \ @@ -445,6 +447,7 @@ FORMS += \ ui/ColumnSettingSimpleDialog.ui \ ui/DevToolsDialog.ui \ ui/DownloadQSLDialog.ui \ + ui/DuplicateContactsDialog.ui \ ui/DxFilterDialog.ui \ ui/DxWidget.ui \ ui/EditActivitiesDialog.ui \ diff --git a/ui/DuplicateContactsDialog.cpp b/ui/DuplicateContactsDialog.cpp new file mode 100644 index 00000000..30018836 --- /dev/null +++ b/ui/DuplicateContactsDialog.cpp @@ -0,0 +1,679 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "ui/DuplicateContactsDialog.h" +#include "ui_DuplicateContactsDialog.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.ui.duplicatecontactsdialog"); + +// Column indices +static const int COL_DELETE = 0; +static const int COL_ID = 1; +static const int COL_DATETIME = 2; +static const int COL_CALLSIGN = 3; +static const int COL_MODE = 4; +static const int COL_MODEGROUP = 5; +static const int COL_BAND = 6; +static const int COL_FREQ = 7; +static const int COL_QSL = 8; +static const int COL_LOTW = 9; +static const int COL_EQSL = 10; +static const int COL_MYCALL = 11; +static const int COL_COUNT = 12; + +// data[] layout per contact (11 values from query) +static const int D_ID = 0; +static const int D_TIME = 1; +static const int D_CALL = 2; +static const int D_MODE = 3; +static const int D_BAND = 4; +static const int D_FREQ = 5; +static const int D_MYCALL = 6; +static const int D_QSL = 7; +static const int D_LOTW = 8; +static const int D_EQSL = 9; +static const int D_MODEGROUP = 10; + +// Group background colors are resolved at runtime from the widget palette so +// they automatically follow the active theme (native / light / dark). + +DuplicateContactsDialog::DuplicateContactsDialog(QWidget *parent) : + QDialog(parent), + ui(new Ui::DuplicateContactsDialog) +{ + FCT_IDENTIFICATION; + + ui->setupUi(this); + + ui->tableWidget->setColumnCount(COL_COUNT); + ui->tableWidget->setHorizontalHeaderLabels({ + tr("Delete"), tr("ID"), tr("Date/Time (UTC)"), tr("Callsign"), + tr("Mode"), tr("Mode Group"), tr("Band"), tr("Freq (MHz)"), + tr("QSL Rcvd"), tr("LoTW Rcvd"), tr("eQSL Rcvd"), tr("My Callsign") + }); + ui->tableWidget->horizontalHeader()->setStretchLastSection(true); + ui->tableWidget->verticalHeader()->hide(); + ui->tableWidget->setShowGrid(true); + + connect(ui->autoSelectButton, &QPushButton::clicked, + this, &DuplicateContactsDialog::autoSelectDuplicates); + connect(ui->unselectAllButton, &QPushButton::clicked, + this, &DuplicateContactsDialog::unselectAll); + connect(ui->mergeButton, &QPushButton::clicked, + this, &DuplicateContactsDialog::mergeGroups); + connect(ui->removeButton, &QPushButton::clicked, + this, &DuplicateContactsDialog::removeChecked); + connect(ui->tableWidget, &QTableWidget::itemChanged, + this, &DuplicateContactsDialog::onItemChanged); + + findDuplicates(); +} + +DuplicateContactsDialog::~DuplicateContactsDialog() +{ + FCT_IDENTIFICATION; + + delete ui; +} + +void DuplicateContactsDialog::findDuplicates() +{ + FCT_IDENTIFICATION; + + ui->tableWidget->blockSignals(true); + ui->tableWidget->setRowCount(0); + ui->tableWidget->blockSignals(false); + + ui->statusLabel->setText(tr("Searching for duplicates...")); + updateRemoveButton(); + + /* + * Duplicate criteria (mirrors ADIF import logic, but with DXCC mode groups): + * - Same callsign + * - Same band (case-insensitive) + * - Same satellite name (or both empty) + * - Start times within 30 minutes + * - Same DXCC mode group: CW / PHONE / DIGITAL + * DATA-U and DATA-L are treated as DIGITAL even if absent from the + * modes table. All other unknown modes fall back to their literal name. + */ + QSqlQuery query; + if (!query.prepare( + "SELECT " + " c1.id, c1.start_time, c1.callsign, c1.mode, c1.band, c1.freq, " + " c1.station_callsign, c1.qsl_rcvd, c1.lotw_qsl_rcvd, c1.eqsl_qsl_rcvd, " + " COALESCE(CASE WHEN c1.mode IN ('DATA-U','DATA-L','DATA') THEN 'DIGITAL' ELSE NULL END, mg1.dxcc, c1.mode), " + " c2.id, c2.start_time, c2.callsign, c2.mode, c2.band, c2.freq, " + " c2.station_callsign, c2.qsl_rcvd, c2.lotw_qsl_rcvd, c2.eqsl_qsl_rcvd, " + " COALESCE(CASE WHEN c2.mode IN ('DATA-U','DATA-L','DATA') THEN 'DIGITAL' ELSE NULL END, mg2.dxcc, c2.mode) " + "FROM contacts c1 " + "JOIN contacts c2 ON c1.callsign = c2.callsign AND c1.id < c2.id " + "LEFT JOIN modes mg1 ON mg1.name = c1.mode " + "LEFT JOIN modes mg2 ON mg2.name = c2.mode " + "WHERE upper(c1.band) = upper(c2.band) " + "AND COALESCE(c1.sat_name, '') = COALESCE(c2.sat_name, '') " + "AND ABS(JULIANDAY(c1.start_time) - JULIANDAY(c2.start_time))*24*60 < 30 " + "AND COALESCE(CASE WHEN c1.mode IN ('DATA-U','DATA-L','DATA') THEN 'DIGITAL' ELSE NULL END, mg1.dxcc, c1.mode) " + " = COALESCE(CASE WHEN c2.mode IN ('DATA-U','DATA-L','DATA') THEN 'DIGITAL' ELSE NULL END, mg2.dxcc, c2.mode) " + "ORDER BY c1.start_time DESC")) + { + qWarning() << "Cannot prepare duplicate query:" << query.lastError(); + ui->statusLabel->setText(tr("Error: could not prepare search query.")); + return; + } + + if (!query.exec()) + { + qWarning() << "Cannot execute duplicate query:" << query.lastError(); + ui->statusLabel->setText(tr("Error: could not execute search query.")); + return; + } + + /* + * Collect all pairs and contact data, then use union-find to group contacts + * that are all duplicates of each other. This means three contacts A, B, C + * that are mutually duplicate appear as one group of three rows rather than + * three separate pairs. + */ + QMap> contactData; // id -> 11-element field list + QList> pairs; + + while (query.next()) + { + const int id1 = query.value(0).toInt(); + const int id2 = query.value(11).toInt(); + + if (!contactData.contains(id1)) + { + contactData[id1] = { + query.value(0), query.value(1), query.value(2), + query.value(3), query.value(4), query.value(5), + query.value(6), query.value(7), query.value(8), + query.value(9), query.value(10) + }; + } + if (!contactData.contains(id2)) + { + contactData[id2] = { + query.value(11), query.value(12), query.value(13), + query.value(14), query.value(15), query.value(16), + query.value(17), query.value(18), query.value(19), + query.value(20), query.value(21) + }; + } + pairs.append({id1, id2}); + } + + // Union-Find: path-compressed + QMap parent; + for (int id : contactData.keys()) + parent[id] = id; + + std::function findRoot = [&](int x) -> int + { + if (parent[x] != x) parent[x] = findRoot(parent[x]); + return parent[x]; + }; + + for (const QPair &p : pairs) + { + const int rx = findRoot(p.first); + const int ry = findRoot(p.second); + if (rx != ry) parent[rx] = ry; + } + + // Build groups: root -> sorted list of IDs (ascending = lowest ID first) + QMap> groups; + for (int id : contactData.keys()) + groups[findRoot(id)].append(id); + + for (QList &g : groups) + std::sort(g.begin(), g.end()); + + // Persist groups for use by mergeGroups() + duplicateGroups.clear(); + for (const QList &g : groups) + duplicateGroups.append(g); + + // Populate table + groupFirstRows.clear(); + int rowIndex = 0; + int groupColorIdx = 0; + + // Use the same two palette roles Qt uses for alternatingRowColors so the + // group shading follows the active theme (native / light / dark) automatically. + const QColor colorBase = ui->tableWidget->palette().color(QPalette::Base); + const QColor colorAlt = ui->tableWidget->palette().color(QPalette::AlternateBase); + + ui->tableWidget->blockSignals(true); + + for (const QList &group : groups) + { + const QColor &bgColor = (groupColorIdx % 2 == 0) ? colorBase : colorAlt; + groupFirstRows.append(rowIndex); + bool isFirst = true; + for (int id : group) + { + ui->tableWidget->insertRow(rowIndex); + addRow(rowIndex, contactData.value(id), bgColor, !isFirst); + rowIndex++; + isFirst = false; + } + groupColorIdx++; + } + + ui->tableWidget->blockSignals(false); + + const int groupCount = groups.size(); + const int contactCount = contactData.size(); + + if (groupCount == 0) + { + ui->statusLabel->setText(tr("No duplicate contacts found.")); + } + else + { + ui->statusLabel->setText( + tr("Found %1 duplicate group(s) involving %2 contact(s). " + "All but the first in each group are pre-selected for removal.") + .arg(groupCount).arg(contactCount)); + } + + ui->mergeButton->setEnabled(groupCount > 0); + ui->tableWidget->resizeColumnsToContents(); + updateRemoveButton(); +} + +void DuplicateContactsDialog::addRow(int row, const QList &data, + const QColor &bgColor, bool checked) +{ + FCT_IDENTIFICATION; + + auto makeItem = [&](const QString &text, int align = Qt::AlignLeft | Qt::AlignVCenter) + { + QTableWidgetItem *item = new QTableWidgetItem(text); + item->setBackground(bgColor); + item->setTextAlignment(align); + return item; + }; + + // Col 0: Delete checkbox — ID stored in UserRole + QTableWidgetItem *checkItem = new QTableWidgetItem(); + checkItem->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + checkItem->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + checkItem->setData(Qt::UserRole, data[D_ID].toInt()); + checkItem->setBackground(bgColor); + checkItem->setTextAlignment(Qt::AlignCenter); + ui->tableWidget->setItem(row, COL_DELETE, checkItem); + + ui->tableWidget->setItem(row, COL_ID, + makeItem(data[D_ID].toString(), Qt::AlignRight | Qt::AlignVCenter)); + ui->tableWidget->setItem(row, COL_DATETIME, + makeItem(data[D_TIME].toString())); + ui->tableWidget->setItem(row, COL_CALLSIGN, + makeItem(data[D_CALL].toString())); + ui->tableWidget->setItem(row, COL_MODE, + makeItem(data[D_MODE].toString())); + ui->tableWidget->setItem(row, COL_MODEGROUP, + makeItem(data[D_MODEGROUP].toString())); + ui->tableWidget->setItem(row, COL_BAND, + makeItem(data[D_BAND].toString())); + ui->tableWidget->setItem(row, COL_FREQ, + makeItem(data[D_FREQ].toString(), Qt::AlignRight | Qt::AlignVCenter)); + ui->tableWidget->setItem(row, COL_QSL, + makeItem(data[D_QSL].toString(), Qt::AlignCenter)); + ui->tableWidget->setItem(row, COL_LOTW, + makeItem(data[D_LOTW].toString(), Qt::AlignCenter)); + ui->tableWidget->setItem(row, COL_EQSL, + makeItem(data[D_EQSL].toString(), Qt::AlignCenter)); + ui->tableWidget->setItem(row, COL_MYCALL, + makeItem(data[D_MYCALL].toString())); +} + +static const QStringList QSL_STATUS_PRIORITY = {"Y", "R", "I", "Q", "N"}; + +static bool qslStatusBetter(const QString &candidate, const QString ¤t) +{ + int ic = QSL_STATUS_PRIORITY.indexOf(candidate.toUpper()); + int ii = QSL_STATUS_PRIORITY.indexOf(current.toUpper()); + if (ic < 0) return false; // unknown candidate — don't replace + if (ii < 0) return true; // unknown current — take candidate + return ic < ii; // lower index = higher priority +} + +static bool freqMorePrecise(const QVariant &candidate, const QVariant ¤t) +{ + const double candVal = candidate.toDouble(); + if (candVal == 0.0 || candidate.isNull() || candidate.toString().isEmpty()) return false; + const double currVal = current.toDouble(); + if (currVal == 0.0 || current.isNull() || current.toString().isEmpty()) return true; + auto decimals = [](const QString &s) -> int + { + const int dot = s.indexOf('.'); + return dot >= 0 ? s.length() - dot - 1 : 0; + }; + return decimals(candidate.toString()) > decimals(current.toString()); +} + +static bool dateLater(const QString &candidate, const QString ¤t) +{ + if (candidate.isEmpty()) return false; + if (current.isEmpty()) return true; + return candidate > current; // ISO / ADIF date strings sort lexicographically +} + +static bool isSkipField(const QString &name) +{ + static const QSet SKIP = { + "id", "start_time", "end_time", "callsign", "band", "sat_name", + "station_callsign", + // My-station fields + "my_dxcc", "my_country", "my_country_intl", "my_gridsquare", + "my_name", "my_name_intl", "my_city", "my_city_intl", + "my_iota", "my_pota_ref", "my_sota_ref", + "my_sig", "my_sig_intl", "my_sig_info", "my_sig_info_intl", + "my_vucc_grids", "my_itu_zone", "my_cq_zone", "my_cnty", "my_darc_dok", + "operator", + // Handled explicitly below + "mode", "submode", "freq", "freq_rx", + "qsl_rcvd", "qsl_sent", + "lotw_qsl_rcvd", "lotw_qsl_sent", + "eqsl_qsl_rcvd", "eqsl_qsl_sent", + "qsl_rdate", "qsl_sdate", + "lotw_qslrdate", "lotw_qslsdate", + "eqsl_qslrdate", "eqsl_qslsdate", + // JSON blob — too complex to merge + "fields" + }; + return SKIP.contains(name); +} + +bool DuplicateContactsDialog::mergeGroupById(const QList &group, int &removedOut) +{ + FCT_IDENTIFICATION; + + removedOut = 0; + if (group.size() < 2) return true; + + QStringList idStrs; + for (int id : group) idStrs << QString::number(id); + + QSqlQuery sel; + if (!sel.exec("SELECT * FROM contacts WHERE id IN (" + idStrs.join(",") + ") ORDER BY id ASC")) + { + qWarning() << "mergeGroupById: SELECT failed:" << sel.lastError(); + return false; + } + + QList recs; + while (sel.next()) + recs.append(sel.record()); + + if (recs.size() < 2) + return true; // nothing to do (maybe already deleted) + + const QSqlRecord &base = recs[0]; + const int baseId = base.value("id").toInt(); + + QVariantMap updates; + + for (int di = 1; di < recs.size(); di++) + { + const QSqlRecord &dup = recs[di]; + + for (const QString &f : {"qsl_rcvd", "lotw_qsl_rcvd", "eqsl_qsl_rcvd"}) + { + const QString cur = updates.contains(f) ? updates[f].toString() + : base.value(f).toString(); + const QString cand = dup.value(f).toString(); + if (qslStatusBetter(cand, cur)) + updates[f] = cand; + } + + for (const QString &f : {"qsl_sent", "lotw_qsl_sent", "eqsl_qsl_sent"}) + { + const QString cur = updates.contains(f) ? updates[f].toString() + : base.value(f).toString(); + const QString cand = dup.value(f).toString(); + if (qslStatusBetter(cand, cur)) + updates[f] = cand; + } + + for (const QString &f : {"qsl_rdate", "qsl_sdate", + "lotw_qslrdate", "lotw_qslsdate", + "eqsl_qslrdate", "eqsl_qslsdate"}) + { + const QString cur = updates.contains(f) ? updates[f].toString() + : base.value(f).toString(); + const QString cand = dup.value(f).toString(); + if (dateLater(cand, cur)) + updates[f] = cand; + } + + for (const QString &f : {"freq", "freq_rx"}) + { + const QVariant cur = updates.contains(f) ? updates[f] + : base.value(f); + const QVariant cand = dup.value(f); + if (freqMorePrecise(cand, cur)) + updates[f] = cand; + } + + { + const QString baseMode = base.value("mode").toString(); + const QString dupMode = dup.value("mode").toString(); + const QString baseSub = updates.contains("submode") ? updates["submode"].toString() + : base.value("submode").toString(); + const QString dupSub = dup.value("submode").toString(); + if (baseMode == dupMode && baseSub.isEmpty() && !dupSub.isEmpty()) + updates["submode"] = dupSub; + } + + for (int col = 0; col < dup.count(); col++) + { + const QString fname = dup.fieldName(col); + if (isSkipField(fname)) continue; + + const QVariant cur = updates.contains(fname) ? updates[fname] + : base.value(fname); + const QVariant cand = dup.value(fname); + if ((cur.isNull() || cur.toString().isEmpty()) && + !cand.isNull() && !cand.toString().isEmpty()) + { + updates[fname] = cand; + } + } + } + + if (!updates.isEmpty()) + { + QStringList setClauses; + QVariantList bindValues; + for (auto it = updates.constBegin(); it != updates.constEnd(); ++it) + { + setClauses << QString("\"%1\"=?").arg(it.key()); + bindValues << it.value(); + } + + QSqlQuery upd; + if (!upd.prepare(QString("UPDATE contacts SET %1 WHERE id=?") + .arg(setClauses.join(", ")))) + { + qWarning() << "mergeGroupById: UPDATE prepare failed:" << upd.lastError(); + return false; + } + for (const QVariant &v : bindValues) + upd.addBindValue(v); + upd.addBindValue(baseId); + + if (!upd.exec()) + { + qWarning() << "mergeGroupById: UPDATE exec failed:" << upd.lastError(); + return false; + } + } + + // Delete the duplicates (all but the base) + QStringList dupIds; + for (int i = 1; i < recs.size(); i++) + dupIds << QString::number(recs[i].value("id").toInt()); + + QSqlQuery del; + if (!del.exec("DELETE FROM contacts WHERE id IN (" + dupIds.join(",") + ")")) + { + qWarning() << "mergeGroupById: DELETE failed:" << del.lastError(); + return false; + } + + removedOut = del.numRowsAffected(); + return true; +} + +void DuplicateContactsDialog::mergeGroups() +{ + FCT_IDENTIFICATION; + + if (duplicateGroups.isEmpty()) + { + QMessageBox::information(this, tr("Nothing to Merge"), + tr("No duplicate groups are currently shown.")); + return; + } + + int totalRemoved = 0; + for (const QList &g : duplicateGroups) + totalRemoved += g.size() - 1; + + const QMessageBox::StandardButton reply = QMessageBox::question( + this, + tr("Confirm Merge"), + tr("Merge %1 duplicate group(s) (%2 contact(s) will be removed)?\n\n" + "The earliest contact in each group (lowest ID) is kept.\n" + "Fields are merged using these rules:\n" + " • QSL status: best value wins (Y \u003e R \u003e I \u003e Q \u003e N)\n" + " • QSL dates: fill blank; prefer latest when both present\n" + " • Frequency: most-precise (most decimal digits) wins\n" + " • Submode: copied from duplicate only when modes match exactly\n" + " • Other fields: copied from duplicate only when kept contact is blank\n\n" + "This cannot be undone.") + .arg(duplicateGroups.size()).arg(totalRemoved), + QMessageBox::Yes | QMessageBox::No + ); + + if (reply != QMessageBox::Yes) return; + + QSqlDatabase::database().transaction(); + + int mergedGroups = 0; + int removedContacts = 0; + bool anyFailed = false; + + for (const QList &group : duplicateGroups) + { + int removed = 0; + if (mergeGroupById(group, removed)) + { + mergedGroups++; + removedContacts += removed; + } + else + { + anyFailed = true; + break; + } + } + + if (anyFailed) + { + QSqlDatabase::database().rollback(); + QMessageBox::critical(this, tr("Error"), + tr("Merge failed. No changes were made.")); + return; + } + + QSqlDatabase::database().commit(); + QMessageBox::information(this, tr("Merge Complete"), + tr("Merged %1 group(s), removed %2 contact(s).") + .arg(mergedGroups).arg(removedContacts)); + findDuplicates(); +} + +void DuplicateContactsDialog::autoSelectDuplicates() +{ + FCT_IDENTIFICATION; + + ui->tableWidget->blockSignals(true); + + // Uncheck the first row of each group (keep the original); check all others + const QSet firstRows(groupFirstRows.begin(), groupFirstRows.end()); + for (int row = 0; row < ui->tableWidget->rowCount(); row++) + { + QTableWidgetItem *item = ui->tableWidget->item(row, COL_DELETE); + if (!item) continue; + item->setCheckState(firstRows.contains(row) ? Qt::Unchecked : Qt::Checked); + } + + ui->tableWidget->blockSignals(false); + updateRemoveButton(); +} + +void DuplicateContactsDialog::unselectAll() +{ + FCT_IDENTIFICATION; + + ui->tableWidget->blockSignals(true); + for (int row = 0; row < ui->tableWidget->rowCount(); row++) + { + QTableWidgetItem *item = ui->tableWidget->item(row, COL_DELETE); + if (item) item->setCheckState(Qt::Unchecked); + } + ui->tableWidget->blockSignals(false); + updateRemoveButton(); +} + +void DuplicateContactsDialog::removeChecked() +{ + FCT_IDENTIFICATION; + + const QSet idsToDelete = getCheckedIds(); + + if (idsToDelete.isEmpty()) + { + QMessageBox::information(this, tr("No Selection"), + tr("No contacts are checked for removal.")); + return; + } + + const QMessageBox::StandardButton reply = QMessageBox::question( + this, + tr("Confirm Removal"), + tr("Permanently remove %1 selected contact(s)? This cannot be undone.") + .arg(idsToDelete.size()), + QMessageBox::Yes | QMessageBox::No + ); + + if (reply != QMessageBox::Yes) return; + + QStringList idStrings; + for (int id : idsToDelete) + idStrings << QString::number(id); + + QSqlQuery delQuery; + if (!delQuery.exec(QString("DELETE FROM contacts WHERE id IN (%1)").arg(idStrings.join(",")))) + { + qWarning() << "Cannot delete contacts:" << delQuery.lastError(); + QMessageBox::critical(this, tr("Error"), + tr("Failed to remove contacts: %1").arg(delQuery.lastError().text())); + return; + } + + const int removed = delQuery.numRowsAffected(); + QMessageBox::information(this, tr("Done"), + tr("Removed %1 contact(s).").arg(removed)); + + findDuplicates(); +} + +void DuplicateContactsDialog::onItemChanged(QTableWidgetItem *item) +{ + FCT_IDENTIFICATION; + + if (!item || item->column() != COL_DELETE) return; + updateRemoveButton(); +} + +void DuplicateContactsDialog::updateRemoveButton() +{ + FCT_IDENTIFICATION; + + int count = 0; + for (int row = 0; row < ui->tableWidget->rowCount(); row++) + { + const QTableWidgetItem *item = ui->tableWidget->item(row, COL_DELETE); + if (item && item->checkState() == Qt::Checked) + count++; + } + + ui->removeButton->setText(tr("Remove Checked (%1)").arg(count)); + ui->removeButton->setEnabled(count > 0); +} + +QSet DuplicateContactsDialog::getCheckedIds() const +{ + FCT_IDENTIFICATION; + + QSet ids; + for (int row = 0; row < ui->tableWidget->rowCount(); row++) + { + const QTableWidgetItem *item = ui->tableWidget->item(row, COL_DELETE); + if (item && item->checkState() == Qt::Checked) + ids.insert(item->data(Qt::UserRole).toInt()); + } + return ids; +} diff --git a/ui/DuplicateContactsDialog.h b/ui/DuplicateContactsDialog.h new file mode 100644 index 00000000..498cf274 --- /dev/null +++ b/ui/DuplicateContactsDialog.h @@ -0,0 +1,45 @@ +#ifndef QLOG_UI_DUPLICATECONTACTSDIALOG_H +#define QLOG_UI_DUPLICATECONTACTSDIALOG_H + +#include +#include +#include +#include +#include +#include "core/LogLocale.h" +#include +#include + +namespace Ui { +class DuplicateContactsDialog; +} + +class DuplicateContactsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit DuplicateContactsDialog(QWidget *parent = nullptr); + ~DuplicateContactsDialog(); + +private slots: + void autoSelectDuplicates(); + void unselectAll(); + void removeChecked(); + void mergeGroups(); + void onItemChanged(QTableWidgetItem *item); + +private: + Ui::DuplicateContactsDialog *ui; + LogLocale locale; + + void findDuplicates(); + void addRow(int row, const QList &data, const QColor &bgColor, bool checked); + QSet getCheckedIds() const; + void updateRemoveButton(); + bool mergeGroupById(const QList &group, int &removedOut); + QList> duplicateGroups; + QList groupFirstRows; +}; + +#endif // QLOG_UI_DUPLICATECONTACTSDIALOG_H diff --git a/ui/DuplicateContactsDialog.ui b/ui/DuplicateContactsDialog.ui new file mode 100644 index 00000000..795e3277 --- /dev/null +++ b/ui/DuplicateContactsDialog.ui @@ -0,0 +1,168 @@ + + + DuplicateContactsDialog + + + + 0 + 0 + 950 + 600 + + + + Find, Merge & Remove Duplicate Contacts + + + + 6 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Duplicate contacts match on: Callsign, Band, Mode Group (CW / PHONE / DIGITAL), Satellite, and start time within 30 minutes. + + + true + + + + + + + Searching... + + + true + + + + + + + QAbstractItemView::SelectRows + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + + + + + + + Automatically check the second contact in each duplicate pair (the one with the higher ID) for removal. + + + Auto-Select Duplicates + + + + + + + Uncheck all rows so you can manually select which contacts to merge or remove. + + + Unselect All + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + Merge all duplicate groups into a single contact each. + +Rules per field: +• QSL status (qsl_rcvd / lotw / eqsl): best value wins — Y > R > I > Q > N +• QSL dates: fill blank, keep latest when both present +• Frequency: most decimal digits (most precise) wins +• Submode: copied from duplicate only when both share the same exact mode +• All other fields: fill blank from duplicate if the kept contact has no value + +The earliest contact (lowest ID) is always kept. +Duplicate contacts are removed after the merge. + + + Merge All Groups + + + + + + + false + + + Permanently delete all checked contacts. This cannot be undone. + + + Remove Checked (0) + + + + + + + Close + + + + + + + + + + closeButton + clicked() + DuplicateContactsDialog + close() + + + -1 + -1 + + + 475 + 300 + + + + + diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index 271bd6a0..755adf89 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -10,6 +10,7 @@ #include "ui_MainWindow.h" #include "ui/SettingsDialog.h" #include "ui/ImportDialog.h" +#include "ui/DuplicateContactsDialog.h" #include "ui/ExportDialog.h" #include "ui/DevToolsDialog.h" #include "ui/CabrilloExportDialog.h" @@ -1107,6 +1108,15 @@ void MainWindow::showQSLGallery() dialog.exec(); } +void MainWindow::showDuplicateContacts() +{ + FCT_IDENTIFICATION; + + DuplicateContactsDialog dialog(this); + dialog.exec(); + ui->logbookWidget->updateTable(); +} + void MainWindow::showDevTools() { FCT_IDENTIFICATION; diff --git a/ui/MainWindow.h b/ui/MainWindow.h index 4cfd796a..95ddabab 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -81,6 +81,7 @@ private slots: void showDumpDB(); void showLoadDB(); void showQSLGallery(); + void showDuplicateContacts(); void showDevTools(); void printQslLabels(); diff --git a/ui/MainWindow.ui b/ui/MainWindow.ui index e75c9479..4b0099f2 100644 --- a/ui/MainWindow.ui +++ b/ui/MainWindow.ui @@ -76,6 +76,8 @@ + + @@ -408,6 +410,14 @@ QAction::NoRole + + + Find && Remove Duplicates + + + Search the logbook for duplicate contacts and optionally remove them + + true @@ -1931,6 +1941,22 @@ + + actionFindDuplicates + triggered() + MainWindow + showDuplicateContacts() + + + -1 + -1 + + + 456 + 258 + + + actionDeveloperTools triggered() @@ -2013,6 +2039,7 @@ showDumpDB() showLoadDB() showQSLGallery() + showDuplicateContacts() showDevTools() printQslLabels() exportCabrillo()