Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ede0868
Add initial Bidi support with configurable CCSID conversion
yuvalnn Jul 25, 2025
3b2f2b7
Add Bidi support to include member upload
yuvalnn Jul 26, 2025
68fd689
Update Bidi setting descriptions to clarify they are disabled when So…
yuvalnn Jul 26, 2025
2cf82bb
Merge branch 'master' into bidi-support
yuvalnn Aug 2, 2025
d7b7870
test(bidi): add synchronous test suite in encoding.test.ts
Itsanexpriment Aug 16, 2025
50dbcd7
feat(bidi): add source date support for bidi-compatible CCSID (#2)
Itsanexpriment Oct 19, 2025
42e41b6
Merge remote-tracking branch 'upstream/master' into bidi-support
yuvalnn Oct 19, 2025
499b202
feat(settings): introduce configurable CCSID conversion (source/targe…
yuvalnn Jan 3, 2026
dc1800a
fix(settings): add default option and fix CCSID selection persistence
yuvalnn Jan 10, 2026
04b0578
Merge remote-tracking branch 'upstream/master' into bidi-support
yuvalnn Jan 10, 2026
c868a78
Merge remote-tracking branch 'upstream/master' into bidi-support
yuvalnn Jan 21, 2026
b8acf1e
test: add tests for non-BiDi source files with BiDi support enabled
yuvalnn Feb 6, 2026
7cac2e0
Merge branch 'ccsid-conversion-settings' into bidi-support
yuvalnn Feb 6, 2026
d13c845
feat: Add CCSID validation before conversion
yuvalnn Feb 6, 2026
d538f0c
test: enhance non-BiDi tests with edge case for mismatched CCSID
yuvalnn Feb 7, 2026
402a91e
Merge remote-tracking branch 'upstream/master' into bidi-support
yuvalnn Feb 7, 2026
b056541
test: restore BiDi and Non-BiDi encoding tests after merge
yuvalnn Feb 7, 2026
c036b79
fix: run CCSID conversion commands separately to avoid job context is…
yuvalnn Feb 7, 2026
071f968
improve error handling in uploadMemberContent and update tests
yuvalnn Feb 11, 2026
966d3a7
refactor: use single await with array for SQL commands in download/up…
yuvalnn Feb 12, 2026
1899834
Cache file CCSID to avoid redundant look‑ups
Itsanexpriment Feb 23, 2026
efcac9e
Change return value of determineCcsidConversion from an array to an o…
Itsanexpriment Feb 23, 2026
b872ed2
Set default values to CCSID conversion fields
Itsanexpriment Feb 23, 2026
e98228b
Merge upstream/master into bidi-support
yuvalnn Mar 24, 2026
541c778
Merge helper PR: Add CCSID caching and address reviewer comments
yuvalnn Mar 24, 2026
2c9a24d
refactor(bidi-support): simplify SQL statement execution logic
yuvalnn Mar 24, 2026
b559531
refactor: Extract CCSID data to separate file and simplify filtering
yuvalnn Mar 25, 2026
c387fb8
Improve CCSID cache type safety: use Map<string, CacheItem<number>>
yuvalnn Mar 26, 2026
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
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

244 changes: 244 additions & 0 deletions src/api/CCSIDs.ts

Large diffs are not rendered by default.

49 changes: 48 additions & 1 deletion src/api/IBMi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ConnectionManager } from './configuration/config/ConnectionManager';
import { ConnectionConfig, RemoteConfigFile } from './configuration/config/types';
import { ConfigFile } from './configuration/serverFile';
import { CachedServerSettings, CodeForIStorage } from './configuration/storage/CodeForIStorage';
import { AspInfo, CommandData, CommandResult, ConnectionData, EditorPath, IBMiMember, RemoteCommand } from './types';
import { AspInfo, CommandData, CommandResult, ConnectionData, EditorPath, IBMiMember, RemoteCommand, QsysPath, CacheItem } from './types';

export interface MemberParts extends IBMiMember {
basename: string
Expand Down Expand Up @@ -147,6 +147,8 @@ export default class IBMi {
//Maximum admited length for command's argument - any command whose arguments are longer than this won't be executed by the shell
maximumArgsLength = 0;

ccsidCache: Map<string, CacheItem<number>> = new Map();

public appendOutput: (text: string) => void = (text) => {
process.stdout.write(text);
};
Expand Down Expand Up @@ -1520,6 +1522,51 @@ export default class IBMi {
}
}

async getFileCcsid(path: string | QsysPath): Promise<number> {
const minute = 60 * 1000;
const timeToLive = 10 * minute;

const isQsysPath = typeof path === `object`;
let fileObjPath: string;

if (isQsysPath) {
const localPath: QsysPath = path;
localPath.asp = localPath.asp ? this.sysNameInAmerican(localPath.asp) : undefined;
localPath.library = this.sysNameInAmerican(localPath.library);
localPath.name = this.sysNameInAmerican(localPath.name);
fileObjPath = Tools.qualifyPath(localPath.library, localPath.name, '', localPath.asp || '', true);
} else {
fileObjPath = path.replace(/\/[^/]+\.MBR$/i, ''); // strip the member from path
}

const cached = this.ccsidCache.get(fileObjPath);
if (cached) {
if (!cached.createdAt || cached.createdAt + timeToLive >= Date.now()) {
// cached ccsid still valid
return cached.value;
}
// clear the stale cached ccsid
this.ccsidCache.delete(fileObjPath);
}

// Take {DOES_THIS_WORK: `YESITDOES`} away, and all of a sudden names with # aren't found.
const result = await this.sendCommand({ command: `${this.remoteFeatures.attr} -p "${fileObjPath}" CCSID`, env: { DOES_THIS_WORK: `YESITDOES` } });

if (result.code === 0) {
const pieces = result.stdout.split('=');
if (pieces.length === 2) {
const ccsid = Number(pieces[1]) || 0;
if (ccsid !== 0) {
// save result to cache
this.ccsidCache.set(fileObjPath, { value: ccsid, createdAt: Date.now() })
return ccsid;
}
}
}

return 0;
}

getLibraryIAsp(library: string) {
const found = this.libraryAsps.get(library);
if (found && found >= 0) {
Expand Down
77 changes: 72 additions & 5 deletions src/api/IBMiContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,27 @@ export default class IBMiContent {
const path = Tools.qualifyPath(library, sourceFile, member, asp, true);
const tempRmt = this.getTempRemote(path);
let retry = false;
while (true) {

// Fetch source file CCSID and determine if conversion is needed
const sourceCcsid = await this.ibmi.getFileCcsid(path);
const {requiresConversion, targetCcsid} = Tools.determineCcsidConversion(sourceCcsid, this.config);

while (true) {
let copyResult: CommandResult;
if (this.ibmi.dangerousVariants && new RegExp(`[${this.ibmi.variantChars.local}]`).test(path)) {
copyResult = { code: 0, stdout: '', stderr: '' };
if (requiresConversion) {
await this.ibmi.runSQL(`@QSYS/RMVLNK OBJLNK('${tempRmt}')`).catch(_ => {}); // ignore error
await this.ibmi.runSQL([
`Drop table if exists QTEMP.QTEMPSRC`,
`@QSYS/CPYF FROMFILE(${library}/${sourceFile}) TOFILE(QTEMP/QTEMPSRC) FROMMBR(${member}) TOMBR(TEMPMEMBER) MBROPT(*REPLACE) CRTFILE(*YES)`,
`@QSYS/CPYTOSTMF FROMMBR('${Tools.qualifyPath("QTEMP", "QTEMPSRC", "TEMPMEMBER", undefined)}') TOSTMF('${tempRmt}') STMFOPT(*REPLACE) STMFCCSID(${targetCcsid}) DBFCCSID(${this.config.sourceFileCCSID})`,
`@QSYS/CPY OBJ('${tempRmt}') TOOBJ('${tempRmt}') TOCCSID(1208) DTAFMT(*TEXT) REPLACE(*YES)`
].join("\n")).catch(e => {
copyResult.code = -1;
copyResult.stderr = String(e);
});
} else {
try {
await this.ibmi.runSQL([
`Drop table if exists QTEMP.QTEMPSRC`,
Expand All @@ -165,12 +182,28 @@ export default class IBMiContent {
copyResult.code = -1;
copyResult.stderr = String(error);
}
}
}
else {
copyResult = await this.ibmi.runCommand({
command: `QSYS/CPYTOSTMF FROMMBR('${path}') TOSTMF('${tempRmt}') STMFOPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${this.config.sourceFileCCSID})`,
noLibList: true
});
if (requiresConversion) {
copyResult = { code: 0, stdout: '', stderr: '' };

await this.ibmi.runSQL(`@QSYS/RMVLNK OBJLNK('${tempRmt}')`).catch(_ => {}); // ignore error
try {
await this.ibmi.runSQL([
`@QSYS/CPYTOSTMF FROMMBR('${path}') TOSTMF('${tempRmt}') STMFOPT(*REPLACE) STMFCCSID(${targetCcsid}) DBFCCSID(${this.config.sourceFileCCSID})`,
`@QSYS/CPY OBJ('${tempRmt}') TOOBJ('${tempRmt}') TOCCSID(1208) DTAFMT(*TEXT) REPLACE(*YES)`
]);
} catch (e: any) {
copyResult.code = -1;
copyResult.stderr = String(e);
}
} else {
copyResult = await this.ibmi.runCommand({
command: `QSYS/CPYTOSTMF FROMMBR('${path}') TOSTMF('${tempRmt}') STMFOPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${this.config.sourceFileCCSID})`,
noLibList: true
});
}
}

if (copyResult.code === 0) {
Expand Down Expand Up @@ -221,6 +254,10 @@ export default class IBMiContent {
try {
await writeFileAsync(tmpobj, content, `utf8`);
const path = Tools.qualifyPath(library, sourceFile, member, asp, true);
// Fetch source file CCSID and determine if conversion is needed
const sourceCcsid = await this.ibmi.getFileCcsid(path);
const {requiresConversion, targetCcsid} = Tools.determineCcsidConversion(sourceCcsid, this.config);

const tempRmt = this.getTempRemote(path);

const touchUnicode = await this.ibmi.sendCommand({ command: `touch ${tempRmt} && attr ${tempRmt} CCSID=1208` });
Expand All @@ -232,6 +269,21 @@ export default class IBMiContent {
let copyResult: CommandResult;
if (this.ibmi.dangerousVariants && new RegExp(`[${this.ibmi.variantChars.local}]`).test(path)) {
copyResult = { code: 0, stdout: '', stderr: '' };

if (requiresConversion) {
try {
await this.ibmi.runSQL([
`@QSYS/CPYF FROMFILE(${library}/${sourceFile}) TOFILE(QTEMP/QTEMPSRC) FROMMBR(${member}) TOMBR(TEMPMEMBER) MBROPT(*REPLACE) CRTFILE(*YES)`,
`@QSYS/CPY OBJ('${tempRmt}') TOOBJ('${tempRmt}') TOCCSID(${targetCcsid}) DTAFMT(*TEXT) REPLACE(*YES)`,
`@QSYS/CPYFRMSTMF FROMSTMF('${tempRmt}') TOMBR('${Tools.qualifyPath("QTEMP", "QTEMPSRC", "TEMPMEMBER", undefined)}') MBROPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${this.config.sourceFileCCSID})`,
`@QSYS/CPYF FROMFILE(QTEMP/QTEMPSRC) FROMMBR(TEMPMEMBER) TOFILE(${library}/${sourceFile}) TOMBR(${member}) MBROPT(*REPLACE)`,
`@CHGATR OBJ('${tempRmt}') ATR(*CCSID) VALUE(1208)`
].join("\n"));
} catch (e: any) {
copyResult.code = -1;
copyResult.stderr = String(e);
}
} else {
try {
await this.ibmi.runSQL([
`Drop table if exists QTEMP.QTEMPSRC`,
Expand All @@ -244,12 +296,27 @@ export default class IBMiContent {
copyResult.stderr = String(error);
}
}
}
else {
if (requiresConversion) {
copyResult = { code: 0, stdout: '', stderr: '' };
try {
await this.ibmi.runSQL([
`@QSYS/CPY OBJ('${tempRmt}') TOOBJ('${tempRmt}') TOCCSID(${targetCcsid}) DTAFMT(*TEXT) REPLACE(*YES)`,
`@QSYS/CPYFRMSTMF FROMSTMF('${tempRmt}') TOMBR('${path}') MBROPT(*REPLACE) STMFCCSID(${targetCcsid}) DBFCCSID(${this.config.sourceFileCCSID})`,
`@CHGATR OBJ('${tempRmt}') ATR(*CCSID) VALUE(1208)`
]);
} catch (e: any) {
copyResult.code = -1;
copyResult.stderr = String(e);
}
} else {
copyResult = await this.ibmi.runCommand({
command: `QSYS/CPYFRMSTMF FROMSTMF('${tempRmt}') TOMBR('${path}') MBROPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${this.config.sourceFileCCSID})`,
noLibList: true
});
}
}

if (copyResult.code === 0) {
const messages = Tools.parseMessages(copyResult.stderr);
Expand Down
30 changes: 30 additions & 0 deletions src/api/Tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,34 @@ export namespace Tools {
path = path.replace("~", os.homedir());
return process.platform === "win32" ? path.replaceAll('/', '\\') : path;
}

/**
* Determines if CCSID conversion should be applied based on configuration and source file CCSID.
* @param sourceCcsid The CCSID of the source file
* @param config Connection configuration containing conversion settings
* @returns {requiresConversion, targetCcsid} - whether conversion is required and the target CCSID to use
*/
export function determineCcsidConversion(sourceCcsid: number, config: { ccsidConversionEnabled?: boolean, ccsidConvertFrom?: string, ccsidConvertTo?: string }): { requiresConversion: boolean, targetCcsid: number } {
const noConversion = { requiresConversion: false, targetCcsid: 0 };

// conversion must be enabled
if (!config.ccsidConversionEnabled) {
return noConversion;
}

const configuredSourceCcsid = Number(config.ccsidConvertFrom) || 0;
const configuredTargetCcsid = Number(config.ccsidConvertTo) || 0;

// both source and target must be configured
if (configuredSourceCcsid === 0 || configuredTargetCcsid === 0) {
return noConversion;
}

// source CCSID must match configured value
if (sourceCcsid != configuredSourceCcsid) {
return noConversion;
}

return { requiresConversion: true, targetCcsid: configuredTargetCcsid };
}
Comment on lines +191 to +219
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return an object instead of an array here. An object directly gives a view of what is being returned by the function. An array doesn't and forces whoever uses the function to look at its description to figure out what's returned.

The return type should be:

{ requiresConversion: boolean, targetCcsid: number }

}
3 changes: 3 additions & 0 deletions src/api/configuration/config/ConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ function initialize(parameters: Partial<ConnectionConfig>): ConnectionConfig {
secureSQL: (parameters.secureSQL === true),
keepActionSpooledFiles: (parameters.keepActionSpooledFiles === true),
mapepireJavaVersion: (parameters.mapepireJavaVersion || "default"),
ccsidConversionEnabled: (parameters.ccsidConversionEnabled === true),
ccsidConvertFrom: parameters.ccsidConvertFrom || ``,
ccsidConvertTo: parameters.ccsidConvertTo || ``,
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/api/configuration/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface ConnectionConfig extends ConnectionProfile {
hideCompileErrors: string[];
enableSourceDates: boolean;
sourceDateGutter: boolean;
ccsidConversionEnabled: boolean;
ccsidConvertFrom: string;
ccsidConvertTo: string;
Comment on lines +19 to +21
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default values for these three fields must be provided here (ince it's missing, it's producing an error):

encodingFor5250: string;
terminalFor5250: string;
setDeviceNameFor5250: boolean;
Expand Down
Loading