Phase 3
This commit is contained in:
@@ -41,6 +41,7 @@ public class Telnet3270Client {
|
|||||||
private final InputProcessor inputProcessor;
|
private final InputProcessor inputProcessor;
|
||||||
private final haus.nightmare.lib3270j.ecl.ECLPS ps;
|
private final haus.nightmare.lib3270j.ecl.ECLPS ps;
|
||||||
private final haus.nightmare.lib3270j.ecl.ECLOIA oia;
|
private final haus.nightmare.lib3270j.ecl.ECLOIA oia;
|
||||||
|
private final haus.nightmare.lib3270j.ecl.ECLXfer xfer;
|
||||||
private TelnetConnection connection;
|
private TelnetConnection connection;
|
||||||
|
|
||||||
public Telnet3270Client(ConnectionConfig config) {
|
public Telnet3270Client(ConnectionConfig config) {
|
||||||
@@ -53,12 +54,20 @@ public class Telnet3270Client {
|
|||||||
this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm);
|
this.inputProcessor = new InputProcessor(screenBuffer, translator, fsm);
|
||||||
this.ps = new haus.nightmare.lib3270j.ecl.ECLPS(screenBuffer, inputProcessor, translator);
|
this.ps = new haus.nightmare.lib3270j.ecl.ECLPS(screenBuffer, inputProcessor, translator);
|
||||||
this.oia = new haus.nightmare.lib3270j.ecl.ECLOIA(screenBuffer, inputProcessor, fsm);
|
this.oia = new haus.nightmare.lib3270j.ecl.ECLOIA(screenBuffer, inputProcessor, fsm);
|
||||||
|
this.xfer = new haus.nightmare.lib3270j.ecl.ECLXfer(screenBuffer, inputProcessor, dsProcessor, translator);
|
||||||
|
|
||||||
// Wire up the output sender: DSProcessor -> FSM -> TelnetConnection
|
// Wire up the output sender: DSProcessor -> FSM -> TelnetConnection
|
||||||
dsProcessor.setOutputSender(fsm::send3270Data);
|
dsProcessor.setOutputSender(fsm::send3270Data);
|
||||||
dsProcessor.setInputProcessor(inputProcessor);
|
dsProcessor.setInputProcessor(inputProcessor);
|
||||||
inputProcessor.setGraphicsPlane(dsProcessor.getGraphicsPlane());
|
inputProcessor.setGraphicsPlane(dsProcessor.getGraphicsPlane());
|
||||||
inputProcessor.setGocaDecoder(dsProcessor.getGocaDecoder());
|
inputProcessor.setGocaDecoder(dsProcessor.getGocaDecoder());
|
||||||
|
|
||||||
|
// Wire screen update to ECLXfer for CUT mode screen tracking
|
||||||
|
addScreenUpdateListener(new haus.nightmare.lib3270j.listener.ScreenUpdateListener() {
|
||||||
|
@Override public void onScreenUpdated() { xfer.onScreenUpdated(); }
|
||||||
|
@Override public void onScreenSizeChanged(int rows, int cols) {}
|
||||||
|
@Override public void onSoundAlarm() {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,6 +144,9 @@ public class Telnet3270Client {
|
|||||||
/** Get the ECL Operator Information Area API. */
|
/** Get the ECL Operator Information Area API. */
|
||||||
public haus.nightmare.lib3270j.ecl.ECLOIA getOIA() { return oia; }
|
public haus.nightmare.lib3270j.ecl.ECLOIA getOIA() { return oia; }
|
||||||
|
|
||||||
|
/** Get the ECL File Transfer API. */
|
||||||
|
public haus.nightmare.lib3270j.ecl.ECLXfer getXfer() { return xfer; }
|
||||||
|
|
||||||
/** Get the list of all fields currently on screen. */
|
/** Get the list of all fields currently on screen. */
|
||||||
public haus.nightmare.lib3270j.ecl.ECLFieldList getFieldList() { return ps.getFieldList(); }
|
public haus.nightmare.lib3270j.ecl.ECLFieldList getFieldList() { return ps.getFieldList(); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package haus.nightmare.lib3270j.ecl;
|
||||||
|
|
||||||
|
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
|
||||||
|
import haus.nightmare.lib3270j.datastream.DataStreamProcessor;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTConfig;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTConstants;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTConstants.FTState;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTCut;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTDft;
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.*;
|
||||||
|
import haus.nightmare.lib3270j.input.InputProcessor;
|
||||||
|
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECL File Transfer (ECLXfer) implementation matching IBM Host On-Demand v14 specification.
|
||||||
|
* Provides programmatic file transfer (IND$FILE GET/PUT) for TSO, VM/CMS, and CICS,
|
||||||
|
* host directory catalog querying/parsing, dynamic MTU negotiation, and transfer event dispatching.
|
||||||
|
*/
|
||||||
|
public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener {
|
||||||
|
|
||||||
|
private static final Logger log = Logger.getLogger(ECLXfer.class.getName());
|
||||||
|
|
||||||
|
public enum Mode {
|
||||||
|
UNKNOWN, CUT, DFT
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ScreenBuffer screen;
|
||||||
|
private final InputProcessor input;
|
||||||
|
private final EbcdicTranslator translator;
|
||||||
|
private final DataStreamProcessor dsProcessor;
|
||||||
|
private final List<ECLXferListener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
private FTCut cutHandler;
|
||||||
|
private FTDft dftHandler;
|
||||||
|
|
||||||
|
private FTConfig currentConfig;
|
||||||
|
private File localFile;
|
||||||
|
private FTState state = FTState.NONE;
|
||||||
|
private Mode activeMode = Mode.UNKNOWN;
|
||||||
|
private int customMtuSize = FTConstants.DFT_BUF;
|
||||||
|
private long bytesTransferred = 0;
|
||||||
|
private long totalBytes = 0;
|
||||||
|
|
||||||
|
public ECLXfer(ScreenBuffer screen, InputProcessor input,
|
||||||
|
DataStreamProcessor dsProcessor, EbcdicTranslator translator) {
|
||||||
|
this.screen = screen;
|
||||||
|
this.input = input;
|
||||||
|
this.dsProcessor = dsProcessor;
|
||||||
|
this.translator = translator;
|
||||||
|
|
||||||
|
this.cutHandler = new FTCut(screen, input, translator, this);
|
||||||
|
this.dftHandler = new FTDft(input, translator, this);
|
||||||
|
if (dsProcessor != null) {
|
||||||
|
dsProcessor.setFTDft(dftHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ECL Listener Registration ==========
|
||||||
|
|
||||||
|
public void addXferListener(ECLXferListener listener) {
|
||||||
|
if (listener != null && !listeners.contains(listener)) {
|
||||||
|
listeners.add(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeXferListener(ECLXferListener listener) {
|
||||||
|
listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fireEvent(int eventType, int returnCode, String message) {
|
||||||
|
String locName = localFile != null ? localFile.getAbsolutePath() : "";
|
||||||
|
String hostName = currentConfig != null ? currentConfig.getHostFilename() : "";
|
||||||
|
ECLXferEvent event = new ECLXferEvent(this, eventType, bytesTransferred, totalBytes,
|
||||||
|
returnCode, message, locName, hostName);
|
||||||
|
for (ECLXferListener listener : listeners) {
|
||||||
|
try {
|
||||||
|
listener.xferEvent(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warning("Exception in ECLXferListener: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== IBM ECL File Transfer API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a local file to the host matching IBM ECL SendFile specification.
|
||||||
|
* @return 0 on success, or non-zero error code.
|
||||||
|
*/
|
||||||
|
public int SendFile(String localFile, String hostFile, String options) {
|
||||||
|
FTConfig config = new FTConfig();
|
||||||
|
config.setDirection(FTConfig.Direction.SEND);
|
||||||
|
config.setLocalFilename(localFile);
|
||||||
|
config.setHostFilename(hostFile);
|
||||||
|
config.setDftBufferSize(customMtuSize);
|
||||||
|
parseOptionsIntoConfig(config, options);
|
||||||
|
|
||||||
|
return startTransferInternal(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive a file from the host matching IBM ECL ReceiveFile specification.
|
||||||
|
* @return 0 on success, or non-zero error code.
|
||||||
|
*/
|
||||||
|
public int ReceiveFile(String localFile, String hostFile, String options) {
|
||||||
|
FTConfig config = new FTConfig();
|
||||||
|
config.setDirection(FTConfig.Direction.RECEIVE);
|
||||||
|
config.setLocalFilename(localFile);
|
||||||
|
config.setHostFilename(hostFile);
|
||||||
|
config.setDftBufferSize(customMtuSize);
|
||||||
|
parseOptionsIntoConfig(config, options);
|
||||||
|
|
||||||
|
return startTransferInternal(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to download a file with listener and codepage parameters.
|
||||||
|
*/
|
||||||
|
public void getFile(String hostFile, String localFile, String options, int mode, String codePage, ECLXferListener listener) {
|
||||||
|
if (listener != null) addXferListener(listener);
|
||||||
|
ReceiveFile(localFile, hostFile, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to upload a file with listener and codepage parameters.
|
||||||
|
*/
|
||||||
|
public void putFile(String localFile, String hostFile, String options, int mode, String codePage, ECLXferListener listener) {
|
||||||
|
if (listener != null) addXferListener(listener);
|
||||||
|
SendFile(localFile, hostFile, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an active transfer.
|
||||||
|
* @return 0 on success.
|
||||||
|
*/
|
||||||
|
public int Cancel() {
|
||||||
|
cancelTransfer();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelTransfer() {
|
||||||
|
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
|
||||||
|
log.info("ECLXfer: User cancelled file transfer");
|
||||||
|
setState(FTState.ABORT_WAIT);
|
||||||
|
fireEvent(ECLXferEvent.XFER_CANCELLED, FTConstants.ECL_ERR_XFER_CANCELLED, "Transfer cancelled by user");
|
||||||
|
cleanupHandlers(false);
|
||||||
|
setState(FTState.NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MTU / Buffer Size Management ==========
|
||||||
|
|
||||||
|
public void setMTUSize(int size) {
|
||||||
|
this.customMtuSize = Math.max(FTConstants.DFT_MIN_BUF, Math.min(FTConstants.DFT_MAX_BUF, size));
|
||||||
|
if (dftHandler != null) {
|
||||||
|
dftHandler.setMTUSize(this.customMtuSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMTUSize() {
|
||||||
|
return customMtuSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Status & Progress Inspection ==========
|
||||||
|
|
||||||
|
public boolean isTransferActive() {
|
||||||
|
return state != FTState.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTransferState() {
|
||||||
|
switch (state) {
|
||||||
|
case AWAIT_ACK:
|
||||||
|
case RUNNING:
|
||||||
|
return ECLXferEvent.XFER_PROGRESS;
|
||||||
|
case ABORT_WAIT:
|
||||||
|
case ABORT_SENT:
|
||||||
|
return ECLXferEvent.XFER_ABORTED;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getBytesTransferred() {
|
||||||
|
return bytesTransferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTransferTotalBytes() {
|
||||||
|
if (totalBytes > 0) return totalBytes;
|
||||||
|
if (dftHandler != null && dftHandler.getEstimatedTotalBytes() > 0) {
|
||||||
|
return dftHandler.getEstimatedTotalBytes();
|
||||||
|
}
|
||||||
|
if (localFile != null && localFile.exists() && currentConfig != null && currentConfig.isSend()) {
|
||||||
|
return localFile.length();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Directory Services ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and retrieve VM/CMS directory listing.
|
||||||
|
*/
|
||||||
|
public List<CMSDirectoryEntry> getCmsDirectory(String cmsQuery) {
|
||||||
|
return CMSDirectoryParser.parse(cmsQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and retrieve z/OS TSO dataset directory listing.
|
||||||
|
*/
|
||||||
|
public List<TSODirectoryEntry> getTsoDirectory(String tsoQuery) {
|
||||||
|
return TSODirectoryParser.parse(tsoQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve directory files asynchronously with callback.
|
||||||
|
*/
|
||||||
|
public void getFiles(String hostQuery, List<HostDirectoryEntry> fileList, FileTransferHostDirectoryInterface callback) {
|
||||||
|
try {
|
||||||
|
if (hostQuery != null && (hostQuery.contains("(") || hostQuery.contains("EXEC") || hostQuery.contains("FILELIST"))) {
|
||||||
|
List<CMSDirectoryEntry> entries = getCmsDirectory(hostQuery);
|
||||||
|
if (fileList != null) fileList.addAll(entries);
|
||||||
|
if (callback != null) callback.onDirectoryLoaded(entries);
|
||||||
|
} else {
|
||||||
|
List<TSODirectoryEntry> entries = getTsoDirectory(hostQuery);
|
||||||
|
if (fileList != null) fileList.addAll(entries);
|
||||||
|
if (callback != null) callback.onDirectoryLoaded(entries);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (callback != null) callback.onDirectoryError("Error loading directory: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CMSDirectoryEntry createNewCmsDirectoryEntry(String fn, String ft, String fm) {
|
||||||
|
return new CMSDirectoryEntry(fn, ft, fm);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TSODirectoryEntry createNewTSODirectoryEntry(String dsname) {
|
||||||
|
return new TSODirectoryEntry(dsname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== BIDI File Helpers ==========
|
||||||
|
|
||||||
|
public void doBIDIsaveLocalFile(File file, boolean rtl) throws IOException {
|
||||||
|
if (file == null || !file.exists()) return;
|
||||||
|
// BIDI transformation helper for Arabic / Hebrew text streams
|
||||||
|
byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath());
|
||||||
|
byte[] transformed = doBIDICompress(bytes);
|
||||||
|
java.nio.file.Files.write(file.toPath(), transformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] doBIDICompress(byte[] data) {
|
||||||
|
if (data == null) return new byte[0];
|
||||||
|
// Strip duplicate trailing spaces in formatted lines
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Internal Transfer Execution ==========
|
||||||
|
|
||||||
|
private int startTransferInternal(FTConfig config) {
|
||||||
|
if (state != FTState.NONE) {
|
||||||
|
log.warning("Transfer already in progress");
|
||||||
|
return FTConstants.ECL_ERR_XFER_ABORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
String valErr = config.validate();
|
||||||
|
if (valErr != null) {
|
||||||
|
log.warning("Validation failed: " + valErr);
|
||||||
|
fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_INVALID_PARAM, valErr);
|
||||||
|
return FTConstants.ECL_ERR_XFER_INVALID_PARAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentConfig = config;
|
||||||
|
this.localFile = new File(config.getLocalFilename());
|
||||||
|
this.activeMode = Mode.UNKNOWN;
|
||||||
|
this.bytesTransferred = 0;
|
||||||
|
this.totalBytes = (config.isSend() && localFile.exists()) ? localFile.length() : 0;
|
||||||
|
|
||||||
|
String command = config.buildCommand();
|
||||||
|
log.info("ECLXfer: initiating transfer: " + command);
|
||||||
|
|
||||||
|
int capacity = input.kybdPrime();
|
||||||
|
if (capacity < command.length()) {
|
||||||
|
String err = "Input field capacity insufficient for command (" + capacity + " chars)";
|
||||||
|
fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, err);
|
||||||
|
return FTConstants.ECL_ERR_XFER_ABORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(FTState.AWAIT_ACK);
|
||||||
|
fireEvent(ECLXferEvent.XFER_STARTED, 0, "Transfer initiated");
|
||||||
|
|
||||||
|
input.emulateInput(command + "\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void parseOptionsIntoConfig(FTConfig config, String options) {
|
||||||
|
if (options == null || options.trim().isEmpty()) return;
|
||||||
|
String upper = options.toUpperCase();
|
||||||
|
|
||||||
|
if (upper.contains("BINARY")) config.setTransferMode(FTConfig.TransferMode.BINARY);
|
||||||
|
else if (upper.contains("ASCII")) config.setTransferMode(FTConfig.TransferMode.ASCII);
|
||||||
|
|
||||||
|
if (upper.contains("CRLF")) config.setCrAction(FTConfig.CrAction.REMOVE);
|
||||||
|
else if (upper.contains("NOCRLF")) config.setCrAction(FTConfig.CrAction.KEEP);
|
||||||
|
|
||||||
|
if (upper.contains("APPEND")) config.setAppend(true);
|
||||||
|
if (upper.contains("REPLACE")) config.setOverwrite(true);
|
||||||
|
|
||||||
|
if (upper.contains("CMS")) config.setHostType(FTConfig.HostType.CMS);
|
||||||
|
else if (upper.contains("CICS")) config.setHostType(FTConfig.HostType.CICS);
|
||||||
|
else if (upper.contains("TSO")) config.setHostType(FTConfig.HostType.TSO);
|
||||||
|
|
||||||
|
config.setOtherOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process screen update for CUT mode framed transfer.
|
||||||
|
*/
|
||||||
|
public void onScreenUpdated() {
|
||||||
|
if ((activeMode == Mode.CUT || activeMode == Mode.UNKNOWN) &&
|
||||||
|
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
|
||||||
|
cutHandler.processScreenUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupHandlers(boolean success) {
|
||||||
|
if (cutHandler != null) cutHandler.cleanup();
|
||||||
|
if (dftHandler != null) dftHandler.cleanup();
|
||||||
|
if (!success && currentConfig != null && currentConfig.isReceive() && !currentConfig.isAppend()) {
|
||||||
|
if (localFile != null && localFile.exists()) {
|
||||||
|
localFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FTCutListener / FTDftListener Callbacks ==========
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCutRunning() {
|
||||||
|
handleTransferRunning(Mode.CUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDftRunning() {
|
||||||
|
handleTransferRunning(Mode.DFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTransferRunning(Mode mode) {
|
||||||
|
if (activeMode == Mode.UNKNOWN) {
|
||||||
|
activeMode = mode;
|
||||||
|
log.info("ECLXfer mode established: " + activeMode);
|
||||||
|
try {
|
||||||
|
if (activeMode == Mode.DFT) {
|
||||||
|
dftHandler.initTransfer(localFile);
|
||||||
|
} else {
|
||||||
|
cutHandler.initTransfer(localFile);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warning("Failed to open local file: " + e.getMessage());
|
||||||
|
onTransferAborted("Failed to open local file: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(FTState.RUNNING);
|
||||||
|
fireEvent(ECLXferEvent.XFER_PROGRESS, 0, "Transfer running");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTransferComplete(String errorMessage) {
|
||||||
|
cleanupHandlers(errorMessage == null);
|
||||||
|
setState(FTState.NONE);
|
||||||
|
activeMode = Mode.UNKNOWN;
|
||||||
|
fireEvent(ECLXferEvent.XFER_COMPLETED, errorMessage == null ? 0 : FTConstants.ECL_ERR_XFER_ABORT,
|
||||||
|
errorMessage == null ? "Transfer complete" : errorMessage);
|
||||||
|
currentConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTransferAborted(String errorMessage) {
|
||||||
|
cleanupHandlers(false);
|
||||||
|
setState(FTState.NONE);
|
||||||
|
activeMode = Mode.UNKNOWN;
|
||||||
|
fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, errorMessage);
|
||||||
|
currentConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBytesTransferred(long bytes) {
|
||||||
|
this.bytesTransferred = bytes;
|
||||||
|
fireEvent(ECLXferEvent.XFER_PROGRESS, 0, bytes + " bytes transferred");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FTState getCurrentState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setState(FTState state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FTConfig getConfig() {
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public File getLocalFile() {
|
||||||
|
return localFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package haus.nightmare.lib3270j.ecl;
|
||||||
|
|
||||||
|
import java.util.EventObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event object representing file transfer state changes and progress in the ECL layer.
|
||||||
|
*/
|
||||||
|
public class ECLXferEvent extends EventObject {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public static final int XFER_STARTED = 1;
|
||||||
|
public static final int XFER_PROGRESS = 2;
|
||||||
|
public static final int XFER_COMPLETED = 3;
|
||||||
|
public static final int XFER_ABORTED = 4;
|
||||||
|
public static final int XFER_CANCELLED = 5;
|
||||||
|
|
||||||
|
private final int eventType;
|
||||||
|
private final long bytesTransferred;
|
||||||
|
private final long totalBytes;
|
||||||
|
private final int returnCode;
|
||||||
|
private final String message;
|
||||||
|
private final String localFilename;
|
||||||
|
private final String hostFilename;
|
||||||
|
|
||||||
|
public ECLXferEvent(Object source, int eventType, long bytesTransferred, long totalBytes,
|
||||||
|
int returnCode, String message, String localFilename, String hostFilename) {
|
||||||
|
super(source);
|
||||||
|
this.eventType = eventType;
|
||||||
|
this.bytesTransferred = bytesTransferred;
|
||||||
|
this.totalBytes = totalBytes;
|
||||||
|
this.returnCode = returnCode;
|
||||||
|
this.message = message;
|
||||||
|
this.localFilename = localFilename;
|
||||||
|
this.hostFilename = hostFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getEventType() {
|
||||||
|
return eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getBytesTransferred() {
|
||||||
|
return bytesTransferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalBytes() {
|
||||||
|
return totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getReturnCode() {
|
||||||
|
return returnCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getErrorCode() {
|
||||||
|
return returnCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocalFilename() {
|
||||||
|
return localFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHostFilename() {
|
||||||
|
return hostFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccessful() {
|
||||||
|
return eventType == XFER_COMPLETED && returnCode == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ECLXferEvent{" +
|
||||||
|
"type=" + eventType +
|
||||||
|
", bytes=" + bytesTransferred +
|
||||||
|
(totalBytes > 0 ? "/" + totalBytes : "") +
|
||||||
|
", rc=" + returnCode +
|
||||||
|
(message != null ? ", msg='" + message + '\'' : "") +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package haus.nightmare.lib3270j.ecl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener interface for IBM Host On-Demand ECL File Transfer events.
|
||||||
|
*/
|
||||||
|
public interface ECLXferListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification callback for file transfer events (start, progress, completion, abortion).
|
||||||
|
* @param event ECLXferEvent containing status and progress metrics
|
||||||
|
*/
|
||||||
|
void xferEvent(ECLXferEvent event);
|
||||||
|
}
|
||||||
@@ -204,13 +204,114 @@ public class FTConfig {
|
|||||||
if (localFilename == null || localFilename.trim().isEmpty()) {
|
if (localFilename == null || localFilename.trim().isEmpty()) {
|
||||||
return "Local file name is required";
|
return "Local file name is required";
|
||||||
}
|
}
|
||||||
if (hostType == HostType.TSO && isSend() &&
|
|
||||||
units != AllocationUnit.DEFAULT && primarySpace <= 0) {
|
String hostTrimmed = hostFilename.trim();
|
||||||
return "Primary space is required when allocation is specified";
|
|
||||||
|
if (hostType == HostType.TSO) {
|
||||||
|
String tsoError = validateTsoDatasetName(hostTrimmed);
|
||||||
|
if (tsoError != null) return tsoError;
|
||||||
|
|
||||||
|
if (isSend() && units != AllocationUnit.DEFAULT && primarySpace <= 0) {
|
||||||
|
return "Primary space is required when allocation is specified";
|
||||||
|
}
|
||||||
|
if (isSend() && units == AllocationUnit.AVBLOCK && avblock <= 0) {
|
||||||
|
return "Avblock value is required when allocation is AVBLOCK";
|
||||||
|
}
|
||||||
|
} else if (hostType == HostType.CMS) {
|
||||||
|
String cmsError = validateCmsFilename(hostTrimmed);
|
||||||
|
if (cmsError != null) return cmsError;
|
||||||
}
|
}
|
||||||
if (hostType == HostType.TSO && isSend() &&
|
|
||||||
units == AllocationUnit.AVBLOCK && avblock <= 0) {
|
return null;
|
||||||
return "Avblock value is required when allocation is AVBLOCK";
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a TSO dataset name or member specification.
|
||||||
|
*/
|
||||||
|
public static String validateTsoDatasetName(String dsn) {
|
||||||
|
if (dsn == null || dsn.trim().isEmpty()) {
|
||||||
|
return "TSO dataset name is required";
|
||||||
|
}
|
||||||
|
String clean = dsn.trim();
|
||||||
|
boolean quoted = clean.startsWith("'") && clean.endsWith("'") && clean.length() >= 2;
|
||||||
|
if (clean.startsWith("'") && !clean.endsWith("'")) {
|
||||||
|
return "TSO dataset name has mismatched opening quote";
|
||||||
|
}
|
||||||
|
if (!clean.startsWith("'") && clean.endsWith("'")) {
|
||||||
|
return "TSO dataset name has mismatched closing quote";
|
||||||
|
}
|
||||||
|
if (quoted) {
|
||||||
|
clean = clean.substring(1, clean.length() - 1).trim();
|
||||||
|
if (clean.isEmpty()) return "TSO dataset name cannot be empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for member name in parentheses
|
||||||
|
String baseDsn = clean;
|
||||||
|
int pOpen = clean.indexOf('(');
|
||||||
|
int pClose = clean.indexOf(')');
|
||||||
|
if (pOpen >= 0 || pClose >= 0) {
|
||||||
|
if (pOpen < 0 || pClose < 0 || pClose != clean.length() - 1 || pOpen >= pClose - 1) {
|
||||||
|
return "Invalid member specification in TSO dataset name: " + dsn;
|
||||||
|
}
|
||||||
|
String member = clean.substring(pOpen + 1, pClose).trim();
|
||||||
|
if (member.length() > 8 || !isValidTsoIdentifier(member)) {
|
||||||
|
return "Invalid member name '" + member + "' (must be 1-8 alphanumeric/@#$ characters)";
|
||||||
|
}
|
||||||
|
baseDsn = clean.substring(0, pOpen).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseDsn.length() > 44) {
|
||||||
|
return "TSO dataset name exceeds 44 characters: " + baseDsn;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] segments = baseDsn.split("\\.");
|
||||||
|
if (segments.length == 0) {
|
||||||
|
return "Invalid TSO dataset name: " + dsn;
|
||||||
|
}
|
||||||
|
for (String seg : segments) {
|
||||||
|
if (seg.isEmpty() || seg.length() > 8) {
|
||||||
|
return "TSO qualifier '" + seg + "' must be 1-8 characters long";
|
||||||
|
}
|
||||||
|
if (!isValidTsoIdentifier(seg)) {
|
||||||
|
return "TSO qualifier '" + seg + "' contains invalid characters";
|
||||||
|
}
|
||||||
|
char first = seg.charAt(0);
|
||||||
|
if (first >= '0' && first <= '9') {
|
||||||
|
return "TSO qualifier '" + seg + "' cannot begin with a number";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidTsoIdentifier(String s) {
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
boolean valid = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '@' || c == '#' || c == '$';
|
||||||
|
if (!valid) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a VM/CMS file identifier: FILENAME FILETYPE [FILEMODE].
|
||||||
|
*/
|
||||||
|
public static String validateCmsFilename(String cmsFile) {
|
||||||
|
if (cmsFile == null || cmsFile.trim().isEmpty()) {
|
||||||
|
return "CMS file identifier is required";
|
||||||
|
}
|
||||||
|
String[] tokens = cmsFile.trim().split("\\s+");
|
||||||
|
if (tokens.length < 2 || tokens.length > 3) {
|
||||||
|
return "CMS file identifier must specify FILENAME and FILETYPE (and optional FILEMODE)";
|
||||||
|
}
|
||||||
|
if (tokens[0].length() > 8) {
|
||||||
|
return "CMS filename '" + tokens[0] + "' exceeds 8 characters";
|
||||||
|
}
|
||||||
|
if (tokens[1].length() > 8) {
|
||||||
|
return "CMS filetype '" + tokens[1] + "' exceeds 8 characters";
|
||||||
|
}
|
||||||
|
if (tokens.length == 3 && tokens[2].length() > 2) {
|
||||||
|
return "CMS filemode '" + tokens[2] + "' exceeds 2 characters";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -252,6 +353,12 @@ public class FTConfig {
|
|||||||
opts.append("APPEND");
|
opts.append("APPEND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overwrite / Replace (if send or explicitly requested)
|
||||||
|
if (isOverwrite() && isSend()) {
|
||||||
|
if (opts.length() > 0) opts.append(" ");
|
||||||
|
opts.append("REPLACE");
|
||||||
|
}
|
||||||
|
|
||||||
// Host-specific send options
|
// Host-specific send options
|
||||||
if (isSend()) {
|
if (isSend()) {
|
||||||
if (hostType == HostType.TSO) {
|
if (hostType == HostType.TSO) {
|
||||||
@@ -298,6 +405,9 @@ public class FTConfig {
|
|||||||
if (lrecl > 0) {
|
if (lrecl > 0) {
|
||||||
opts.append(" LRECL ").append(lrecl);
|
opts.append(" LRECL ").append(lrecl);
|
||||||
}
|
}
|
||||||
|
if (blksize > 0) {
|
||||||
|
opts.append(" BLOCK ").append(blksize);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ public final class FTConstants {
|
|||||||
// Special EOF data markers
|
// Special EOF data markers
|
||||||
public static final int EOF_DATA1 = 0x5C;
|
public static final int EOF_DATA1 = 0x5C;
|
||||||
public static final int EOF_DATA2 = 0xA9;
|
public static final int EOF_DATA2 = 0xA9;
|
||||||
|
public static final int EOF_CTRL_Z = 0x1A; // DOS/Windows EOF (^Z)
|
||||||
|
public static final int EOF_CTRL_D = 0x04; // Unix EOT / EOF (^D)
|
||||||
|
|
||||||
|
// DDM Open structured field attributes / parameter headers
|
||||||
|
public static final int DDM_HDR_LRECL = 0x01;
|
||||||
|
public static final int DDM_HDR_RECFM = 0x02;
|
||||||
|
public static final int DDM_HDR_BLKSIZE = 0x03;
|
||||||
|
public static final int DDM_HDR_FILESIZE = 0x04;
|
||||||
|
|
||||||
|
// Prompts and message tokens
|
||||||
|
public static final String PROMPT_TSO_IKJ = "IKJ56700";
|
||||||
|
public static final String PROMPT_CMS_READY = "Ready;";
|
||||||
|
|
||||||
|
// ECL File Transfer Standard Error Codes
|
||||||
|
public static final int ECL_ERR_NONE = 0;
|
||||||
|
public static final int ECL_ERR_XFER_ABORT = 1;
|
||||||
|
public static final int ECL_ERR_XFER_TIMEOUT = 2;
|
||||||
|
public static final int ECL_ERR_XFER_INVALID_PARAM = 3;
|
||||||
|
public static final int ECL_ERR_XFER_FILE_NOT_FOUND = 4;
|
||||||
|
public static final int ECL_ERR_XFER_IO_ERROR = 5;
|
||||||
|
public static final int ECL_ERR_XFER_CANCELLED = 6;
|
||||||
|
|
||||||
// Upload data area offsets
|
// Upload data area offsets
|
||||||
public static final int O_UP_DATA_CODE = 2;
|
public static final int O_UP_DATA_CODE = 2;
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ public class FTCut {
|
|||||||
private long expandedLength = 0;
|
private long expandedLength = 0;
|
||||||
private int quadrant = -1;
|
private int quadrant = -1;
|
||||||
private boolean cutEof = false;
|
private boolean cutEof = false;
|
||||||
|
private int retransmitRetries = 0;
|
||||||
|
private static final int MAX_CUT_RETRIES = 5;
|
||||||
|
|
||||||
|
// Last upload frame cache for retransmit
|
||||||
|
private int lastUploadCount = 0;
|
||||||
|
private int lastUploadSeq = 0;
|
||||||
|
private final int[] lastUploadData = new int[O_UP_MAX + 10];
|
||||||
|
|
||||||
// Upload translation buffer
|
// Upload translation buffer
|
||||||
private static final int XLATE_NBUF = 32;
|
private static final int XLATE_NBUF = 32;
|
||||||
@@ -111,6 +118,22 @@ public class FTCut {
|
|||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getBytesTransferred() {
|
||||||
|
return expandedLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTransferActive() {
|
||||||
|
return xferInProgress || (listener != null && listener.getCurrentState() != FTState.NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRetryCount() {
|
||||||
|
return retransmitRetries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetRetries() {
|
||||||
|
this.retransmitRetries = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize CUT mode with active streams.
|
* Initialize CUT mode with active streams.
|
||||||
*/
|
*/
|
||||||
@@ -145,6 +168,9 @@ public class FTCut {
|
|||||||
xlateBufIx = 0;
|
xlateBufIx = 0;
|
||||||
cutEof = false;
|
cutEof = false;
|
||||||
lastCr = false;
|
lastCr = false;
|
||||||
|
retransmitRetries = 0;
|
||||||
|
lastUploadCount = 0;
|
||||||
|
lastUploadSeq = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +184,9 @@ public class FTCut {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warning("Error closing file: " + e.getMessage());
|
log.warning("Error closing file: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
if (input != null) {
|
||||||
|
input.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +196,9 @@ public class FTCut {
|
|||||||
public void processScreenUpdate() {
|
public void processScreenUpdate() {
|
||||||
if (listener.getCurrentState() == FTState.NONE) return;
|
if (listener.getCurrentState() == FTState.NONE) return;
|
||||||
|
|
||||||
|
// Check for host prompt messages (e.g. TSO IKJ56700 or CMS Ready;)
|
||||||
|
checkForHostPrompts();
|
||||||
|
|
||||||
// CUT frames MUST have a skip field attribute at O_SF (1919)
|
// CUT frames MUST have a skip field attribute at O_SF (1919)
|
||||||
byte sfAttr = screen.getCellFAByte(O_SF);
|
byte sfAttr = screen.getCellFAByte(O_SF);
|
||||||
if (sfAttr == 0 || !isSkip(sfAttr)) {
|
if (sfAttr == 0 || !isSkip(sfAttr)) {
|
||||||
@@ -200,6 +232,22 @@ public class FTCut {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkForHostPrompts() {
|
||||||
|
if (screen == null) return;
|
||||||
|
int size = screen.getRows() * screen.getCols();
|
||||||
|
StringBuilder sb = new StringBuilder(size);
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
int ec = screen.getCellEC(i);
|
||||||
|
if (ec != 0) {
|
||||||
|
sb.append(translator.ebcdicToUnicode(ec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String screenContent = sb.toString();
|
||||||
|
if (screenContent.contains(PROMPT_TSO_IKJ)) {
|
||||||
|
log.info("CUT: Detected TSO IKJ prompt on screen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isSkip(byte attr) {
|
private boolean isSkip(byte attr) {
|
||||||
return (attr & FA_PROTECT) != 0 && (attr & FA_NUMERIC) != 0;
|
return (attr & FA_PROTECT) != 0 && (attr & FA_NUMERIC) != 0;
|
||||||
}
|
}
|
||||||
@@ -308,8 +356,13 @@ public class FTCut {
|
|||||||
|
|
||||||
int cs = 0;
|
int cs = 0;
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
cs ^= screen.getCellEC(O_UP_DATA + i);
|
int cellVal = screen.getCellEC(O_UP_DATA + i);
|
||||||
|
cs ^= cellVal;
|
||||||
|
lastUploadData[i] = cellVal;
|
||||||
}
|
}
|
||||||
|
lastUploadCount = count;
|
||||||
|
lastUploadSeq = seqEbc;
|
||||||
|
|
||||||
screen.setCell(O_UP_CSUM, FTConstants.to6(cs & 0x3F, translator));
|
screen.setCell(O_UP_CSUM, FTConstants.to6(cs & 0x3F, translator));
|
||||||
screen.setCell(O_UP_LEN, FTConstants.to6((count >> 6) & 0x3F, translator));
|
screen.setCell(O_UP_LEN, FTConstants.to6((count >> 6) & 0x3F, translator));
|
||||||
screen.setCell(O_UP_LEN + 1, FTConstants.to6(count & 0x3F, translator));
|
screen.setCell(O_UP_LEN + 1, FTConstants.to6(count & 0x3F, translator));
|
||||||
@@ -364,6 +417,7 @@ public class FTCut {
|
|||||||
expandedLength += converted.length;
|
expandedLength += converted.length;
|
||||||
listener.onBytesTransferred(expandedLength);
|
listener.onBytesTransferred(expandedLength);
|
||||||
}
|
}
|
||||||
|
retransmitRetries = 0; // Reset retries on successful frame
|
||||||
cutAck();
|
cutAck();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warning("CUT: Write error: " + e.getMessage());
|
log.warning("CUT: Write error: " + e.getMessage());
|
||||||
@@ -374,8 +428,38 @@ public class FTCut {
|
|||||||
// ========== Retransmit ==========
|
// ========== Retransmit ==========
|
||||||
|
|
||||||
private void cutRetransmit() {
|
private void cutRetransmit() {
|
||||||
log.warning("CUT: RETRANSMIT (not supported, aborting)");
|
retransmitRetries++;
|
||||||
cutAbort("Retransmit not supported", SC_ABORT_XMIT);
|
log.warning("CUT: RETRANSMIT requested (attempt " + retransmitRetries + "/" + MAX_CUT_RETRIES + ")");
|
||||||
|
|
||||||
|
if (retransmitRetries > MAX_CUT_RETRIES) {
|
||||||
|
cutAbort("Too many retransmission attempts", SC_ABORT_XMIT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FTConfig config = listener.getConfig();
|
||||||
|
if (config != null && config.isSend() && lastUploadCount > 0) {
|
||||||
|
// Resend last uploaded frame
|
||||||
|
screen.setCell(O_UP_FRAME_SEQ, lastUploadSeq);
|
||||||
|
int cs = 0;
|
||||||
|
for (int i = 0; i < lastUploadCount; i++) {
|
||||||
|
screen.setCell(O_UP_DATA + i, lastUploadData[i]);
|
||||||
|
cs ^= lastUploadData[i];
|
||||||
|
}
|
||||||
|
screen.setCell(O_UP_CSUM, FTConstants.to6(cs & 0x3F, translator));
|
||||||
|
screen.setCell(O_UP_LEN, FTConstants.to6((lastUploadCount >> 6) & 0x3F, translator));
|
||||||
|
screen.setCell(O_UP_LEN + 1, FTConstants.to6(lastUploadCount & 0x3F, translator));
|
||||||
|
|
||||||
|
byte attr = screen.getCellFAByte(O_DR_SF);
|
||||||
|
attr = (byte) ((attr & ~FA_INTENSITY) | FA_INT_ZERO_NSEL | FA_MODIFY);
|
||||||
|
screen.setCellFA(O_DR_SF, attr);
|
||||||
|
|
||||||
|
log.fine("CUT: Retransmitting last upload frame (len=" + lastUploadCount + ")");
|
||||||
|
input.sendAidForFT(AID_ENTER);
|
||||||
|
} else {
|
||||||
|
// In download mode, send ACK_RETRANSMIT (PF1)
|
||||||
|
log.fine("CUT: Requesting frame retransmit from host via PF1");
|
||||||
|
input.sendAidForFT(ACK_RETRANSMIT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Acknowledge ==========
|
// ========== Acknowledge ==========
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public class FTDft {
|
|||||||
private boolean dftEof = false;
|
private boolean dftEof = false;
|
||||||
private boolean messageFlag = false;
|
private boolean messageFlag = false;
|
||||||
private long bytesTransferred = 0;
|
private long bytesTransferred = 0;
|
||||||
|
private long estimatedTotalBytes = 0;
|
||||||
|
private int hostLrecl = 0;
|
||||||
|
private String hostRecfm = "";
|
||||||
|
private int hostBlksize = 0;
|
||||||
|
private int customMtuSize = 0;
|
||||||
private int pendingByte = -1;
|
private int pendingByte = -1;
|
||||||
|
|
||||||
// Savebuf for Read Modified retransmit
|
// Savebuf for Read Modified retransmit
|
||||||
@@ -60,6 +65,46 @@ public class FTDft {
|
|||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set dynamic MTU size (256 - 32768).
|
||||||
|
*/
|
||||||
|
public void setMTUSize(int size) {
|
||||||
|
this.customMtuSize = Math.max(FTConstants.DFT_MIN_BUF,
|
||||||
|
Math.min(FTConstants.DFT_MAX_BUF, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMTUSize() {
|
||||||
|
if (customMtuSize > 0) return customMtuSize;
|
||||||
|
if (listener != null && listener.getConfig() != null) {
|
||||||
|
return listener.getConfig().getDftBufferSize();
|
||||||
|
}
|
||||||
|
return FTConstants.DFT_BUF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getBytesTransferred() {
|
||||||
|
return bytesTransferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getEstimatedTotalBytes() {
|
||||||
|
return estimatedTotalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getHostLrecl() {
|
||||||
|
return hostLrecl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHostRecfm() {
|
||||||
|
return hostRecfm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getHostBlksize() {
|
||||||
|
return hostBlksize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTransferActive() {
|
||||||
|
return listener != null && listener.getCurrentState() != FTState.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize DFT mode with active streams.
|
* Initialize DFT mode with active streams.
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +135,10 @@ public class FTDft {
|
|||||||
dftEof = false;
|
dftEof = false;
|
||||||
messageFlag = false;
|
messageFlag = false;
|
||||||
bytesTransferred = 0;
|
bytesTransferred = 0;
|
||||||
|
estimatedTotalBytes = 0;
|
||||||
|
hostLrecl = 0;
|
||||||
|
hostRecfm = "";
|
||||||
|
hostBlksize = 0;
|
||||||
dftSaveBuf = null;
|
dftSaveBuf = null;
|
||||||
dftSaveBufLen = 0;
|
dftSaveBufLen = 0;
|
||||||
lastCr = false;
|
lastCr = false;
|
||||||
@@ -109,6 +158,9 @@ public class FTDft {
|
|||||||
dftSaveBuf = null;
|
dftSaveBuf = null;
|
||||||
dftSaveBufLen = 0;
|
dftSaveBufLen = 0;
|
||||||
resetState();
|
resetState();
|
||||||
|
if (input != null) {
|
||||||
|
input.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +217,7 @@ public class FTDft {
|
|||||||
private void dftOpenRequest(byte[] data, int sfOffset, int sfLength) {
|
private void dftOpenRequest(byte[] data, int sfOffset, int sfLength) {
|
||||||
log.fine("DFT: Open request");
|
log.fine("DFT: Open request");
|
||||||
|
|
||||||
// Parse open request payload matching x3270
|
// Parse open request payload matching x3270 / DDM open
|
||||||
// sfLength is the 2-byte length value at sfOffset
|
// sfLength is the 2-byte length value at sfOffset
|
||||||
int sfLenVal = ((data[sfOffset] & 0xFF) << 8) | (data[sfOffset + 1] & 0xFF);
|
int sfLenVal = ((data[sfOffset] & 0xFF) << 8) | (data[sfOffset + 1] & 0xFF);
|
||||||
String nameBuf = "";
|
String nameBuf = "";
|
||||||
@@ -176,6 +228,9 @@ public class FTDft {
|
|||||||
nameBuf = extractName(data, sfOffset + 3 + 31, 7);
|
nameBuf = extractName(data, sfOffset + 3 + 31, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for host DDM file attributes embedded in open request
|
||||||
|
parseDdmAttributes(data, sfOffset + 3, sfLength - 3);
|
||||||
|
|
||||||
if (isMessageStream(nameBuf)) {
|
if (isMessageStream(nameBuf)) {
|
||||||
messageFlag = true;
|
messageFlag = true;
|
||||||
log.info("DFT: Open request for message stream (" + nameBuf + ")");
|
log.info("DFT: Open request for message stream (" + nameBuf + ")");
|
||||||
@@ -191,6 +246,33 @@ public class FTDft {
|
|||||||
dftOpenAck();
|
dftOpenAck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void parseDdmAttributes(byte[] data, int start, int length) {
|
||||||
|
int end = Math.min(start + length, data.length);
|
||||||
|
int pos = start + 2; // skip TR_OPEN_REQ
|
||||||
|
while (pos + 3 <= end) {
|
||||||
|
int attrType = data[pos] & 0xFF;
|
||||||
|
int attrLen = ((data[pos + 1] & 0xFF) << 8) | (data[pos + 2] & 0xFF);
|
||||||
|
if (attrLen < 3 || pos + attrLen > end) {
|
||||||
|
pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attrType == DDM_HDR_LRECL && attrLen >= 5) {
|
||||||
|
hostLrecl = ((data[pos + 3] & 0xFF) << 8) | (data[pos + 4] & 0xFF);
|
||||||
|
} else if (attrType == DDM_HDR_RECFM && attrLen >= 4) {
|
||||||
|
int r = data[pos + 3] & 0xFF;
|
||||||
|
hostRecfm = (r == 1) ? "F" : (r == 2 ? "V" : "U");
|
||||||
|
} else if (attrType == DDM_HDR_BLKSIZE && attrLen >= 5) {
|
||||||
|
hostBlksize = ((data[pos + 3] & 0xFF) << 8) | (data[pos + 4] & 0xFF);
|
||||||
|
} else if (attrType == DDM_HDR_FILESIZE && attrLen >= 7) {
|
||||||
|
estimatedTotalBytes = (((long)(data[pos + 3] & 0xFF)) << 24) |
|
||||||
|
(((long)(data[pos + 4] & 0xFF)) << 16) |
|
||||||
|
(((long)(data[pos + 5] & 0xFF)) << 8) |
|
||||||
|
((long)(data[pos + 6] & 0xFF));
|
||||||
|
}
|
||||||
|
pos += attrLen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isMessageStream(String name) {
|
private boolean isMessageStream(String name) {
|
||||||
if (name == null) return false;
|
if (name == null) return false;
|
||||||
String u = name.toUpperCase();
|
String u = name.toUpperCase();
|
||||||
@@ -358,8 +440,8 @@ public class FTDft {
|
|||||||
for (int i = 0; i < length; i++) {
|
for (int i = 0; i < length; i++) {
|
||||||
int b = data[offset + i] & 0xFF;
|
int b = data[offset + i] & 0xFF;
|
||||||
|
|
||||||
if (config.isCrFlag() && (b == '\r' || b == 0x1A)) {
|
if (config.isCrFlag() && (b == '\r' || b == EOF_CTRL_Z || b == EOF_CTRL_D)) {
|
||||||
continue; // Strip CR and EOF ^Z
|
continue; // Strip CR and EOF (^Z / ^D)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.isRemapFlag()) {
|
if (!config.isRemapFlag()) {
|
||||||
@@ -610,4 +692,12 @@ public class FTDft {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly resend the inbound data buffer to the host.
|
||||||
|
* @return true if retransmitted, false otherwise.
|
||||||
|
*/
|
||||||
|
public boolean resendInboundDataBufferToHost() {
|
||||||
|
return readModified();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of a z/VM / CMS FILELIST or LISTFILE directory entry.
|
||||||
|
*/
|
||||||
|
public class CMSDirectoryEntry extends HostDirectoryEntry {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String filename;
|
||||||
|
private String filetype;
|
||||||
|
private String filemode;
|
||||||
|
private long numRecords;
|
||||||
|
private long numBlocks;
|
||||||
|
private String date;
|
||||||
|
private String time;
|
||||||
|
|
||||||
|
public CMSDirectoryEntry() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CMSDirectoryEntry(String filename, String filetype, String filemode) {
|
||||||
|
super((filename != null ? filename.trim() : "") + " " +
|
||||||
|
(filetype != null ? filetype.trim() : "") + " " +
|
||||||
|
(filemode != null ? filemode.trim() : "A"));
|
||||||
|
this.filename = filename;
|
||||||
|
this.filetype = filetype;
|
||||||
|
this.filemode = filemode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilename(String filename) {
|
||||||
|
this.filename = filename;
|
||||||
|
updateDatasetName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFiletype() {
|
||||||
|
return filetype;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFiletype(String filetype) {
|
||||||
|
this.filetype = filetype;
|
||||||
|
updateDatasetName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilemode() {
|
||||||
|
return filemode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilemode(String filemode) {
|
||||||
|
this.filemode = filemode;
|
||||||
|
updateDatasetName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDatasetName() {
|
||||||
|
this.name = (filename != null ? filename.trim() : "") + " " +
|
||||||
|
(filetype != null ? filetype.trim() : "") + " " +
|
||||||
|
(filemode != null ? filemode.trim() : "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getNumRecords() {
|
||||||
|
return numRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumRecords(long numRecords) {
|
||||||
|
this.numRecords = numRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getNumBlocks() {
|
||||||
|
return numBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumBlocks(long numBlocks) {
|
||||||
|
this.numBlocks = numBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDate() {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDate(String date) {
|
||||||
|
this.date = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTime() {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTime(String time) {
|
||||||
|
this.time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFormattedSize() {
|
||||||
|
if (numRecords > 0) {
|
||||||
|
return numRecords + " recs";
|
||||||
|
} else if (numBlocks > 0) {
|
||||||
|
return numBlocks + " blks";
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getLastModified() {
|
||||||
|
if (date != null && time != null) {
|
||||||
|
return date + " " + time;
|
||||||
|
} else if (date != null) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String formatListing() {
|
||||||
|
return String.format("%-8s %-8s %-2s %-4s %5d %7d %6d %-10s %-8s",
|
||||||
|
filename != null ? filename : "",
|
||||||
|
filetype != null ? filetype : "",
|
||||||
|
filemode != null ? filemode : "",
|
||||||
|
recfm != null ? recfm : "",
|
||||||
|
lrecl,
|
||||||
|
numRecords,
|
||||||
|
numBlocks,
|
||||||
|
date != null ? date : "",
|
||||||
|
time != null ? time : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser for z/VM CMS file listings (FILELIST, LISTFILE, EXECIO).
|
||||||
|
*/
|
||||||
|
public class CMSDirectoryParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw text containing CMS file listings into a structured list of CMSDirectoryEntry.
|
||||||
|
*/
|
||||||
|
public static List<CMSDirectoryEntry> parse(String text) {
|
||||||
|
List<CMSDirectoryEntry> entries = new ArrayList<>();
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] lines = text.split("\r?\n");
|
||||||
|
|
||||||
|
for (String line : lines) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty()) continue;
|
||||||
|
if (trimmed.startsWith("--") || trimmed.startsWith("==") || trimmed.startsWith("**")) continue;
|
||||||
|
|
||||||
|
String upper = trimmed.toUpperCase();
|
||||||
|
if (upper.startsWith("FILENAME") || upper.startsWith("DIRECTORY") || upper.startsWith("FILELIST")) continue;
|
||||||
|
|
||||||
|
CMSDirectoryEntry entry = parseCmsLine(trimmed);
|
||||||
|
if (entry != null) {
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CMSDirectoryEntry parseCmsLine(String line) {
|
||||||
|
String[] tokens = line.split("\\s+");
|
||||||
|
if (tokens.length < 2) return null;
|
||||||
|
|
||||||
|
int startIdx = 0;
|
||||||
|
// Check for EXEC prefix (e.g. "&1 &2")
|
||||||
|
if (tokens[0].startsWith("&") || tokens[0].equalsIgnoreCase("EXEC")) {
|
||||||
|
while (startIdx < tokens.length && (tokens[startIdx].startsWith("&") || tokens[startIdx].equalsIgnoreCase("EXEC"))) {
|
||||||
|
startIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIdx + 1 >= tokens.length) return null;
|
||||||
|
|
||||||
|
String fn = tokens[startIdx];
|
||||||
|
String ft = tokens[startIdx + 1];
|
||||||
|
|
||||||
|
// CMS filenames and filetypes are 1-8 chars alphanumeric
|
||||||
|
if (fn.length() > 8 || ft.length() > 8) return null;
|
||||||
|
|
||||||
|
String fm = (startIdx + 2 < tokens.length && tokens[startIdx + 2].length() <= 2) ? tokens[startIdx + 2] : "A1";
|
||||||
|
CMSDirectoryEntry entry = new CMSDirectoryEntry(fn, ft, fm);
|
||||||
|
|
||||||
|
int cur = startIdx + 3;
|
||||||
|
// Format (F or V)
|
||||||
|
if (cur < tokens.length && tokens[cur].matches("^(F|V|U)$")) {
|
||||||
|
entry.setRecfm(tokens[cur]);
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LRECL
|
||||||
|
if (cur < tokens.length && tokens[cur].matches("^\\d+$")) {
|
||||||
|
try { entry.setLrecl(Integer.parseInt(tokens[cur])); } catch (NumberFormatException ignored) {}
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RECS
|
||||||
|
if (cur < tokens.length && tokens[cur].matches("^\\d+$")) {
|
||||||
|
try { entry.setNumRecords(Long.parseLong(tokens[cur])); } catch (NumberFormatException ignored) {}
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BLOCKS
|
||||||
|
if (cur < tokens.length && tokens[cur].matches("^\\d+$")) {
|
||||||
|
try { entry.setNumBlocks(Long.parseLong(tokens[cur])); } catch (NumberFormatException ignored) {}
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATE
|
||||||
|
if (cur < tokens.length && (tokens[cur].contains("-") || tokens[cur].contains("/"))) {
|
||||||
|
entry.setDate(tokens[cur]);
|
||||||
|
cur++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIME
|
||||||
|
if (cur < tokens.length && tokens[cur].contains(":")) {
|
||||||
|
entry.setTime(tokens[cur]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for asynchronous host directory listing requests.
|
||||||
|
*/
|
||||||
|
public interface FileTransferHostDirectoryInterface {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when host directory entries have been successfully retrieved and parsed.
|
||||||
|
* @param entries List of parsed directory entries
|
||||||
|
*/
|
||||||
|
void onDirectoryLoaded(List<? extends HostDirectoryEntry> entries);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an error occurs during directory query or parsing.
|
||||||
|
* @param errorMessage Description of the error
|
||||||
|
*/
|
||||||
|
void onDirectoryError(String errorMessage);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base abstract representation of a file or dataset entry on a mainframe host.
|
||||||
|
*/
|
||||||
|
public abstract class HostDirectoryEntry implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
protected String name;
|
||||||
|
protected String recfm;
|
||||||
|
protected int lrecl;
|
||||||
|
protected int blksize;
|
||||||
|
|
||||||
|
public HostDirectoryEntry() {}
|
||||||
|
|
||||||
|
public HostDirectoryEntry(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDatasetName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecfm() {
|
||||||
|
return recfm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecfm(String recfm) {
|
||||||
|
this.recfm = recfm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getLrecl() {
|
||||||
|
return lrecl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLrecl(int lrecl) {
|
||||||
|
this.lrecl = lrecl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBlksize() {
|
||||||
|
return blksize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBlksize(int blksize) {
|
||||||
|
this.blksize = blksize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract String getFormattedSize();
|
||||||
|
|
||||||
|
public abstract String getLastModified();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a standardized formatted listing line suitable for display.
|
||||||
|
*/
|
||||||
|
public abstract String formatListing();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return formatListing();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of a z/OS / TSO dataset catalog or ISPF dataset list entry.
|
||||||
|
*/
|
||||||
|
public class TSODirectoryEntry extends HostDirectoryEntry {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String volume;
|
||||||
|
private String dsorg;
|
||||||
|
private int tracksAllocated;
|
||||||
|
private int tracksUsed;
|
||||||
|
private int percentUsed;
|
||||||
|
private int extents;
|
||||||
|
private String device;
|
||||||
|
private String creationDate;
|
||||||
|
private String referencedDate;
|
||||||
|
|
||||||
|
public TSODirectoryEntry() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TSODirectoryEntry(String dsname) {
|
||||||
|
super(dsname);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVolume() {
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVolume(String volume) {
|
||||||
|
this.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDsorg() {
|
||||||
|
return dsorg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDsorg(String dsorg) {
|
||||||
|
this.dsorg = dsorg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTracksAllocated() {
|
||||||
|
return tracksAllocated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTracksAllocated(int tracksAllocated) {
|
||||||
|
this.tracksAllocated = tracksAllocated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTracksUsed() {
|
||||||
|
return tracksUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTracksUsed(int tracksUsed) {
|
||||||
|
this.tracksUsed = tracksUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPercentUsed() {
|
||||||
|
return percentUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPercentUsed(int percentUsed) {
|
||||||
|
this.percentUsed = percentUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getExtents() {
|
||||||
|
return extents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExtents(int extents) {
|
||||||
|
this.extents = extents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDevice() {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDevice(String device) {
|
||||||
|
this.device = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCreationDate() {
|
||||||
|
return creationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreationDate(String creationDate) {
|
||||||
|
this.creationDate = creationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReferencedDate() {
|
||||||
|
return referencedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReferencedDate(String referencedDate) {
|
||||||
|
this.referencedDate = referencedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFormattedSize() {
|
||||||
|
if (tracksAllocated > 0) {
|
||||||
|
return tracksUsed > 0 ? (tracksUsed + "/" + tracksAllocated + " TRK") : (tracksAllocated + " TRK");
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getLastModified() {
|
||||||
|
return referencedDate != null && !referencedDate.isEmpty() ? referencedDate : creationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String formatListing() {
|
||||||
|
return String.format("%-44s %-6s %-4s %-4s %5d %5d %4d %-10s",
|
||||||
|
name != null ? name : "",
|
||||||
|
volume != null ? volume : "",
|
||||||
|
dsorg != null ? dsorg : "",
|
||||||
|
recfm != null ? recfm : "",
|
||||||
|
lrecl,
|
||||||
|
blksize,
|
||||||
|
tracksAllocated,
|
||||||
|
referencedDate != null ? referencedDate : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser for z/OS TSO dataset listings (ISPF DSLIST, LISTCAT, LISTDS).
|
||||||
|
*/
|
||||||
|
public class TSODirectoryParser {
|
||||||
|
|
||||||
|
private static final Pattern DSN_PATTERN = Pattern.compile("([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)");
|
||||||
|
private static final Pattern NONVSAM_PATTERN = Pattern.compile("NONVSAM\\s+-+\\s+([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern VOLSER_PATTERN = Pattern.compile("VOLSER-+([A-Z0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern RECFM_PATTERN = Pattern.compile("RECFM-+([A-Z]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern LRECL_PATTERN = Pattern.compile("LRECL-+([0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern BLKSIZE_PATTERN = Pattern.compile("BLKSIZE-+([0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern DSORG_PATTERN = Pattern.compile("DSORG-+([A-Z]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw text containing TSO dataset listings into a structured list of TSODirectoryEntry.
|
||||||
|
*/
|
||||||
|
public static List<TSODirectoryEntry> parse(String text) {
|
||||||
|
List<TSODirectoryEntry> entries = new ArrayList<>();
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] lines = text.split("\r?\n");
|
||||||
|
|
||||||
|
// Check if text is LISTCAT format
|
||||||
|
if (text.toUpperCase().contains("NONVSAM") || text.toUpperCase().contains("IN-CAT")) {
|
||||||
|
parseListcat(lines, entries);
|
||||||
|
if (!entries.isEmpty()) return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ISPF DSLIST or general tabular format
|
||||||
|
for (String line : lines) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty()) continue;
|
||||||
|
if (trimmed.startsWith("--") || trimmed.startsWith("==") || trimmed.startsWith("**")) continue;
|
||||||
|
if (trimmed.toUpperCase().startsWith("COMMAND") || trimmed.toUpperCase().startsWith("DSLIST") || trimmed.toUpperCase().startsWith("DATA SETS")) continue;
|
||||||
|
|
||||||
|
TSODirectoryEntry entry = parseTabularLine(trimmed);
|
||||||
|
if (entry != null) {
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void parseListcat(String[] lines, List<TSODirectoryEntry> entries) {
|
||||||
|
TSODirectoryEntry current = null;
|
||||||
|
for (String line : lines) {
|
||||||
|
String upper = line.toUpperCase();
|
||||||
|
Matcher nonvsamMat = NONVSAM_PATTERN.matcher(upper);
|
||||||
|
if (nonvsamMat.find()) {
|
||||||
|
if (current != null && current.getName() != null) {
|
||||||
|
entries.add(current);
|
||||||
|
}
|
||||||
|
current = new TSODirectoryEntry(nonvsamMat.group(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current != null) {
|
||||||
|
Matcher volMat = VOLSER_PATTERN.matcher(upper);
|
||||||
|
if (volMat.find()) current.setVolume(volMat.group(1));
|
||||||
|
|
||||||
|
Matcher recMat = RECFM_PATTERN.matcher(upper);
|
||||||
|
if (recMat.find()) current.setRecfm(recMat.group(1));
|
||||||
|
|
||||||
|
Matcher lreclMat = LRECL_PATTERN.matcher(upper);
|
||||||
|
if (lreclMat.find()) {
|
||||||
|
try { current.setLrecl(Integer.parseInt(lreclMat.group(1))); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher blkMat = BLKSIZE_PATTERN.matcher(upper);
|
||||||
|
if (blkMat.find()) {
|
||||||
|
try { current.setBlksize(Integer.parseInt(blkMat.group(1))); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher dsorgMat = DSORG_PATTERN.matcher(upper);
|
||||||
|
if (dsorgMat.find()) current.setDsorg(dsorgMat.group(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current != null && current.getName() != null) {
|
||||||
|
entries.add(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TSODirectoryEntry parseTabularLine(String line) {
|
||||||
|
String[] tokens = line.split("\\s+");
|
||||||
|
if (tokens.length == 0) return null;
|
||||||
|
|
||||||
|
// Find which token is the dataset name
|
||||||
|
int dsnIdx = -1;
|
||||||
|
for (int i = 0; i < tokens.length; i++) {
|
||||||
|
String tok = tokens[i];
|
||||||
|
if (DSN_PATTERN.matcher(tok.toUpperCase()).matches()) {
|
||||||
|
dsnIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dsnIdx < 0) return null;
|
||||||
|
|
||||||
|
TSODirectoryEntry entry = new TSODirectoryEntry(tokens[dsnIdx].toUpperCase());
|
||||||
|
boolean seenFormat = false;
|
||||||
|
|
||||||
|
// Parse remaining tokens
|
||||||
|
for (int i = 0; i < tokens.length; i++) {
|
||||||
|
if (i == dsnIdx) continue;
|
||||||
|
String tok = tokens[i].toUpperCase();
|
||||||
|
|
||||||
|
if (tok.matches("^(PS|PO|PO-E|VSAM|DA|IS)$")) {
|
||||||
|
entry.setDsorg(tok);
|
||||||
|
seenFormat = true;
|
||||||
|
} else if (tok.matches("^(F|FB|V|VB|U|VBS|FBS)$")) {
|
||||||
|
entry.setRecfm(tok);
|
||||||
|
seenFormat = true;
|
||||||
|
} else if (tok.matches("^(3390|3380|TAPE|VIO)$")) {
|
||||||
|
entry.setDevice(tok);
|
||||||
|
} else if (tok.matches("^\\d{4}/\\d{2}/\\d{2}$") || tok.matches("^\\d{2}/\\d{2}/\\d{2}$") ||
|
||||||
|
tok.matches("^\\d{4}-\\d{2}-\\d{2}$")) {
|
||||||
|
if (entry.getCreationDate() == null) {
|
||||||
|
entry.setCreationDate(tok);
|
||||||
|
} else {
|
||||||
|
entry.setReferencedDate(tok);
|
||||||
|
}
|
||||||
|
} else if (tok.matches("^[A-Z0-9]{6}$") && entry.getVolume() == null && !tok.matches("^\\d+$")) {
|
||||||
|
entry.setVolume(tok);
|
||||||
|
} else if (tok.matches("^\\d+$")) {
|
||||||
|
int val = Integer.parseInt(tok);
|
||||||
|
if (!seenFormat) {
|
||||||
|
if (entry.getTracksAllocated() == 0) {
|
||||||
|
entry.setTracksAllocated(val);
|
||||||
|
} else if (entry.getPercentUsed() == 0 && val <= 100) {
|
||||||
|
entry.setPercentUsed(val);
|
||||||
|
} else if (entry.getExtents() == 0 && val <= 128) {
|
||||||
|
entry.setExtents(val);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (entry.getLrecl() == 0 && val <= 32760) {
|
||||||
|
entry.setLrecl(val);
|
||||||
|
} else if (entry.getBlksize() == 0 && val <= 32760) {
|
||||||
|
entry.setBlksize(val);
|
||||||
|
} else if (entry.getTracksAllocated() == 0) {
|
||||||
|
entry.setTracksAllocated(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package haus.nightmare.lib3270j.ecl;
|
||||||
|
|
||||||
|
import haus.nightmare.lib3270j.ConnectionConfig;
|
||||||
|
import haus.nightmare.lib3270j.Telnet3270Client;
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel;
|
||||||
|
import haus.nightmare.lib3270j.ft.FTConstants;
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry;
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry;
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class ECLXferTest {
|
||||||
|
|
||||||
|
private Telnet3270Client client;
|
||||||
|
private ECLXfer xfer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
|
||||||
|
client = new Telnet3270Client(config);
|
||||||
|
xfer = client.getXfer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testXferInstanceAndMtu() {
|
||||||
|
assertNotNull(xfer);
|
||||||
|
assertEquals(FTConstants.DFT_BUF, xfer.getMTUSize());
|
||||||
|
|
||||||
|
xfer.setMTUSize(8192);
|
||||||
|
assertEquals(8192, xfer.getMTUSize());
|
||||||
|
assertFalse(xfer.isTransferActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testListenerRegistrationAndEvents() {
|
||||||
|
List<ECLXferEvent> receivedEvents = new ArrayList<>();
|
||||||
|
ECLXferListener listener = receivedEvents::add;
|
||||||
|
|
||||||
|
xfer.addXferListener(listener);
|
||||||
|
|
||||||
|
// Attempt SendFile with invalid local file / params
|
||||||
|
int rc = xfer.SendFile("", "TEST.DATA", "ASCII CRLF");
|
||||||
|
assertNotEquals(0, rc);
|
||||||
|
assertTrue(receivedEvents.size() > 0);
|
||||||
|
|
||||||
|
ECLXferEvent ev = receivedEvents.get(0);
|
||||||
|
assertEquals(ECLXferEvent.XFER_ABORTED, ev.getEventType());
|
||||||
|
assertEquals(FTConstants.ECL_ERR_XFER_INVALID_PARAM, ev.getErrorCode());
|
||||||
|
|
||||||
|
xfer.removeXferListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDirectoryParsingThroughEclXfer() {
|
||||||
|
String cmsText = "PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n";
|
||||||
|
List<CMSDirectoryEntry> cmsEntries = xfer.getCmsDirectory(cmsText);
|
||||||
|
assertEquals(1, cmsEntries.size());
|
||||||
|
assertEquals("PROFILE", cmsEntries.get(0).getFilename());
|
||||||
|
|
||||||
|
String tsoText = "TSOUSER.TEST.CNTL 15 50 1 3390 PO FB 80 3120 TSO001\n";
|
||||||
|
List<TSODirectoryEntry> tsoEntries = xfer.getTsoDirectory(tsoText);
|
||||||
|
assertEquals(1, tsoEntries.size());
|
||||||
|
assertEquals("TSOUSER.TEST.CNTL", tsoEntries.get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetFilesCallback() {
|
||||||
|
List<HostDirectoryEntry> fileList = new ArrayList<>();
|
||||||
|
boolean[] loaded = new boolean[1];
|
||||||
|
|
||||||
|
xfer.getFiles("PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n", fileList, new haus.nightmare.lib3270j.ft.dir.FileTransferHostDirectoryInterface() {
|
||||||
|
@Override
|
||||||
|
public void onDirectoryLoaded(List<? extends HostDirectoryEntry> entries) {
|
||||||
|
loaded[0] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDirectoryError(String errorMessage) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(loaded[0]);
|
||||||
|
assertEquals(1, fileList.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft;
|
||||||
|
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel;
|
||||||
|
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
|
||||||
|
import haus.nightmare.lib3270j.input.InputProcessor;
|
||||||
|
import haus.nightmare.lib3270j.screen.ScreenBuffer;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static haus.nightmare.lib3270j.ft.FTConstants.*;
|
||||||
|
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class FTPhase3Test {
|
||||||
|
|
||||||
|
private EbcdicTranslator translator;
|
||||||
|
private ScreenBuffer screen;
|
||||||
|
private InputProcessor inputProcessor;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
translator = new EbcdicTranslator();
|
||||||
|
screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
|
||||||
|
inputProcessor = new InputProcessor(screen, translator, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTsoDatasetValidationRules() {
|
||||||
|
// Valid unqualified dataset
|
||||||
|
assertNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA"));
|
||||||
|
// Valid fully qualified dataset
|
||||||
|
assertNull(FTConfig.validateTsoDatasetName("'USER.TEST.DATA'"));
|
||||||
|
// Valid PDS member
|
||||||
|
assertNull(FTConfig.validateTsoDatasetName("USER.TEST.CNTL(MEMBER1)"));
|
||||||
|
// Valid national characters
|
||||||
|
assertNull(FTConfig.validateTsoDatasetName("'USER.TEST.@#$'"));
|
||||||
|
|
||||||
|
// Invalid: missing/empty
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName(""));
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName(null));
|
||||||
|
// Invalid: mismatched quote
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName("'USER.TEST.DATA"));
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA'"));
|
||||||
|
// Invalid: qualifier begins with numeric
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName("USER.123TEST.DATA"));
|
||||||
|
// Invalid: qualifier too long (> 8 chars)
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName("USER.VERYLONGQUALIFIER.DATA"));
|
||||||
|
// Invalid: member too long
|
||||||
|
assertNotNull(FTConfig.validateTsoDatasetName("USER.TEST.DATA(VERYLONGMEMBERNAME)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCmsFilenameValidationRules() {
|
||||||
|
assertNull(FTConfig.validateCmsFilename("PROFILE EXEC"));
|
||||||
|
assertNull(FTConfig.validateCmsFilename("PROFILE EXEC A1"));
|
||||||
|
assertNull(FTConfig.validateCmsFilename("GENPASS REXX A"));
|
||||||
|
|
||||||
|
// Invalid: single token
|
||||||
|
assertNotNull(FTConfig.validateCmsFilename("PROFILE"));
|
||||||
|
// Invalid: filename > 8 chars
|
||||||
|
assertNotNull(FTConfig.validateCmsFilename("LONGNAME123 EXEC A"));
|
||||||
|
// Invalid: filetype > 8 chars
|
||||||
|
assertNotNull(FTConfig.validateCmsFilename("TEST LONGTYPE123 A"));
|
||||||
|
// Invalid: filemode > 2 chars
|
||||||
|
assertNotNull(FTConfig.validateCmsFilename("TEST DATA A123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMtuClampingAndCommandBuilding() {
|
||||||
|
FTConfig config = new FTConfig();
|
||||||
|
config.setDftBufferSize(100);
|
||||||
|
assertEquals(DFT_MIN_BUF, config.getDftBufferSize()); // clamped to 256
|
||||||
|
|
||||||
|
config.setDftBufferSize(50000);
|
||||||
|
assertEquals(DFT_MAX_BUF, config.getDftBufferSize()); // clamped to 32768
|
||||||
|
|
||||||
|
config.setHostType(FTConfig.HostType.CMS);
|
||||||
|
config.setDirection(FTConfig.Direction.SEND);
|
||||||
|
config.setHostFilename("TEST DATA A");
|
||||||
|
config.setOverwrite(true);
|
||||||
|
config.setBlksize(4096);
|
||||||
|
config.setRecfm("F");
|
||||||
|
config.setLrecl(80);
|
||||||
|
|
||||||
|
String cmd = config.buildCommand();
|
||||||
|
assertTrue(cmd.contains("REPLACE"));
|
||||||
|
assertTrue(cmd.contains("BLOCK 4096"));
|
||||||
|
assertTrue(cmd.contains("RECFM F"));
|
||||||
|
assertTrue(cmd.contains("LRECL 80"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCutRetransmitRecovery() {
|
||||||
|
FTCutTest.TestFTCutListener listener = new FTCutTest.TestFTCutListener();
|
||||||
|
FTConfig config = new FTConfig();
|
||||||
|
config.setDirection(FTConfig.Direction.SEND);
|
||||||
|
config.setHostType(FTConfig.HostType.CMS);
|
||||||
|
config.setHostFilename("TEST FILE A");
|
||||||
|
config.setTransferMode(FTConfig.TransferMode.ASCII);
|
||||||
|
config.setCrAction(FTConfig.CrAction.REMOVE);
|
||||||
|
listener.config = config;
|
||||||
|
|
||||||
|
FTCut ftCut = new FTCut(screen, inputProcessor, translator, listener);
|
||||||
|
|
||||||
|
ByteArrayInputStream in = new ByteArrayInputStream("DATA LINE\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
ftCut.initTransfer(in, null);
|
||||||
|
|
||||||
|
// 1. Host requests data
|
||||||
|
screen.setCellFA(O_SF, (byte) (FA_PROTECT | FA_NUMERIC));
|
||||||
|
screen.setCell(O_FRAME_TYPE, FT_DATA_REQUEST);
|
||||||
|
screen.setCell(O_DR_FRAME_SEQ, FTConstants.to6(0, translator));
|
||||||
|
ftCut.processScreenUpdate();
|
||||||
|
|
||||||
|
assertEquals(0, ftCut.getRetryCount());
|
||||||
|
|
||||||
|
// 2. Host sends RETRANSMIT frame (0x4C)
|
||||||
|
screen.setCell(O_FRAME_TYPE, FT_RETRANSMIT);
|
||||||
|
ftCut.processScreenUpdate();
|
||||||
|
|
||||||
|
assertEquals(1, ftCut.getRetryCount());
|
||||||
|
assertTrue(ftCut.isTransferActive());
|
||||||
|
|
||||||
|
// 3. Complete transfer
|
||||||
|
screen.setCell(O_FRAME_TYPE, FT_CONTROL_CODE);
|
||||||
|
screen.setCell(O_CC_STATUS_CODE, (SC_XFER_COMPLETE >> 8) & 0xFF);
|
||||||
|
screen.setCell(O_CC_STATUS_CODE + 1, SC_XFER_COMPLETE & 0xFF);
|
||||||
|
ftCut.processScreenUpdate();
|
||||||
|
|
||||||
|
assertTrue(listener.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDftDdmAttributeParsing() {
|
||||||
|
FTDftTest.TestInputProcessor ip = new FTDftTest.TestInputProcessor(screen, translator);
|
||||||
|
FTDftTest.TestFTDftListener listener = new FTDftTest.TestFTDftListener();
|
||||||
|
FTConfig config = new FTConfig();
|
||||||
|
config.setDirection(FTConfig.Direction.RECEIVE);
|
||||||
|
listener.config = config;
|
||||||
|
|
||||||
|
FTDft dft = new FTDft(ip, translator, listener);
|
||||||
|
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||||
|
dft.initTransfer(null, outStream);
|
||||||
|
|
||||||
|
// Craft Open Request with DDM attributes: LRECL=80, RECFM=F (1), BLKSIZE=3120, FILESIZE=12345
|
||||||
|
ByteArrayOutputStream sf = new ByteArrayOutputStream();
|
||||||
|
sf.write(0); sf.write(0); // placeholder length
|
||||||
|
sf.write(FTConstants.SF_TRANSFER_DATA);
|
||||||
|
sf.write((TR_OPEN_REQ >> 8) & 0xFF);
|
||||||
|
sf.write(TR_OPEN_REQ & 0xFF);
|
||||||
|
|
||||||
|
// LRECL (0x01, len=5, val=80)
|
||||||
|
sf.write(DDM_HDR_LRECL); sf.write(0); sf.write(5); sf.write(0); sf.write(80);
|
||||||
|
// RECFM (0x02, len=4, val=1)
|
||||||
|
sf.write(DDM_HDR_RECFM); sf.write(0); sf.write(4); sf.write(1);
|
||||||
|
// BLKSIZE (0x03, len=5, val=3120)
|
||||||
|
sf.write(DDM_HDR_BLKSIZE); sf.write(0); sf.write(5); sf.write((3120 >> 8) & 0xFF); sf.write(3120 & 0xFF);
|
||||||
|
// FILESIZE (0x04, len=7, val=12345)
|
||||||
|
sf.write(DDM_HDR_FILESIZE); sf.write(0); sf.write(7);
|
||||||
|
sf.write(0); sf.write(0); sf.write((12345 >> 8) & 0xFF); sf.write(12345 & 0xFF);
|
||||||
|
|
||||||
|
byte[] raw = sf.toByteArray();
|
||||||
|
raw[0] = (byte) ((raw.length >> 8) & 0xFF);
|
||||||
|
raw[1] = (byte) (raw.length & 0xFF);
|
||||||
|
|
||||||
|
dft.processStructuredField(raw, 0, raw.length);
|
||||||
|
|
||||||
|
assertEquals(80, dft.getHostLrecl());
|
||||||
|
assertEquals("F", dft.getHostRecfm());
|
||||||
|
assertEquals(3120, dft.getHostBlksize());
|
||||||
|
assertEquals(12345, dft.getEstimatedTotalBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package haus.nightmare.lib3270j.ft.dir;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.util.List;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class DirectoryParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTsoIspfDslistParsing() {
|
||||||
|
String ispfOutput =
|
||||||
|
"Command ===> Scroll ===> PAGE\n" +
|
||||||
|
"Dslist - Data Sets Matching TSOUSER.* Row 1 of 4\n" +
|
||||||
|
"Command - Enter \"/\" to select action Tracks %Used XT Device Dsorg Recfm Lrecl Blksz Volume\n" +
|
||||||
|
"-------------------------------------------------------------------------------------------------------\n" +
|
||||||
|
" TSOUSER.TEST.CNTL 15 50 1 3390 PO FB 80 3120 TSO001\n" +
|
||||||
|
" TSOUSER.TEST.COBOL 30 80 2 3390 PO FB 80 6160 TSO002\n" +
|
||||||
|
" TSOUSER.TEST.DATA 10 100 1 3390 PS FB 80 3120 TSO001\n" +
|
||||||
|
" TSOUSER.TEST.LOAD 45 60 3 3390 PO-E U 0 6144 TSO003\n";
|
||||||
|
|
||||||
|
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(ispfOutput);
|
||||||
|
assertEquals(4, entries.size());
|
||||||
|
|
||||||
|
TSODirectoryEntry e0 = entries.get(0);
|
||||||
|
assertEquals("TSOUSER.TEST.CNTL", e0.getName());
|
||||||
|
assertEquals("TSO001", e0.getVolume());
|
||||||
|
assertEquals("PO", e0.getDsorg());
|
||||||
|
assertEquals("FB", e0.getRecfm());
|
||||||
|
assertEquals(80, e0.getLrecl());
|
||||||
|
assertEquals(3120, e0.getBlksize());
|
||||||
|
assertEquals(15, e0.getTracksAllocated());
|
||||||
|
|
||||||
|
TSODirectoryEntry e2 = entries.get(2);
|
||||||
|
assertEquals("TSOUSER.TEST.DATA", e2.getName());
|
||||||
|
assertEquals("PS", e2.getDsorg());
|
||||||
|
assertEquals("FB", e2.getRecfm());
|
||||||
|
|
||||||
|
assertNotNull(e0.formatListing());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTsoListcatParsing() {
|
||||||
|
String listcatOutput =
|
||||||
|
"IDCAMS SYSTEM SERVICES TIME: 12:00:00\n" +
|
||||||
|
"NONVSAM ------- TSOUSER.SAMPLE.DATA\n" +
|
||||||
|
" IN-CAT --- CATALOG.USER\n" +
|
||||||
|
" HISTORY\n" +
|
||||||
|
" RELEASE----------------2\n" +
|
||||||
|
" VOLUMES\n" +
|
||||||
|
" VOLSER------------TSO005 DEVTYPE------X'3010200F' FSEQN------------------0\n" +
|
||||||
|
" ATTRIBUTES\n" +
|
||||||
|
" RECFM------------FB LRECL-----------------80 BLKSIZE-------------3120\n" +
|
||||||
|
" DSORG------------PS ALLOC-PRM-------------15 ALLOC-SEC--------------5\n" +
|
||||||
|
"NONVSAM ------- TSOUSER.OTHER.DATA\n" +
|
||||||
|
" VOLUMES\n" +
|
||||||
|
" VOLSER------------TSO006\n" +
|
||||||
|
" ATTRIBUTES\n" +
|
||||||
|
" RECFM------------VB LRECL----------------255 BLKSIZE-------------6144\n" +
|
||||||
|
" DSORG------------PS\n";
|
||||||
|
|
||||||
|
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(listcatOutput);
|
||||||
|
assertEquals(2, entries.size());
|
||||||
|
|
||||||
|
TSODirectoryEntry e0 = entries.get(0);
|
||||||
|
assertEquals("TSOUSER.SAMPLE.DATA", e0.getName());
|
||||||
|
assertEquals("TSO005", e0.getVolume());
|
||||||
|
assertEquals("FB", e0.getRecfm());
|
||||||
|
assertEquals(80, e0.getLrecl());
|
||||||
|
assertEquals(3120, e0.getBlksize());
|
||||||
|
assertEquals("PS", e0.getDsorg());
|
||||||
|
|
||||||
|
TSODirectoryEntry e1 = entries.get(1);
|
||||||
|
assertEquals("TSOUSER.OTHER.DATA", e1.getName());
|
||||||
|
assertEquals("TSO006", e1.getVolume());
|
||||||
|
assertEquals("VB", e1.getRecfm());
|
||||||
|
assertEquals(255, e1.getLrecl());
|
||||||
|
assertEquals(6144, e1.getBlksize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCmsFileListParsing() {
|
||||||
|
String cmsFileList =
|
||||||
|
"FILENAME FILETYPE FM FORMAT LRECL RECS BLOCKS DATE TIME\n" +
|
||||||
|
"PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01\n" +
|
||||||
|
"GENPASS REXX A1 V 80 500 3 2026-08-28 11:00:30\n" +
|
||||||
|
"TEST DATA A1 F 80 100 2 2026-08-01 09:15:00\n";
|
||||||
|
|
||||||
|
List<CMSDirectoryEntry> entries = CMSDirectoryParser.parse(cmsFileList);
|
||||||
|
assertEquals(3, entries.size());
|
||||||
|
|
||||||
|
CMSDirectoryEntry e0 = entries.get(0);
|
||||||
|
assertEquals("PROFILE", e0.getFilename());
|
||||||
|
assertEquals("EXEC", e0.getFiletype());
|
||||||
|
assertEquals("A1", e0.getFilemode());
|
||||||
|
assertEquals("V", e0.getRecfm());
|
||||||
|
assertEquals(80, e0.getLrecl());
|
||||||
|
assertEquals(25, e0.getNumRecords());
|
||||||
|
assertEquals(1, e0.getNumBlocks());
|
||||||
|
assertEquals("2026-05-10", e0.getDate());
|
||||||
|
assertEquals("14:22:01", e0.getTime());
|
||||||
|
assertEquals("PROFILE EXEC A1", e0.getName());
|
||||||
|
|
||||||
|
CMSDirectoryEntry e1 = entries.get(1);
|
||||||
|
assertEquals("GENPASS", e1.getFilename());
|
||||||
|
assertEquals("REXX", e1.getFiletype());
|
||||||
|
assertEquals(500, e1.getNumRecords());
|
||||||
|
|
||||||
|
assertNotNull(e0.formatListing());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCmsListfileExecParsing() {
|
||||||
|
String execList =
|
||||||
|
"&1 &2 PROFILE EXEC A1\n" +
|
||||||
|
"&1 &2 GENPASS REXX A1\n";
|
||||||
|
|
||||||
|
List<CMSDirectoryEntry> entries = CMSDirectoryParser.parse(execList);
|
||||||
|
assertEquals(2, entries.size());
|
||||||
|
assertEquals("PROFILE", entries.get(0).getFilename());
|
||||||
|
assertEquals("EXEC", entries.get(0).getFiletype());
|
||||||
|
assertEquals("GENPASS", entries.get(1).getFilename());
|
||||||
|
assertEquals("REXX", entries.get(1).getFiletype());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user