Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Small helpers for writing ADIF fields safely.
Expand Down Expand Up @@ -160,4 +162,61 @@ public static String sliceByUtf8Length(String raw, int byteLen) {
}
return raw.substring(0, i);
}

/** The ADIF header terminator {@code <eoh>}, matched case-insensitively. */
private static final Pattern EOH = Pattern.compile("<[Ee][Oo][Hh]>");

/**
* The data-record body of a full {@code .adi} file — everything after the optional
* header — ready to be split on {@code <eor>}.
*
* <p>Per the ADI file format a Header is <em>optional</em>: it is present only when the
* file does <b>not</b> begin with {@code '<'}. When present it is arbitrary text
* terminated by an {@code <eoh>} tag, and the data records follow that tag. A file that
* begins with {@code '<'} is headerless and consists entirely of data records.
*
* <p>Both importers previously split on {@code <eoh>} and returned {@code ""} whenever no
* marker was found. That silently dropped every QSO of a valid <em>headerless</em> ADIF
* log on import (0 records, and 0 errors surfaced to the user) — a common export shape,
* and exactly what a WSJT-X {@code wsjtx_log.adi} whose header was written with the
* v2.2.2 {@code <eh>} bug looks like once opened. This helper returns:
* <ul>
* <li>the whole content when the file is headerless (first non-blank char is {@code '<'});</li>
* <li>the text after the first {@code <eoh>} when a header is present and terminated;</li>
* <li>{@code ""} only when a header is present but unterminated (no {@code <eoh>}) or the
* input is null — there is genuinely no parseable record body.</li>
* </ul>
*
* @param fileContext the full {@code .adi} file text (null → {@code ""})
* @return the record-body text to split on {@code <eor>}
*/
public static String stripHeader(String fileContext) {
if (fileContext == null) {
return "";
}
if (beginsWithField(fileContext)) {
// Headerless file: the whole content is data records.
return fileContext;
}
// Header present (does not begin with '<'); records start after its <eoh> terminator.
Matcher m = EOH.matcher(fileContext);
return m.find() ? fileContext.substring(m.end()) : "";
}

/**
* True when the first non-whitespace character of {@code content} (after an optional
* leading UTF-8 BOM) is {@code '<'} — i.e. the file opens with an ADIF field and so,
* per the spec, carries no header.
*/
private static boolean beginsWithField(String content) {
int i = 0;
int n = content.length();
if (n > 0 && content.charAt(0) == '\uFEFF') { // skip a UTF-8 BOM if present
i = 1;
}
while (i < n && Character.isWhitespace(content.charAt(i))) {
i++;
}
return i < n && content.charAt(i) == '<';
}
}
10 changes: 4 additions & 6 deletions ft8af/app/src/main/java/com/k1af/ft8af/log/ImportSharedLogs.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,10 @@ private boolean loadData(OnShareLogEvents onShareLogEvents) {
}

public String getLogBody() {
String[] temp = fileContext.split("[<][Ee][Oo][Hh][>]");
if (temp.length > 1) {
return temp[temp.length - 1];
} else {
return "";
}
// ADIF's Header is optional: a file that begins with '<' is headerless and is
// entirely records. AdifFormat.stripHeader handles both cases; the previous
// "return '' when no <eoh>" dropped every QSO of a valid headerless log.
return AdifFormat.stripHeader(fileContext);
}

/**
Expand Down
12 changes: 5 additions & 7 deletions ft8af/app/src/main/java/com/k1af/ft8af/log/LogFileImport.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* Log file import.
* The constructor requires a log file name; the file here is posted from NanoHTTPd's session.
* getFileContext returns the entire file content.
* getLogBody returns all raw record content from the log file, i.e., all data after the &lt;eoh&gt; tag.
* getLogBody returns all raw record content from the log file: the data after the &lt;eoh&gt; tag, or the whole file when it is a headerless ADIF log (see AdifFormat.stripHeader).
* getLogRecords returns a list of all parsed records stored as HashMaps, where the Key is the field name (uppercase) and the value is the actual value.
*
* @author BGY70Z
Expand Down Expand Up @@ -92,12 +92,10 @@ public String getFileContext() {
}

public String getLogBody() {
String[] temp = fileContext.split("[<][Ee][Oo][Hh][>]");
if (temp.length > 1) {
return temp[temp.length - 1];
} else {
return "";
}
// ADIF's Header is optional: a file that begins with '<' is headerless and is
// entirely records. AdifFormat.stripHeader handles both cases; the previous
// "return '' when no <eoh>" dropped every QSO of a valid headerless log.
return AdifFormat.stripHeader(fileContext);
}

/**
Expand Down
57 changes: 57 additions & 0 deletions ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,61 @@ public void sliceByUtf8Length_roundTripsUtf8LengthForAstralChars() {
assertThat(AdifFormat.sliceByUtf8Length(emoji, AdifFormat.utf8Length(emoji)))
.isEqualTo(emoji);
}

// ---- stripHeader: the ADIF header/record split (optional-header handling) ----

@Test
public void stripHeader_headedFile_returnsBodyAfterEoh() {
// A header (does not begin with '<') is terminated by <eoh>; records follow it.
String body = AdifFormat.stripHeader(
"Generated by FT8AF<adif_ver:5>3.1.0<eoh><call:5>K1ABC<eor>");
assertThat(body).isEqualTo("<call:5>K1ABC<eor>");
assertThat(body).doesNotContain("adif_ver");
}

@Test
public void stripHeader_headlessFile_returnsWholeContent() {
// Regression: the file begins with '<' so per the ADI spec it has NO header and is
// entirely records. The old importers split on <eoh>, found none, and returned "" \u2014
// dropping every QSO. The whole content must survive.
String content = "<call:5>K1ABC<gridsquare:4>FN42<eor>\n<call:4>W1AW<eor>";
assertThat(AdifFormat.stripHeader(content)).isEqualTo(content);
}

@Test
public void stripHeader_headlessFile_skipsLeadingWhitespaceAndBom() {
// A leading BOM or newline before the first field must not be mistaken for a header.
String content = "<call:5>K1ABC<eor>";
assertThat(AdifFormat.stripHeader("\n " + content)).isEqualTo("\n " + content);
assertThat(AdifFormat.stripHeader("\uFEFF" + content)).isEqualTo("\uFEFF" + content);
}

@Test
public void stripHeader_eohMarkerIsCaseInsensitive() {
assertThat(AdifFormat.stripHeader("hdr<EOH><call:5>K1ABC<eor>"))
.isEqualTo("<call:5>K1ABC<eor>");
assertThat(AdifFormat.stripHeader("hdr<Eoh><call:5>K1ABC<eor>"))
.isEqualTo("<call:5>K1ABC<eor>");
}

@Test
public void stripHeader_splitsAtFirstEohOnly() {
// Only the first <eoh> terminates the header; a later literal one (however unlikely)
// stays in the body rather than truncating it.
assertThat(AdifFormat.stripHeader("hdr<eoh>a<eoh>b"))
.isEqualTo("a<eoh>b");
}

@Test
public void stripHeader_headPresentButNoEoh_returnsEmpty() {
// Header present (does not begin with '<') but unterminated: no parseable body.
assertThat(AdifFormat.stripHeader("header only, no eoh marker")).isEmpty();
}

@Test
public void stripHeader_nullOrEmpty_returnsEmpty() {
assertThat(AdifFormat.stripHeader(null)).isEmpty();
assertThat(AdifFormat.stripHeader("")).isEmpty();
assertThat(AdifFormat.stripHeader(" ")).isEmpty();
}
}
29 changes: 25 additions & 4 deletions ft8af/app/src/test/java/com/k1af/ft8af/log/LogFileImportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,34 @@ public void malformedAdif_errorLinesHtmlEscapesAngleBrackets() throws IOExceptio
}

@Test
public void getLogBody_returnsEmptyWhenNoEoh() throws IOException {
File f = tmp.newFile("noeoh.adi");
public void headerlessAdif_importsRecords() throws IOException {
// Per the ADI spec a file that begins with '<' has no header and is entirely
// data records (WSJT-X's v2.2.2 <eh>-header bug, and many loggers/hand exports,
// produce headerless .adi files). The old code split on <eoh>, found none, and
// returned "" — silently dropping every QSO with no error surfaced.
File f = tmp.newFile("headerless.adi");
try (FileOutputStream out = new FileOutputStream(f)) {
out.write("<call:5>K1ABC<eor>".getBytes(StandardCharsets.UTF_8));
out.write("<call:5>K1ABC<gridsquare:4>FN42<eor>\n<call:4>W1AW<eor>"
.getBytes(StandardCharsets.UTF_8));
}
LogFileImport imp = new LogFileImport(task, f.getAbsolutePath());
assertThat(imp.getLogBody()).contains("<call:5>K1ABC");
ArrayList<HashMap<String, String>> records = imp.getLogRecords();
assertThat(records).hasSize(2);
assertThat(records.get(0).get("CALL")).isEqualTo("K1ABC");
assertThat(records.get(0).get("GRIDSQUARE")).isEqualTo("FN42");
assertThat(records.get(1).get("CALL")).isEqualTo("W1AW");
}

@Test
public void headerPresentButUnterminated_yieldsNoRecords() throws IOException {
// A file that does NOT begin with '<' has a header; without an <eoh> terminator
// it is malformed and carries no parseable record body.
File f = tmp.newFile("badheader.adi");
try (FileOutputStream out = new FileOutputStream(f)) {
out.write("header only, no eoh marker".getBytes(StandardCharsets.UTF_8));
}
LogFileImport imp = new LogFileImport(task, f.getAbsolutePath());
// No <eoh> marker → split produces a single element → body is "".
assertThat(imp.getLogBody()).isEmpty();
assertThat(imp.getLogRecords()).isEmpty();
}
Expand Down
Loading