/* * * * Adif.cs * * ADIF interface. * * License: GNU General Public License Version 3.0. * * Copyright (C) 2017-2023 by Matthew K. Roberts, KK5JY. * All rights reserved. * * 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: http://www.gnu.org/licenses/ * * */ using System; using System.IO; using System.Diagnostics; using System.Collections.Generic; using System.Linq; using System.Text; namespace KK5JY.Log { /// /// ADIF record structure. /// public class AdifRecord { #region Public Properties /// /// The other call. /// public string Call { get; set; } /// /// The QSO mode string. /// public string Mode { get; set; } /// /// The QSO frequency in MHz (when split, report TX frequency). /// public string Freq { get; set; } /// /// The QSO frequency in MHz (for split RX frequency). /// public string FreqRx { get; set; } /// /// The QSO date (YYYYMMDD). /// public string DateOn { get; set; } /// /// The QSO end date (YYYYMMDD). /// public string DateOff { get; set; } /// /// The QSO start time (HHMMSS). /// public string TimeOn { get; set; } /// /// The QSO stop time (HHMMSS). /// public string TimeOff { get; set; } /// /// Received grid square. /// public string GridSquare { get; set; } /// /// Remote station latitude. /// public string Latitude { get; set; } /// /// Remote station longitude. /// public string Longitude { get; set; } /// /// Sent grid square. /// public string MyGridSquare { get; set; } /// /// Received report. /// public string RecdRST { get; set; } /// /// Sent report. /// public string SentRST { get; set; } /// /// Received exchange. /// public string RxString { get; set; } /// /// Sent exchange. /// public string TxString { get; set; } /// /// Notes. /// public string Notes { get; set; } /// /// Operator (i.e., "my call") /// public string Operator { get; set; } #endregion #region Public Methods /// /// Return TimeOn as a DateTime. /// public DateTime TimeOnToDateTime() { if (String.IsNullOrEmpty(DateOn) || String.IsNullOrEmpty(TimeOn)) { throw new NullReferenceException("DateOn and TimeOn must be non-null and non-empty"); } return new DateTime( Int32.Parse(DateOn.Substring(0, 4)), Int32.Parse(DateOn.Substring(4, 2)), Int32.Parse(DateOn.Substring(6, 2)), Int32.Parse(TimeOn.Substring(0, 2)), Int32.Parse(TimeOn.Substring(2, 2)), Int32.Parse(TimeOn.Substring(4, 2)), DateTimeKind.Utc); } /// /// Return TimeOff as a DateTime. /// public DateTime TimeOffToDateTime() { if (String.IsNullOrEmpty(DateOff) || String.IsNullOrEmpty(TimeOff)) { throw new NullReferenceException("DateOff and TimeOff must be non-null and non-empty"); } return new DateTime( Int32.Parse(DateOff.Substring(0, 4)), Int32.Parse(DateOff.Substring(4, 2)), Int32.Parse(DateOff.Substring(6, 2)), Int32.Parse(TimeOff.Substring(0, 2)), Int32.Parse(TimeOff.Substring(2, 2)), Int32.Parse(TimeOff.Substring(4, 2)), DateTimeKind.Utc); } /// /// Set the time-on, time-off, and date properties to the specified DateTime. /// public void SetQsoTime(DateTime timeOff, DateTime ? timeOn = null) { // if time-on is null, use time-off if (timeOn == null) timeOn = timeOff; // use Z time if (timeOff.Kind == DateTimeKind.Local) timeOff = timeOff.ToUniversalTime(); if (timeOn.Value.Kind == DateTimeKind.Local) timeOn = timeOn.Value.ToUniversalTime(); DateOff = String.Format("{0:0000}{1:00}{2:00}", timeOff.Year, timeOff.Month, timeOff.Day ); TimeOff = String.Format("{0:00}{1:00}{2:00}", timeOff.Hour, timeOff.Minute, timeOff.Second ); DateOn = String.Format("{0:0000}{1:00}{2:00}", timeOn.Value.Year, timeOn.Value.Month, timeOn.Value.Day ); TimeOn = String.Format("{0:00}{1:00}{2:00}", timeOn.Value.Hour, timeOn.Value.Minute, timeOn.Value.Second ); } /// /// Parsing states. /// private enum ParseStates { Searching, Tagging, Measuring, Reading } /// /// Assign an ADIF KVP. /// private void Assign(string tagName, string newValue) { // DEBUG Debug.WriteLine(String.Format("DEBUG: AdifRecord.Assign ({0}, {1})", tagName, newValue)); // write the value to the proper field switch (tagName) { case "CALL": { Call = newValue; } break; case "FREQ": { Freq = newValue; } break; case "FREQ_RX": { FreqRx = newValue; } break; case "MODE": { Mode = newValue; } break; case "QSO_DATE": { DateOn = newValue; } break; case "QSO_DATE_OFF": { DateOff = newValue; } break; case "TIME_ON": { TimeOn = newValue; } break; case "TIME_OFF": { TimeOff = newValue; } break; case "GRIDSQUARE": { GridSquare = newValue; } break; case "MY_GRIDSQUARE": { MyGridSquare = newValue; } break; case "LAT": { Latitude = newValue; } break; case "LON": { Longitude = newValue; } break; case "NOTES": { Notes = newValue; } break; case "RST_RECD": { RecdRST = newValue; } break; case "RST_SENT": { SentRST = newValue; } break; case "SRX_STRING": { RxString = newValue; } break; case "STX_STRING": { TxString = newValue; } break; case "OPERATOR": { Operator = newValue; } break; } } /// /// Return this object as an ADIF string. /// public override string ToString() { StringWriter sw = new StringWriter(); if (!String.IsNullOrEmpty(Call)) sw.Write("{1}", Call.Length, Call); if (!String.IsNullOrEmpty(Freq)) sw.Write("{1}", Freq.Length, Freq); if (!String.IsNullOrEmpty(FreqRx)) sw.Write("{1}", FreqRx.Length, FreqRx); if (!String.IsNullOrEmpty(Mode)) sw.Write("{1}", Mode.Length, Mode); if (!String.IsNullOrEmpty(DateOn)) sw.Write("{1}", DateOn.Length, DateOn); if (!String.IsNullOrEmpty(DateOff)) sw.Write("{1}", DateOff.Length, DateOff); if (!String.IsNullOrEmpty(TimeOn)) sw.Write("{1}", TimeOn.Length, TimeOn); if (!String.IsNullOrEmpty(TimeOff)) sw.Write("{1}", TimeOff.Length, TimeOff); if (!String.IsNullOrEmpty(RecdRST)) sw.Write("{1}", RecdRST.Length, RecdRST); if (!String.IsNullOrEmpty(SentRST)) sw.Write("{1}", SentRST.Length, SentRST); if (!String.IsNullOrEmpty(RxString)) sw.Write("{1}", RxString.Length, RxString); if (!String.IsNullOrEmpty(TxString)) sw.Write("{1}", TxString.Length, TxString); int band = GetBand(); if (band != 0) { string s = band.ToString(); sw.Write("{1}m", s.Length + 1, s); } if (!String.IsNullOrEmpty(GridSquare)) sw.Write("{1}", GridSquare.Length, GridSquare); if (!String.IsNullOrEmpty(MyGridSquare)) sw.Write("{1}", MyGridSquare.Length, MyGridSquare); if (!String.IsNullOrEmpty(Latitude)) sw.Write("{1}", Latitude.Length, Latitude); if (!String.IsNullOrEmpty(Longitude)) sw.Write("{1}", Longitude.Length, Longitude); if (!String.IsNullOrEmpty(Operator)) sw.Write("{1}", Operator.Length, Operator); if (!String.IsNullOrEmpty(Notes)) sw.Write("{1}", Notes.Length, Notes); sw.Write(""); return sw.ToString(); } /// /// Operator == /// public static bool operator==(AdifRecord one, AdifRecord other) { if (ReferenceEquals(one, null)) return (ReferenceEquals(other, null)); return one.Equals(other); } /// /// Operator != /// public static bool operator !=(AdifRecord one, AdifRecord other) { if (ReferenceEquals(one, null)) return (!ReferenceEquals(other, null)); return !one.Equals(other); } /// /// Equality testing. /// public override bool Equals(object obj) { var adif = obj as AdifRecord; if (ReferenceEquals(adif, null)) return false; return String.Equals(Call, adif.Call, StringComparison.InvariantCultureIgnoreCase) && String.Equals(Mode, adif.Mode, StringComparison.InvariantCultureIgnoreCase) && String.Equals(Freq, adif.Freq, StringComparison.InvariantCultureIgnoreCase) && String.Equals(FreqRx, adif.FreqRx, StringComparison.InvariantCultureIgnoreCase) && String.Equals(GridSquare, adif.GridSquare, StringComparison.InvariantCultureIgnoreCase) && String.Equals(Latitude, adif.Latitude, StringComparison.InvariantCultureIgnoreCase) && String.Equals(Longitude, adif.Longitude, StringComparison.InvariantCultureIgnoreCase) && String.Equals(DateOn, adif.DateOn, StringComparison.InvariantCultureIgnoreCase) && String.Equals(DateOff, adif.DateOff, StringComparison.InvariantCultureIgnoreCase) && String.Equals(TimeOn, adif.TimeOn, StringComparison.InvariantCultureIgnoreCase) && String.Equals(TimeOff, adif.TimeOff, StringComparison.InvariantCultureIgnoreCase) && String.Equals(RecdRST, adif.RecdRST, StringComparison.InvariantCultureIgnoreCase) && String.Equals(SentRST, adif.SentRST, StringComparison.InvariantCultureIgnoreCase) && String.Equals(RxString, adif.RxString, StringComparison.InvariantCultureIgnoreCase) && String.Equals(TxString, adif.TxString, StringComparison.InvariantCultureIgnoreCase) && String.Equals(Operator, adif.Operator, StringComparison.InvariantCultureIgnoreCase); } /// /// Return the hash code of the ADIF string. /// public override int GetHashCode() { return ToString().GetHashCode(); } /// /// Parse an ADIF string. /// public static AdifRecord Parse(string s) { if (String.IsNullOrEmpty(s)) return null; AdifRecord result = new AdifRecord(); ParseStates state = ParseStates.Searching; string working = null; string tagName = null; int length = 0; for (int i = 0; i != s.Length; ++i) { char ch = s[i]; switch (state) { case ParseStates.Searching: { if (ch == '<') { state = ParseStates.Tagging; working = ""; tagName = ""; length = 0; } } break; case ParseStates.Tagging: { if (ch == ':') { state = ParseStates.Measuring; tagName = working; working = ""; } else { working += ch; } } break; case ParseStates.Measuring: { if (ch == '>') { state = ParseStates.Reading; if (!Int32.TryParse(working, out length)) { length = -1; } working = ""; } else { working += ch; } } break; case ParseStates.Reading: { if (ch == '<') { if (working.Length != length) { throw new ArgumentException(String.Format("Tag length does not match data: {0}: {1} != {2}", tagName, length, working.Length)); } if (!String.IsNullOrEmpty(working)) { result.Assign(tagName, working); } state = ParseStates.Tagging; working = ""; tagName = ""; length = 0; } else { working += ch; } } break; } } if (!String.IsNullOrEmpty(tagName)) { if (working.Length != length) { throw new ArgumentException(String.Format("Tag length does not match data: {0}: {1} != {2}", tagName, length, working.Length)); } result.Assign(tagName, working); } return result; } /// /// Read a single record from a file. /// public static AdifRecord ReadFromFile(FileStream file) { int b; int state = 0; string key = ""; AdifRecord result = new AdifRecord(); while ((b = file.ReadByte()) != -1) { char ch = Convert.ToChar(b); switch (state) { case 0: { if (ch == '<') { state = 1; } } break; case 1: { if (ch == '>') { var parts = key.Split(':'); if (String.Equals(key, "eor", StringComparison.InvariantCultureIgnoreCase)) return result; if (parts.Length < 2) return null; key = parts[0]; int length = Int32.Parse(parts[1]); string value = ""; for (int i = 0; i != length; ++i) { value += (char)(file.ReadByte()); } switch (key.ToUpper()) { case "CALL": { result.Call = value; } break; case "MODE": { result.Mode = value; } break; case "FREQ": { result.Freq = value; } break; case "FREQ_RX": { result.FreqRx = value; } break; case "QSO_DATE": { result.DateOn = value; } break; case "QSO_DATE_OFF": { result.DateOff = value; } break; case "TIME_ON": { result.TimeOn = value; } break; case "TIME_OFF": { result.TimeOff = value; } break; case "RST_RECD": { result.RecdRST = value; } break; case "RST_SENT": { result.SentRST = value; } break; case "SRX_STRING": { result.RxString = value; } break; case "STX_STRING": { result.TxString = value; } break; case "GRIDSQUARE": { result.GridSquare = value; } break; case "MY_GRIDSQUARE": { result.MyGridSquare = value; } break; case "LAT": { result.Latitude = value; } break; case "LON": { result.Longitude = value; } break; case "OPERATOR": { result.Operator = value; } break; case "NOTES": { result.Notes = value; } break; case "BAND": { // nop } break; } state = 0; key = ""; break; } key += ch; } break; } } return null; } /// /// Convert the frequency field into a band. /// public int GetBand() { decimal freq = 0; if (Decimal.TryParse(Freq, out freq)) { return GetBand(freq); } return 0; } /// /// Convert a frequency (in MHz) into a band (in m). /// public static int GetBand(decimal freq) { switch ((int)(freq)) { case 1: return 160; case 3: return 80; case 5: return 60; case 7: return 40; case 10: return 30; case 14: return 20; case 18: return 17; case 21: return 15; case 24: return 12; case 28: case 29: return 10; case 50: case 51: case 52: case 53: return 6; case 144: case 145: case 146: case 147: return 2; } return 0; } #endregion } /// /// ADIF header block. /// public class AdifHeader { #region Public Properties /// /// Lead-in text. /// public string LeadIn { get; set; } /// /// Header fields. /// public Dictionary Fields { get; private set; } #endregion #region Constructors public AdifHeader() { Clear(); } #endregion #region Public Methods /// /// Clear all fields. /// public void Clear() { LeadIn = ""; Fields = new Dictionary(StringComparer.InvariantCultureIgnoreCase); } /// /// Read header from file. /// public void ReadFromFile(FileStream f) { if (f.CanSeek) { f.Seek(0, SeekOrigin.Begin); } int b = 0; int state = 0; string key = ""; while ((b = f.ReadByte()) != -1) { char ch = (char)(b); switch (state) { // looking for an opening bracket, recording lead-in text case 0: { if (ch == '<') { state = 2; break; } LeadIn += ch; } break; // looking for the next opening bracket case 1: { if (ch == '<') { state = 2; } } break; // recording the tag case 2: { // if the tag is closed, process the tag and its value if (ch == '>') { var parts = key.Split(':'); if (String.Equals(key, "eoh", StringComparison.InvariantCultureIgnoreCase)) return; key = parts[0]; int length = Int32.Parse(parts[1]); string value = ""; for (int i = 0; i != length; ++i) { value += (char)(f.ReadByte()); } Fields[key] = value; state = 1; key = ""; break; } key += ch; } break; } } } /// /// Write the record to a file. /// public void WriteToFile(FileStream f) { using (var sw = new StreamWriter(f, Encoding.ASCII)) { if (String.IsNullOrEmpty(LeadIn)) { sw.WriteLine("KK5JY ADIF for C#"); } else { sw.Write(LeadIn); } // write string adif_version = "3.0.5"; if (Fields.ContainsKey("adif_ver")) { adif_version = Fields["adif_ver"]; } sw.WriteLine("<{0}:{1}>{2}", "adif_ver", adif_version.Length, adif_version); // write all the others foreach (var kvp in Fields) { if (kvp.Key.Equals("adif_ver", StringComparison.InvariantCultureIgnoreCase)) { continue; } sw.WriteLine("<{0}:{1}>{2}", kvp.Key, kvp.Value.Length, kvp.Value); } sw.WriteLine(""); } } #endregion } /// /// Basic interface to ADIF file. /// public interface IAdifFile { /// /// Read the ADIF header. /// AdifHeader ReadHeader(); /// /// Add a single record. /// void AddRecord(AdifRecord rec); /// /// Fetch all records in the file. /// IEnumerable GetAllRecords(); /// /// Fetch records from the file matching the search criteria. /// IEnumerable GetRecords(string call = null, int? bandInMeters = null, string mode = null); /// /// Test the log for a match to the provided search terms. /// bool GetMatch(string call = null, int? bandInMeters = null, string mode = null); /// /// Delete first matching record from ADIF. /// bool DeleteRecord(AdifRecord needle); } /// /// ADIF file. /// public class AdifFile : IAdifFile { #region Public Properties // the read cache (optional) private LinkedList m_ReadCache; private object m_ReadLock; private long m_CacheSize; #endregion #region Public Properties /// /// Path to the ADIF file. /// public string Path { get; private set; } /// /// Return the number of records in the file. /// public int Count { get { try { // if cache enabled... if (ReadCacheEnabled) { lock (m_ReadLock) { // invalidate the cache if the file size has changed var fi = new FileInfo(Path); if (fi.Length != m_CacheSize) { m_ReadCache = null; } // use the cached value if available if (! ReferenceEquals(m_ReadCache, null)) { return m_ReadCache.Count; } } } // as a last resort, read the file and count entries return GetAllRecords().Count(); } catch { return -1; } } } /// /// Return true iff read caching was enabled at construction. /// public bool ReadCacheEnabled { get; private set; } #endregion #region Constructors /// /// Access to ADIF file. /// public AdifFile(string path, bool enableReadCache = false) { // make the lock object m_ReadLock = new object(); // sanity checks if (path == null) throw new ArgumentNullException("path"); if (String.IsNullOrEmpty(path)) throw new ArgumentException("'path' argument cannot be empty"); // keep a copy of the path and cache flag Path = path; ReadCacheEnabled = enableReadCache; // create the directory if it doesn't exist var dir = System.IO.Path.GetDirectoryName(path); if (!String.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { Directory.CreateDirectory(dir); } // create the file if it doesn't exist if (!File.Exists(path)) { using (var file = File.OpenWrite(path)) { var header = new AdifHeader(); header.WriteToFile(file); } } // update the cache size if (ReadCacheEnabled) { var fi = new FileInfo(path); m_CacheSize = fi.Length; } } #endregion #region Public Methods /// /// Read the ADIF header. /// public AdifHeader ReadHeader() { using (var file = File.Open(Path, FileMode.Open, FileAccess.Read, FileShare.None)) { var result = new AdifHeader(); result.ReadFromFile(file); return result; } } /// /// Add a single record. /// public void AddRecord(AdifRecord rec) { if (ReferenceEquals(rec, null)) { throw new ArgumentNullException("rec", "The ADIF record cannot be null"); } // read the file length long len = 0; try { var fi = new FileInfo(Path); len = fi.Length; } catch (FileNotFoundException) { len = 0; } // if it is zero, or if the file doesn't exist, write out a header if (len == 0) { using (var file = File.Open(Path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None)) { AdifHeader h = new AdifHeader(); // default header h.WriteToFile(file); } } // open the file and write the record at the END using (var file = File.Open(Path, FileMode.Open, FileAccess.Write, FileShare.None)) { file.Seek(0, SeekOrigin.End); using (var sw = new StreamWriter(file, Encoding.ASCII)) { sw.WriteLine(rec.ToString()); } } // update the cache; the cache can be null even if // enabled; it will be rebuilt on first read if (ReadCacheEnabled && !ReferenceEquals(m_ReadCache, null)) { m_ReadCache.AddLast(rec); // update the cache size var fi = new FileInfo(Path); m_CacheSize = fi.Length; } } /// /// Fetch all records in the file. /// public IEnumerable GetAllRecords() { List cacheResult = null; lock (m_ReadLock) { if (ReadCacheEnabled) { // invalidate the cache if the file size has changed var fi = new FileInfo(Path); if (fi.Length != m_CacheSize) { m_ReadCache = null; } // if the cache is invalid, reload it if (ReferenceEquals(m_ReadCache, null)) { m_ReadCache = new LinkedList(); if (File.Exists(Path)) { // save the size fi = new FileInfo(Path); m_CacheSize = fi.Length; // read the file contents using (var file = File.Open(Path, FileMode.Open, FileAccess.Read, FileShare.None)) { AdifHeader h = new AdifHeader(); h.ReadFromFile(file); do { AdifRecord r = AdifRecord.ReadFromFile(file); if (ReferenceEquals(r, null)) break; m_ReadCache.AddLast(r); } while (true); } } } // built a temp result list cacheResult = new List(m_ReadCache); } } // if caching is working, return the temp list outside the lock if ( ! ReferenceEquals(cacheResult, null)) { foreach (var item in cacheResult) yield return item; yield break; } // otherwise read records directly from the file if (File.Exists(Path)) { using (var file = File.Open(Path, FileMode.Open, FileAccess.Read, FileShare.None)) { AdifHeader h = new AdifHeader(); /* if (ReadCacheEnabled) { m_ReadCache = new LinkedList(); } */ h.ReadFromFile(file); do { AdifRecord r = AdifRecord.ReadFromFile(file); if (ReferenceEquals(r, null)) break; yield return r; } while (true); } } } /// /// Search for a match to the provided terms. /// public bool GetMatch(string call = null, int? bandInMeters = null, string mode = null) { if ( ! ReferenceEquals(call, null)) { call = call.ToUpper(); } if ( ! ReferenceEquals(mode, null)) { mode = mode.ToUpper(); } foreach (var record in GetAllRecords()) { // match against call if (!String.IsNullOrEmpty(call)) if (!String.Equals(call, record.Call)) continue; // match against band if (bandInMeters != null) { var recBand = record.GetBand(); if (bandInMeters != recBand) continue; } // match against mode if (!String.IsNullOrEmpty(mode)) if (!String.Equals(mode, record.Mode)) continue; // if we made it this far, the record matches return true; } return false; } /// /// Fetch records from the file matching the search criteria. /// public IEnumerable GetRecords(string call = null, int? bandInMeters = null, string mode = null) { if ( ! ReferenceEquals(call, null)) { call = call.ToUpper(); } if ( ! ReferenceEquals(mode, null)) { mode = mode.ToUpper(); } foreach (var record in GetAllRecords()) { // match against call if (!String.IsNullOrEmpty(call)) if (!String.Equals(call, record.Call)) continue; // match against band if (bandInMeters != null) { var recBand = record.GetBand(); if (bandInMeters != recBand) continue; } // match against mode if (!String.IsNullOrEmpty(mode)) if (!String.Equals(mode, record.Mode)) continue; // if we made it this far, the record matches yield return record; } } /// /// Delete first matching record from ADIF. /// public bool DeleteRecord(AdifRecord needle) { if (ReferenceEquals(needle, null)) { throw new ArgumentNullException("needle", "The ADIF record cannot be null"); } // build a replacement file, with the requested record removed var tmp = new AdifFile(Path + ".tmp"); bool found = false; long count = 0; foreach (var rec in GetAllRecords()) { if (rec == needle) { if (!found) { found = true; continue; } } tmp.AddRecord(rec); ++count; } // if that results in no records, just write a temp file with a header like the old one if (count == 0) { using (var file = File.Create(tmp.Path)) { ReadHeader().WriteToFile(file); } } // invalidate the cache lock (m_ReadLock) m_ReadCache = null; // and move the temp file to the original path File.Replace(tmp.Path, Path, null); // return true if work was done return found; } #endregion } } // namespace KK5JY.Log // EOF