using System.Globalization; using System.Text; using Nonemm.Contests; using Nonemm.Core; namespace Nonemm.Formats.Cabrillo; /// Writes a Cabrillo 3.0 log: the header the sponsor's robot reads, then one /// line per contact. public sealed class CabrilloWriter { private readonly Contest contest; private readonly StationInfo me; private readonly ContestEntry entry; public CabrilloWriter(Contest contest, StationInfo me, ContestEntry? entry = null) { this.contest = contest; this.me = me; this.entry = entry ?? new ContestEntry(); } public string Write(CabrilloHeader header, IEnumerable qsos) { StringBuilder text = new(); text.Append("START-OF-LOG: ").Append(contest.CabrilloVersion).Append("\r\n"); Line(text, "CONTEST", header.Contest); Line(text, "CALLSIGN", header.Callsign); Line(text, "CATEGORY-OPERATOR", header.OperatorCategory); Line(text, "CATEGORY-ASSISTED", header.AssistedCategory); Line(text, "CATEGORY-BAND", header.BandCategory); Line(text, "CATEGORY-MODE", header.ModeCategory); Line(text, "CATEGORY-POWER", header.PowerCategory); Line(text, "CATEGORY-STATION", header.StationCategory); Line(text, "CATEGORY-TRANSMITTER", header.TransmitterCategory); Line(text, "CATEGORY-OVERLAY", header.OverlayCategory); Line(text, "CATEGORY-TIME", header.TimeCategory); text.Append(CultureInfo.InvariantCulture, $"CLAIMED-SCORE: {header.ClaimedScore}\r\n"); Line(text, "CLUB", header.Club); Line(text, "NAME", header.Name); foreach (string line in header.Address) { Line(text, "ADDRESS", line); } Line(text, "ADDRESS-CITY", header.AddressCity); Line(text, "ADDRESS-STATE-PROVINCE", header.AddressStateProvince); Line(text, "ADDRESS-POSTALCODE", header.AddressPostalcode); Line(text, "ADDRESS-COUNTRY", header.AddressCountry); Line(text, "EMAIL", header.Email); Line(text, "OPERATORS", header.Operators); foreach (string line in SplitLines(header.Soapbox)) { Line(text, "SOAPBOX", line); } Line(text, "CREATED-BY", header.CreatedBy); foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc)) { text.Append(QsoLine(qso)).Append("\r\n"); } text.Append("END-OF-LOG:\r\n"); return text.ToString(); } public string QsoLine(Qso qso) { CabrilloExchange exchange = contest.CabrilloExchange(qso, me, entry); StringBuilder line = new(RecordName(qso)); line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' '); line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' '); line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' '); line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' '); Append(line, exchange.Sent); Append(line, exchange.Received); if (contest.IsContact(qso) && exchange.AddsTransmitter) { line.Append(qso.RadioNumber > 1 ? '1' : '0'); } return line.ToString(); } /// A contact the operator has excluded from the claimed score is still /// written, under `X-QSO:` rather than `QSO:`. That is what N1MM writes, /// and the sponsors read it as a contact the entrant is not claiming. private string RecordName(Qso qso) { if (!contest.IsContact(qso)) { return "QTC: "; } return qso.IsClaimed ? "QSO: " : "X-QSO: "; } /// Every column is the width the sponsor asks for and one space, and a /// value longer than its column is cut, which is what N1MM writes. private static void Append(StringBuilder line, IReadOnlyList fields) { foreach (CabrilloField field in fields) { string value = field.Value.Length > field.Width ? field.Value[..field.Width] : field.Value.PadRight(field.Width); line.Append(value).Append(' '); } } private static void Line(StringBuilder text, string tag, string value) { if (value.Length > 0) { text.Append(tag).Append(": ").Append(value).Append("\r\n"); } } private static IEnumerable SplitLines(string text) => text.Length == 0 ? [] : text.Split('\n').Select(l => l.TrimEnd('\r')); }