feat(wavelog): wavelog export option + upload helpers (ADIF, candidates, messages)

This commit is contained in:
atsunatsu committed 2026-10-05 01:22:44 +08:00
1 parent 73ac41ff85
commit c050e7bb9b
4 files changed
+149 -2

No files matched your search

@@ -18,7 +18,7 @@ import java.util.TimeZone
object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC")
fun encode(records: List<QsoRecord>): String = buildString {
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
append(field("ADIF_VER", "3.1.7"))
append(field("PROGRAMID", "Look4Sat"))
append("<EOH>\r\n")
@@ -32,7 +32,7 @@ object AdifCodec {
append(field("TIME_OFF", format(it, "HHmmss")))
}
append(field("CALL", record.theirCallsign))
append(field("STATION_CALLSIGN", record.myCallsign))
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid)
@@ -0,0 +1,66 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
/**
* The records a Wavelog upload may submit: locally recorded contacts only.
*
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
* from this app therefore stay eligible; imported history stays out of the upload.
*/
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
}
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
fun WavelogProblem.label(): String = when (this) {
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
WavelogProblem.NO_STATION -> "no station profile selected"
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
}
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
if (preview.skipped <= 0) return ""
val detail = preview.reasons.entries.sortedByDescending { it.value }
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
}
/** One-line result wording for a finished Wavelog upload. */
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
is WavelogUploadOutcome.Imported -> buildString {
if (result.imported == 0 && result.duplicates > 0) {
append("All ${result.markIds.size} record(s) already in Wavelog")
} else {
append("Uploaded ${result.imported} QSO(s) to Wavelog")
}
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
}
is WavelogUploadOutcome.Rejected -> result.message
is WavelogUploadOutcome.Failed -> result.message
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
}
@@ -83,4 +83,20 @@ class AdifCodecTest {
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
}
@Test
fun encodeOmitsStationCallsignForWavelog() {
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
txFrequencyHz = 145_850_000L,
band = "2M",
satelliteName = "SO-50"
)
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
assertTrue(!adif.contains("<STATION_CALLSIGN"))
assertTrue(adif.contains("<CALL:6>N0CALL"))
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
}
}
@@ -0,0 +1,65 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogUploadMessagesTest {
private fun record(
complete: Boolean = true,
wavelogUploaded: Boolean = false,
lotwConfirmed: Boolean = false,
lotwUploaded: Boolean = false
) = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded
)
@Test
fun freshLocalRecordIsEligible() {
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
}
@Test
fun locallyUploadedToLotwStaysEligible() {
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
assertTrue(
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
)
}
@Test
fun importedConfirmationRowIsExcluded() {
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
}
@Test
fun draftAndAlreadyUploadedRecordsAreExcluded() {
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
}
@Test
fun problemLabelsAreOperatorFacing() {
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
}
}