From b12c1245e97ed9cce34506fd9623e231a5c7a211 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 3 Jun 2026 14:07:20 +0200 Subject: [PATCH 1/2] Implement the import/export tool for twinpack/proj projects. --- scripts/impexp.mjs | 340 ++++++++++++++++++++++++++++++++++++ scripts/impexp.py | 417 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 scripts/impexp.mjs create mode 100644 scripts/impexp.py diff --git a/scripts/impexp.mjs b/scripts/impexp.mjs new file mode 100644 index 00000000..9ecc16a0 --- /dev/null +++ b/scripts/impexp.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// +// Copyright (c) 2026 TWINBASIC LTD +// SPDX-License-Identifier: MIT +// +// impexp.mjs -- standalone twinpack/twinproj import/export tool. +// No external dependencies; requires Node.js 18+. +// +// Usage: +// node impexp.mjs import [output_dir] +// node impexp.mjs export +// node impexp.mjs --self-test + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const MAGIC = 0xEA0BA51C; + +const DIR_MARK2 = { + Resources: 0x02, Sources: 0x03, ImportedTypeLibraries: 0x05, + Miscellaneous: 0x06, Packages: 0x07, +}; + +// -------------------------- Parser (binary -> tree) -------------------------- + +function parse(buffer) { + const buf = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const decoder = new TextDecoder('utf-8'); + let pos = 0; + let entryCount = 0; + + function readU32() { const v = view.getUint32(pos, true); pos += 4; return v; } + function readU16() { const v = view.getUint16(pos, true); pos += 2; return v; } + function readI16() { const v = view.getInt16(pos, true); pos += 2; return v; } + function readU8() { const v = view.getUint8(pos); pos += 1; return v; } + function readStr() { + const len = readU32(); + if (len === 0) return ''; + const s = decoder.decode(buf.subarray(pos, pos + len)); + pos += len; + return s; + } + function readBlob() { + const len = readU32(); + const b = Buffer.from(buf.subarray(pos, pos + len)); + pos += len; + return b; + } + + const magic = readU32(); + if (magic !== MAGIC) + throw new Error( + `Bad magic: 0x${magic.toString(16).padStart(8, '0').toUpperCase()}, ` + + `expected 0x${MAGIC.toString(16).padStart(8, '0').toUpperCase()}`); + + function readEntry() { + const kind = readI16(); + const name = readStr(); + const mark1 = readU16(); + pos += 10; // reserved padding -- always zeros + const mark2 = readU8(); + entryCount++; + + if (kind === 1 && entryCount > 1) { + const content = readBlob(); + const revisionCount = readU32(); + const revisions = []; + for (let i = 0; i < revisionCount; i++) revisions.push(readU32()); + return { kind: 'file', name, mark1, mark2, content, revisions }; + } + + const count = readU32(); + const children = []; + for (let i = 0; i < count; i++) children.push(readEntry()); + return { kind: 'directory', name, mark1, mark2, children }; + } + + return readEntry(); +} + +// -------------------------- Serializer (tree -> binary) ---------------------- + +function serialize(root) { + const chunks = []; + const encoder = new TextEncoder(); + + function writeU32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v); chunks.push(b); } + function writeI16(v) { const b = Buffer.alloc(2); b.writeInt16LE(v); chunks.push(b); } + function writeU16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); chunks.push(b); } + function writeU8(v) { chunks.push(Buffer.from([v])); } + function writeStr(s) { + const e = encoder.encode(s); + writeU32(e.length); + if (e.length) chunks.push(Buffer.from(e)); + } + function writeBlob(data) { + writeU32(data.length); + if (data.length) chunks.push(Buffer.from(data)); + } + + writeU32(MAGIC); + let isFirst = true; + + function writeEntry(entry) { + const isRoot = isFirst; + isFirst = false; + + if (entry.kind === 'file' && !isRoot) { + writeI16(1); + writeStr(entry.name); + writeU16(entry.mark1 ?? 0x0002); + chunks.push(Buffer.alloc(10)); + writeU8(entry.mark2 ?? 0x00); + writeBlob(entry.content); + const revs = entry.revisions ?? []; + writeU32(revs.length); + for (const r of revs) writeU32(r); + } else { + writeI16(isRoot ? 1 : 2); + writeStr(entry.name); + writeU16(entry.mark1 ?? 0x0000); + chunks.push(Buffer.alloc(10)); + writeU8(entry.mark2 ?? 0x00); + const children = entry.children ?? []; + writeU32(children.length); + for (const child of children) writeEntry(child); + } + } + + writeEntry(root); + return Buffer.concat(chunks); +} + +// -------------------------- Import (binary -> disk) -------------------------- + +function doImport(inputPath, outputDir, { quiet = false } = {}) { + const root = parse(fs.readFileSync(inputPath)); + if (!outputDir) outputDir = root.name; + + let fileCount = 0, dirCount = 0; + + function extract(entry, parentDir) { + if (entry.kind === 'file') { + fs.writeFileSync(path.join(parentDir, entry.name), entry.content); + fileCount++; + } else { + const dir = path.join(parentDir, entry.name); + fs.mkdirSync(dir, { recursive: true }); + dirCount++; + for (const child of entry.children) extract(child, dir); + } + } + + fs.mkdirSync(outputDir, { recursive: true }); + for (const child of root.children) extract(child, outputDir); + if (!quiet) console.log(`Imported "${root.name}" -> ${outputDir}/ (${fileCount} files, ${dirCount} directories)`); + return { name: root.name, fileCount, dirCount }; +} + +// -------------------------- Export (disk -> binary) -------------------------- + +function mark2For(name, isDir) { + if (isDir) return DIR_MARK2[name] ?? 0x00; + return name === 'Settings' ? 0x04 : 0x00; +} + +function buildTree(dirPath) { + const name = path.basename(dirPath); + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const subdirs = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name)); + const files = entries.filter(e => e.isFile()).sort((a, b) => a.name.localeCompare(b.name)); + + const children = []; + for (const d of subdirs) { + children.push(buildTree(path.join(dirPath, d.name))); + } + for (const f of files) { + children.push({ + kind: 'file', name: f.name, + mark1: 0x0002, mark2: mark2For(f.name, false), + content: fs.readFileSync(path.join(dirPath, f.name)), + revisions: [], + }); + } + return { + kind: 'directory', name, + mark1: 0x0000, mark2: mark2For(name, true), + children, + }; +} + +function doExport(inputDir, outputPath, { quiet = false } = {}) { + const root = buildTree(path.resolve(inputDir)); + const buf = serialize(root); + fs.writeFileSync(outputPath, buf); + + let fileCount = 0, dirCount = 0; + function count(e) { + if (e.kind === 'file') fileCount++; + else { dirCount++; for (const c of e.children) count(c); } + } + for (const c of root.children) count(c); + if (!quiet) console.log(`Exported "${root.name}" -> ${outputPath} (${buf.length} bytes, ${fileCount} files, ${dirCount} directories)`); + return { name: root.name, size: buf.length, fileCount, dirCount }; +} + +// -------------------------- Self-test ---------------------------------------- + +function selfTest() { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const samplePath = path.join(scriptDir, '..', 'indexer', 'sample.twinpack'); + if (!fs.existsSync(samplePath)) { + console.error(`Sample not found: ${samplePath}\n(requires indexer/sample.twinpack from the repository)`); + process.exit(1); + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impexp-test-')); + console.log(`Self-test workdir: ${tmpDir}\n`); + + let passed = 0, failed = 0; + function test(name, fn) { + try { fn(); console.log(` [PASS] ${name}`); passed++; } + catch (e) { console.log(` [FAIL] ${name}\n ${e.message}`); failed++; } + } + function eq(a, b, msg) { + if (a !== b) throw new Error(`${msg}: expected ${b}, got ${a}`); + } + + function treeFiles(entry, prefix) { + if (entry.kind === 'file') return [{ p: prefix + entry.name, d: entry.content }]; + const out = []; + for (const c of entry.children) out.push(...treeFiles(c, prefix + entry.name + '/')); + return out.sort((a, b) => a.p.localeCompare(b.p)); + } + + function diskFiles(dir, prefix) { + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (e.isDirectory()) out.push(...diskFiles(path.join(dir, e.name), prefix + e.name + '/')); + else out.push({ p: prefix + e.name, d: fs.readFileSync(path.join(dir, e.name)) }); + } + return out; + } + + try { + const sampleBuf = fs.readFileSync(samplePath); + let root; + + test('Parse sample.twinpack', () => { + root = parse(sampleBuf); + eq(root.name, 'CustomControlsPackage', 'root name'); + let fc = 0, dc = 0; + function cnt(e) { if (e.kind === 'file') fc++; else { dc++; for (const c of e.children) cnt(c); } } + for (const c of root.children) cnt(c); + eq(fc, 22, 'file count'); + eq(dc, 7, 'dir count'); + }); + + test('In-memory round-trip (parse -> serialize -> re-parse)', () => { + const buf2 = serialize(root); + const root2 = parse(buf2); + const f1 = treeFiles(root, ''), f2 = treeFiles(root2, ''); + eq(f1.length, f2.length, 'file count'); + for (let i = 0; i < f1.length; i++) { + eq(f1[i].p, f2[i].p, `path[${i}]`); + if (!Buffer.from(f1[i].d).equals(Buffer.from(f2[i].d))) + throw new Error(`content mismatch: ${f1[i].p}`); + } + }); + + test('Serializer idempotence (double round-trip)', () => { + const once = serialize(parse(sampleBuf)); + const twice = serialize(parse(once)); + if (!once.equals(twice)) throw new Error(`${once.length} vs ${twice.length} bytes`); + }); + + test('Disk round-trip (import -> export -> re-import)', () => { + const dir1 = path.join(tmpDir, 'import1'); + const rtFile = path.join(tmpDir, 'roundtrip.twinpack'); + const dir2 = path.join(tmpDir, 'import2'); + doImport(samplePath, dir1, { quiet: true }); + doExport(dir1, rtFile, { quiet: true }); + doImport(rtFile, dir2, { quiet: true }); + const a = diskFiles(dir1, ''), b = diskFiles(dir2, ''); + eq(a.length, b.length, 'file count'); + for (let i = 0; i < a.length; i++) { + eq(a[i].p, b[i].p, `path[${i}]`); + if (!a[i].d.equals(b[i].d)) throw new Error(`content mismatch: ${a[i].p}`); + } + }); + + test('Empty project round-trip', () => { + const tree = { kind: 'directory', name: 'Empty', mark1: 0, mark2: 0, children: [] }; + const rt = parse(serialize(tree)); + eq(rt.name, 'Empty', 'name'); + eq(rt.children.length, 0, 'children'); + }); + + test('Single-file project round-trip', () => { + const content = Buffer.from('Hello twinBASIC'); + const tree = { + kind: 'directory', name: 'Mini', mark1: 0, mark2: 0, + children: [{ kind: 'file', name: 'test.twin', mark1: 2, mark2: 0, content, revisions: [] }], + }; + const rt = parse(serialize(tree)); + eq(rt.children.length, 1, 'children'); + eq(rt.children[0].name, 'test.twin', 'filename'); + if (!Buffer.from(rt.children[0].content).equals(content)) + throw new Error('content mismatch'); + }); + + test('Bad magic rejected', () => { + try { parse(Buffer.from('not a twinpack!!')); throw new Error('should have thrown'); } + catch (e) { if (!e.message.includes('Bad magic')) throw e; } + }); + + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + console.log(`\n${passed}/${passed + failed} tests passed.`); + if (failed > 0) process.exit(1); +} + +// -------------------------- CLI ---------------------------------------------- + +const USAGE = `Usage: + impexp import [output_dir] + impexp export + impexp --self-test`; + +const [cmd, ...rest] = process.argv.slice(2); + +if (cmd === '--self-test') selfTest(); +else if (cmd === 'import' && rest.length >= 1) doImport(rest[0], rest[1]); +else if (cmd === 'export' && rest.length >= 2) doExport(rest[0], rest[1]); +else { console.error(USAGE); process.exit(1); } diff --git a/scripts/impexp.py b/scripts/impexp.py new file mode 100644 index 00000000..13d9ffaa --- /dev/null +++ b/scripts/impexp.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 TWINBASIC LTD +# SPDX-License-Identifier: MIT +""" +impexp.py -- standalone twinpack/twinproj import/export tool. +No external dependencies; requires Python 3.6+. + +Usage: + python impexp.py import [output_dir] + python impexp.py export + python impexp.py --self-test +""" + +import os +import struct +import sys + +MAGIC = 0xEA0BA51C + +DIR_MARK2 = { + 'Resources': 0x02, 'Sources': 0x03, 'ImportedTypeLibraries': 0x05, + 'Miscellaneous': 0x06, 'Packages': 0x07, +} + +# -------------------------- Parser (binary -> tree) -------------------------- + + +def parse(data): + pos = [0] + + def read_u32(): + v, = struct.unpack_from(' 1: + content = read_blob() + revision_count = read_u32() + revisions = [read_u32() for _ in range(revision_count)] + return dict(kind='file', name=name, mark1=mark1, mark2=mark2, + content=content, revisions=revisions) + + count = read_u32() + children = [read_entry() for _ in range(count)] + return dict(kind='directory', name=name, mark1=mark1, mark2=mark2, + children=children) + + return read_entry() + + +# -------------------------- Serializer (tree -> binary) ---------------------- + + +def serialize(root): + chunks = [] + + def write_u32(v): + chunks.append(struct.pack(' disk) -------------------------- + + +def do_import(input_path, output_dir, *, quiet=False): + with open(input_path, 'rb') as f: + root = parse(f.read()) + + if not output_dir: + output_dir = root['name'] + + file_count = 0 + dir_count = 0 + + def extract(entry, parent_dir): + nonlocal file_count, dir_count + if entry['kind'] == 'file': + with open(os.path.join(parent_dir, entry['name']), 'wb') as f: + f.write(entry['content']) + file_count += 1 + else: + d = os.path.join(parent_dir, entry['name']) + os.makedirs(d, exist_ok=True) + dir_count += 1 + for child in entry['children']: + extract(child, d) + + os.makedirs(output_dir, exist_ok=True) + for child in root['children']: + extract(child, output_dir) + if not quiet: + print(f'Imported "{root["name"]}" -> {output_dir}/' + f' ({file_count} files, {dir_count} directories)') + return dict(name=root['name'], file_count=file_count, dir_count=dir_count) + + +# -------------------------- Export (disk -> binary) -------------------------- + + +def _mark2_for(name, is_dir): + if is_dir: + return DIR_MARK2.get(name, 0x00) + return 0x04 if name == 'Settings' else 0x00 + + +def _build_tree(dir_path): + name = os.path.basename(os.path.abspath(dir_path)) + listing = sorted(os.listdir(dir_path)) + subdirs = [e for e in listing if os.path.isdir(os.path.join(dir_path, e))] + files = [e for e in listing if os.path.isfile(os.path.join(dir_path, e))] + + children = [] + for d in subdirs: + children.append(_build_tree(os.path.join(dir_path, d))) + for f in files: + with open(os.path.join(dir_path, f), 'rb') as fh: + content = fh.read() + children.append(dict( + kind='file', name=f, + mark1=0x0002, mark2=_mark2_for(f, False), + content=content, revisions=[], + )) + return dict( + kind='directory', name=name, + mark1=0x0000, mark2=_mark2_for(name, True), + children=children, + ) + + +def do_export(input_dir, output_path, *, quiet=False): + root = _build_tree(input_dir) + buf = serialize(root) + with open(output_path, 'wb') as f: + f.write(buf) + + file_count = 0 + dir_count = 0 + + def count(e): + nonlocal file_count, dir_count + if e['kind'] == 'file': + file_count += 1 + else: + dir_count += 1 + for c in e['children']: + count(c) + + for c in root['children']: + count(c) + if not quiet: + print(f'Exported "{root["name"]}" -> {output_path}' + f' ({len(buf)} bytes, {file_count} files, {dir_count} directories)') + return dict(name=root['name'], size=len(buf), + file_count=file_count, dir_count=dir_count) + + +# -------------------------- Self-test ---------------------------------------- + + +def _self_test(): + import shutil + import tempfile + + script_dir = os.path.dirname(os.path.abspath(__file__)) + sample_path = os.path.join(script_dir, '..', 'indexer', 'sample.twinpack') + if not os.path.isfile(sample_path): + print(f'Sample not found: {sample_path}\n' + f'(requires indexer/sample.twinpack from the repository)', + file=sys.stderr) + sys.exit(1) + + tmp_dir = tempfile.mkdtemp(prefix='impexp-test-') + print(f'Self-test workdir: {tmp_dir}\n') + + passed = [0] + failed = [0] + + def test(name, fn): + try: + fn() + print(f' [PASS] {name}') + passed[0] += 1 + except Exception as e: + print(f' [FAIL] {name}\n {e}') + failed[0] += 1 + + def eq(a, b, msg): + if a != b: + raise AssertionError(f'{msg}: expected {b}, got {a}') + + def tree_files(entry, prefix): + if entry['kind'] == 'file': + return [(prefix + entry['name'], entry['content'])] + out = [] + for c in entry['children']: + out.extend(tree_files(c, prefix + entry['name'] + '/')) + out.sort(key=lambda x: x[0]) + return out + + def disk_files(d, prefix=''): + out = [] + for e in sorted(os.listdir(d)): + full = os.path.join(d, e) + if os.path.isdir(full): + out.extend(disk_files(full, prefix + e + '/')) + elif os.path.isfile(full): + with open(full, 'rb') as f: + out.append((prefix + e, f.read())) + return out + + try: + with open(sample_path, 'rb') as f: + sample_buf = f.read() + + root = [None] + + def t_parse(): + root[0] = parse(sample_buf) + eq(root[0]['name'], 'CustomControlsPackage', 'root name') + fc = dc = 0 + def cnt(e): + nonlocal fc, dc + if e['kind'] == 'file': fc += 1 + else: + dc += 1 + for c in e['children']: cnt(c) + for c in root[0]['children']: cnt(c) + eq(fc, 22, 'file count') + eq(dc, 7, 'dir count') + test('Parse sample.twinpack', t_parse) + + def t_inmem(): + buf2 = serialize(root[0]) + root2 = parse(buf2) + f1, f2 = tree_files(root[0], ''), tree_files(root2, '') + eq(len(f1), len(f2), 'file count') + for i in range(len(f1)): + eq(f1[i][0], f2[i][0], f'path[{i}]') + if f1[i][1] != f2[i][1]: + raise AssertionError(f'content mismatch: {f1[i][0]}') + test('In-memory round-trip (parse -> serialize -> re-parse)', t_inmem) + + def t_idempotent(): + once = serialize(parse(sample_buf)) + twice = serialize(parse(once)) + if once != twice: + raise AssertionError(f'{len(once)} vs {len(twice)} bytes') + test('Serializer idempotence (double round-trip)', t_idempotent) + + def t_disk(): + dir1 = os.path.join(tmp_dir, 'import1') + rt_file = os.path.join(tmp_dir, 'roundtrip.twinpack') + dir2 = os.path.join(tmp_dir, 'import2') + do_import(sample_path, dir1, quiet=True) + do_export(dir1, rt_file, quiet=True) + do_import(rt_file, dir2, quiet=True) + a, b = disk_files(dir1), disk_files(dir2) + eq(len(a), len(b), 'file count') + for i in range(len(a)): + eq(a[i][0], b[i][0], f'path[{i}]') + if a[i][1] != b[i][1]: + raise AssertionError(f'content mismatch: {a[i][0]}') + test('Disk round-trip (import -> export -> re-import)', t_disk) + + def t_empty(): + tree = dict(kind='directory', name='Empty', + mark1=0, mark2=0, children=[]) + rt = parse(serialize(tree)) + eq(rt['name'], 'Empty', 'name') + eq(len(rt['children']), 0, 'children') + test('Empty project round-trip', t_empty) + + def t_single(): + content = b'Hello twinBASIC' + tree = dict(kind='directory', name='Mini', mark1=0, mark2=0, + children=[ + dict(kind='file', name='test.twin', mark1=2, + mark2=0, content=content, revisions=[]), + ]) + rt = parse(serialize(tree)) + eq(len(rt['children']), 1, 'children') + eq(rt['children'][0]['name'], 'test.twin', 'filename') + if rt['children'][0]['content'] != content: + raise AssertionError('content mismatch') + test('Single-file project round-trip', t_single) + + def t_bad_magic(): + try: + parse(b'not a twinpack!!') + raise AssertionError('should have thrown') + except ValueError as e: + if 'Bad magic' not in str(e): + raise + test('Bad magic rejected', t_bad_magic) + + finally: + shutil.rmtree(tmp_dir) + + print(f'\n{passed[0]}/{passed[0] + failed[0]} tests passed.') + if failed[0] > 0: + sys.exit(1) + + +# -------------------------- CLI ---------------------------------------------- + +USAGE = """\ +Usage: + impexp import [output_dir] + impexp export + impexp --self-test""" + + +def main(): + args = sys.argv[1:] + cmd = args[0] if args else '' + rest = args[1:] + + if cmd == '--self-test': + _self_test() + elif cmd == 'import' and len(rest) >= 1: + do_import(rest[0], rest[1] if len(rest) > 1 else None) + elif cmd == 'export' and len(rest) >= 2: + do_export(rest[0], rest[1]) + else: + print(USAGE, file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() From 029737f5147bafb780339bc72887f4992b94ae1c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 3 Jun 2026 14:26:31 +0200 Subject: [PATCH 2/2] Bundle and document the impexp tool. --- builder/tbdocs.mjs | 5 + docs/Features/Packages/Import-export tool.md | 97 ++++++++++++++++++++ docs/Features/Packages/index.md | 1 + docs/_config.yml | 9 ++ 4 files changed, 112 insertions(+) create mode 100644 docs/Features/Packages/Import-export tool.md diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 67e04bd1..da308fea 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -356,6 +356,11 @@ const TASKS = { runOnMain: true, async execute({ config: { config } }, ctx) { const { pages, staticFiles } = await discover(ctx.srcRoot, config.exclude ?? []); + for (const entry of config.bundle_extra ?? []) { + const srcPath = path.resolve(ctx.srcRoot, entry.src); + const stat = await fs.stat(srcPath); + staticFiles.push({ srcPath, srcRel: entry.dest, destRel: entry.dest, size: stat.size }); + } return { pages, staticFiles, config }; }, submit(out, state) { diff --git a/docs/Features/Packages/Import-export tool.md b/docs/Features/Packages/Import-export tool.md new file mode 100644 index 00000000..5cfdcce0 --- /dev/null +++ b/docs/Features/Packages/Import-export tool.md @@ -0,0 +1,97 @@ +--- +title: Import/Export Tool +parent: Package Management +grand_parent: Features +nav_order: 7 +permalink: /Features/Packages/Import-Export-Tool +--- + +# Import/export tool + +A standalone command-line tool for unpacking `.twinproj` and `.twinpack` +files to a directory tree, and for repacking a directory tree back into a +binary project file. Useful for inspecting package contents, batch-editing +source files outside the IDE, or integrating twinBASIC projects into +version-control workflows. + +> [!WARNING] +> +> There is no official support for reading or writing project files outside of the twinBASIC executable. This tool may break when the IDE is updated. + +Two functionally identical, single-file implementations are provided -- pick +whichever runtime you already have installed: + +| Runtime | Download | +|---------|----------| +| Node.js 18+ | impexp.mjs | +| Python 3.6+ | impexp.py | + +Neither script has any external dependencies. + +## Usage + +``` +impexp import [output_dir] +impexp export +impexp --self-test +``` + +### Import (unpack) + +Reads a `.twinproj` or `.twinpack` binary and extracts its contents to a +directory on disk. If `output_dir` is omitted, a directory named after the +project root entry is created in the current working directory. + +``` +node impexp.mjs import MyPackage.twinpack +``` + +``` +python impexp.py import MyProject.twinproj unpacked/ +``` + +### Export (pack) + +Scans a directory tree and writes a `.twinproj` or `.twinpack` binary. The +directory name becomes the root entry name in the output file. Well-known +directory and file names (`Sources`, `Resources`, `Settings`, etc.) are +tagged with the correct `mark2` category values automatically. + +``` +node impexp.mjs export unpacked/ MyProject.twinproj +``` + +``` +python impexp.py export unpacked/ MyPackage.twinpack +``` + +### Self-test + +Both implementations include a built-in test suite that exercises parsing, +serialization, and full round-trip fidelity. + +``` +node impexp.mjs --self-test +python impexp.py --self-test +``` + +## Round-trip notes + +Importing and re-exporting a binary file preserves all file contents +byte-for-byte. The following metadata fields are reset to defaults on a +disk round-trip (they are not stored on the filesystem): + +- **mark1** (revision counter) -- directories get `0x0000`; files get + `0x0002`. +- **Revision entries** -- always written as zero. +- **Entry order** -- directories first, then files, sorted alphabetically + within each group. + +The IDE regenerates these fields when the project is opened, so the +round-tripped file is fully functional. + +## See also + +- [TWINPACK File Format](File-Format) -- binary format specification +- [Creating a TWINPACK Package](Creating-TWINPACK) +- [Importing a Package from a TWINPACK File](Importing-TWINPACK) diff --git a/docs/Features/Packages/index.md b/docs/Features/Packages/index.md index 1bd2c559..906146d9 100644 --- a/docs/Features/Packages/index.md +++ b/docs/Features/Packages/index.md @@ -28,5 +28,6 @@ Please be aware that TWINPACK files currently contain the full source code of yo - [Linked Packages](Linked) -- storing a package in a shared location rather than embedding it in each project file. - [Updating a Package](Updating) -- removing an outdated package and installing a newer version from TWINSERV. - [TWINPACK File Format](File-Format) -- binary format specification for `.twinproj` and `.twinpack` files. +- [Import/Export Tool](Import-Export-Tool) -- standalone command-line tool for unpacking and repacking `.twinproj` and `.twinpack` files. [^1]: A service of TWINBASIC LTD offered to the user community. diff --git a/docs/_config.yml b/docs/_config.yml index c9ecfcdf..8a5cc5d2 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -79,6 +79,15 @@ offline_exclude: # # Patterns that start with * must be quoted so YAML does not interpret them # as alias references. +# Extra files from outside the docs tree to bundle as site assets. +# Each entry maps a source path (relative to docs/) to a destination +# path in the built site. Injected during the discover phase. +bundle_extra: + - src: ../scripts/impexp.mjs + dest: Features/Packages/downloads/impexp.mjs + - src: ../scripts/impexp.py + dest: Features/Packages/downloads/impexp.py + exclude: # Underscore-prefixed files and directories -- catches _config.yml, # _book.yml, _site, _site-offline, _site-pdf, _pdf, _includes,