From 47dc4038f38cf0a1499d3dfdf9e9c2bb90b6ecdf Mon Sep 17 00:00:00 2001 From: Rob Greene Date: Fri, 2 Mar 2018 12:30:58 -0600 Subject: [PATCH] Adding AppleSingle support #20; refactored to support minor unit testing. --- .../storage/os/prodos/ProdosFormatDisk.java | 16 +-- .../com/webcodepro/applecommander/ui/ac.java | 118 ++++++++++++------ .../applecommander/util/AppleSingle.java | 112 +++++++++++++++++ .../applecommander/ui/UiBundle.properties | 2 +- .../applecommander/util/AppleSingleTest.java | 56 +++++++++ src/test/resources/hello.applesingle.bin | Bin 0 -> 2970 bytes 6 files changed, 254 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/webcodepro/applecommander/util/AppleSingle.java create mode 100644 src/test/java/com/webcodepro/applecommander/util/AppleSingleTest.java create mode 100644 src/test/resources/hello.applesingle.bin diff --git a/src/main/java/com/webcodepro/applecommander/storage/os/prodos/ProdosFormatDisk.java b/src/main/java/com/webcodepro/applecommander/storage/os/prodos/ProdosFormatDisk.java index 973882a..03cbbb6 100644 --- a/src/main/java/com/webcodepro/applecommander/storage/os/prodos/ProdosFormatDisk.java +++ b/src/main/java/com/webcodepro/applecommander/storage/os/prodos/ProdosFormatDisk.java @@ -68,8 +68,7 @@ public class ProdosFormatDisk extends FormattedDisk { * A complete list of all known ProDOS filetypes. Note that this * list really cannot be complete, as there are multiple mappings per * identifier in some cases - differentiated by AUXTYPE. This is - * loaded via initialize when the first instance of ProdosFormatDisk - * is created. + * loaded via the static initializer. */ private static ProdosFileType[] fileTypes; /** @@ -85,7 +84,7 @@ public class ProdosFormatDisk extends FormattedDisk { /** * This class holds filetype mappings. */ - private class ProdosFileType { + private static class ProdosFileType { private byte type; private String string; private boolean addressRequired; @@ -148,18 +147,15 @@ public class ProdosFormatDisk extends FormattedDisk { public ProdosFormatDisk(String filename, ImageOrder imageOrder) { super(filename, imageOrder); volumeHeader = new ProdosVolumeDirectoryHeader(this); - initialize(); } /** * Initialize all file types. */ - protected void initialize() { - if (fileTypes != null) return; - + static { fileTypes = new ProdosFileType[256]; InputStream inputStream = - getClass().getResourceAsStream("ProdosFileTypes.properties"); //$NON-NLS-1$ + ProdosFormatDisk.class.getResourceAsStream("ProdosFileTypes.properties"); //$NON-NLS-1$ Properties properties = new Properties(); try { properties.load(inputStream); @@ -1251,7 +1247,7 @@ public class ProdosFormatDisk extends FormattedDisk { * Return the filetype of this file. This will be three characters, * according to ProDOS - a "$xx" if unknown. */ - public String getFiletype(int filetype) { + public static String getFiletype(int filetype) { ProdosFileType prodostype = fileTypes[filetype]; return prodostype.getString(); } @@ -1270,7 +1266,7 @@ public class ProdosFormatDisk extends FormattedDisk { /** * Locate the associated ProdosFileType. */ - public ProdosFileType findFileType(String filetype) { + public static ProdosFileType findFileType(String filetype) { for (int i=0; i= 3 ? args[2] : null); } else if ("-geos".equalsIgnoreCase(args[0])) { //$NON-NLS-1$ putGEOS(args[1]); } else if ("-dos140".equalsIgnoreCase(args[0])) { //$NON-NLS-1$ @@ -167,22 +175,23 @@ public class ac { ByteArrayOutputStream buf = new ByteArrayOutputStream(); byte[] inb = new byte[1024]; int byteCount = 0; - InputStream is = new FileInputStream(file); - while ((byteCount = is.read(inb)) > 0) { - buf.write(inb, 0, byteCount); - } - Disk disk = new Disk(imageName); - FormattedDisk[] formattedDisks = disk.getFormattedDisks(); - FormattedDisk formattedDisk = formattedDisks[0]; - FileEntry entry = name.createEntry(formattedDisk); - if (entry != null) { - entry.setFiletype(fileType); - entry.setFilename(name.name); - entry.setFileData(buf.toByteArray()); - if (entry.needsAddress()) { - entry.setAddress(stringToInt(address)); + try (InputStream is = new FileInputStream(file)) { + while ((byteCount = is.read(inb)) > 0) { + buf.write(inb, 0, byteCount); + } + Disk disk = new Disk(imageName); + FormattedDisk[] formattedDisks = disk.getFormattedDisks(); + FormattedDisk formattedDisk = formattedDisks[0]; + FileEntry entry = name.createEntry(formattedDisk); + if (entry != null) { + entry.setFiletype(fileType); + entry.setFilename(name.name); + entry.setFileData(buf.toByteArray()); + if (entry.needsAddress()) { + entry.setAddress(stringToInt(address)); + } + formattedDisk.save(); } - formattedDisk.save(); } } @@ -193,12 +202,18 @@ public class ac { static void putFile(String imageName, Name name, String fileType, String address) throws IOException, DiskException { + putFile(imageName, name, fileType, address, System.in); + } + + /** + * Put InputStream into the file named fileName on the disk named imageName; + * Note: only volume level supported; input size unlimited. + */ + static void putFile(String imageName, Name name, String fileType, + String address, InputStream inputStream) throws IOException, DiskException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); - byte[] inb = new byte[1024]; - int byteCount = 0; - while ((byteCount = System.in.read(inb)) > 0) { - buf.write(inb, 0, byteCount); - } + StreamUtil.copy(inputStream, buf); Disk disk = new Disk(imageName); FormattedDisk[] formattedDisks = disk.getFormattedDisks(); if (formattedDisks == null) @@ -250,6 +265,38 @@ public class ac { putFile(imageName, name, fileType, Integer.toString(address)); } } + + /** + * Put file from AppleSingle format into ProDOS image. + */ + public static void putAppleSingle(String imageName, String fileName) throws IOException, DiskException { + putAppleSingle(imageName, fileName, System.in); + } + /** + * AppleSingle shim to allow for unit testing. + */ + public static void putAppleSingle(String imageName, String fileName, InputStream inputStream) + throws IOException, DiskException { + + AppleSingle as = new AppleSingle(inputStream); + if (fileName == null) { + fileName = as.getRealName(); + } + if (fileName == null) { + throw new IOException("Please specify a file name - this AppleSingle does not have one."); + } + if (as.getProdosFileInfo() == null) { + throw new IOException("This AppleSingle does not contain a ProDOS file."); + } + if (as.getDataFork() == null || as.getDataFork().length == 0) { + throw new IOException("This AppleSingle does not contain a data fork."); + } + Name name = new Name(fileName); + ProdosFileInfo info = as.getProdosFileInfo(); + String fileType = ProdosFormatDisk.getFiletype(info.getFileType()); + putFile(imageName, name, fileType, Integer.toString(info.getAuxType()), + new ByteArrayInputStream(as.getDataFork())); + } /** * Interpret <stdin> as a GEOS file and place it on the disk named imageName. @@ -339,10 +386,8 @@ public class ac { /** * Recursive routine to write directory and file entries. */ - static void writeFiles(List files, String directory) throws IOException, DiskException { - Iterator it = files.iterator(); - while (it.hasNext()) { - FileEntry entry = (FileEntry) it.next(); + static void writeFiles(List files, String directory) throws IOException, DiskException { + for (FileEntry entry : files) { if ((entry != null) && (!entry.isDeleted()) && (!entry.isDirectory())) { FileFilter ff = entry.getSuggestedFilter(); if (ff instanceof BinaryFileFilter) @@ -367,11 +412,9 @@ public class ac { * file with the given filename. * @deprecated */ - static FileEntry getEntry(List files, String fileName) throws DiskException { - FileEntry entry = null; + static FileEntry getEntry(List files, String fileName) throws DiskException { if (files != null) { - for (int i = 0; i < files.size(); i++) { - entry = (FileEntry) files.get(i); + for (FileEntry entry : files) { String entryName = entry.getFilename(); if (!entry.isDeleted() && fileName.equalsIgnoreCase(entryName)) { return entry; @@ -399,7 +442,7 @@ public class ac { FormattedDisk formattedDisk = formattedDisks[i]; System.out.print(args[d] + " "); System.out.println(formattedDisk.getDiskName()); - List files = formattedDisk.getFiles(); + List files = formattedDisk.getFiles(); if (files != null) { showFiles(files, "", display); //$NON-NLS-1$ } @@ -423,11 +466,10 @@ public class ac { * system with directories (e.g. ProDOS), this really returns the first file * with the given filename. */ - static void showFiles(List files, String indent, int display) throws DiskException { - for (int i = 0; i < files.size(); i++) { - FileEntry entry = (FileEntry) files.get(i); + static void showFiles(List files, String indent, int display) throws DiskException { + for (FileEntry entry : files) { if (!entry.isDeleted()) { - List data = entry.getFileColumnData(display); + List data = entry.getFileColumnData(display); System.out.print(indent); for (int d = 0; d < data.size(); d++) { System.out.print(data.get(d)); @@ -451,9 +493,7 @@ public class ac { FormattedDisk[] formattedDisks = disk.getFormattedDisks(); for (int i = 0; i < formattedDisks.length; i++) { FormattedDisk formattedDisk = formattedDisks[i]; - Iterator iterator = formattedDisk.getDiskInformation().iterator(); - while (iterator.hasNext()) { - DiskInformation diskinfo = (DiskInformation) iterator.next(); + for (DiskInformation diskinfo : formattedDisk.getDiskInformation()) { System.out.println(diskinfo.getLabel() + ": " + diskinfo.getValue()); } } @@ -607,7 +647,7 @@ public class ac { } public FileEntry getEntry(FormattedDisk formattedDisk) throws DiskException { - List files = formattedDisk.getFiles(); + List files = formattedDisk.getFiles(); FileEntry entry = null; for (int i = 0; i < path.length - 1; i++) { String dirName = path[i]; @@ -633,7 +673,7 @@ public class ac { if (path.length == 1) { return formattedDisk.createFile(); } - List files = formattedDisk.getFiles(); + List files = formattedDisk.getFiles(); DirectoryEntry dir = null, parentDir = null; for (int i = 0; i < path.length - 1; i++) { String dirName = path[i]; diff --git a/src/main/java/com/webcodepro/applecommander/util/AppleSingle.java b/src/main/java/com/webcodepro/applecommander/util/AppleSingle.java new file mode 100644 index 0000000..3246ca8 --- /dev/null +++ b/src/main/java/com/webcodepro/applecommander/util/AppleSingle.java @@ -0,0 +1,112 @@ +package com.webcodepro.applecommander.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * Support reading of data from and AppleSingle source. + * Does not implement all components at this time, extend as required. + * + * @see https://github.com/AppleCommander/AppleCommander/issues/20 + */ +public class AppleSingle { + public static final int MAGIC_NUMBER = 0x0051600; + public static final int VERSION_NUMBER = 0x00020000; + + private byte[] dataFork; + private byte[] resourceFork; + private String realName; + private ProdosFileInfo prodosFileInfo; + + public AppleSingle(String filename) throws IOException { + byte[] fileData = Files.readAllBytes(Paths.get(filename)); + load(fileData); + } + public AppleSingle(InputStream stream) throws IOException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + StreamUtil.copy(stream, os); + os.flush(); + load(os.toByteArray()); + } + + private void load(byte[] fileData) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(fileData) + .order(ByteOrder.BIG_ENDIAN) + .asReadOnlyBuffer(); + required(buffer, MAGIC_NUMBER, "Not an AppleSingle file - magic number does not match."); + required(buffer, VERSION_NUMBER, "Only AppleSingle version 2 supported."); + buffer.position(buffer.position() + 16); // Skip filler + int entries = buffer.getShort(); + for (int i = 0; i < entries; i++) { + int entryId = buffer.getInt(); + int offset = buffer.getInt(); + int length = buffer.getInt(); + buffer.mark(); + buffer.position(offset); + byte[] entryData = new byte[length]; + buffer.get(entryData); + if (entryId == 1) { + dataFork = entryData; + } else if (entryId == 2) { + resourceFork = entryData; + } else if (entryId == 11) { + ByteBuffer infoData = ByteBuffer.wrap(entryData) + .order(ByteOrder.BIG_ENDIAN) + .asReadOnlyBuffer(); + int access = infoData.getShort(); + int fileType = infoData.getShort(); + int auxType = infoData.getInt(); + prodosFileInfo = new ProdosFileInfo(access, fileType, auxType); + } else { + throw new IOException(String.format("Unknown entry type of %04x", entryId)); + } + buffer.reset(); + } + } + private void required(ByteBuffer buffer, int expected, String message) throws IOException { + int actual = buffer.getInt(); + if (actual != expected) { + throw new IOException(String.format("%s Expected 0x%08x but read 0x%08x.", message, expected, actual)); + } + } + + public byte[] getDataFork() { + return dataFork; + } + public byte[] getResourceFork() { + return resourceFork; + } + public String getRealName() { + return realName; + } + public ProdosFileInfo getProdosFileInfo() { + return prodosFileInfo; + } + + public class ProdosFileInfo { + private int access; + private int fileType; + private int auxType; + + public ProdosFileInfo(int access, int fileType, int auxType) { + this.access = access; + this.fileType = fileType; + this.auxType = auxType; + } + + public int getAccess() { + return access; + } + public int getFileType() { + return fileType; + } + public int getAuxType() { + return auxType; + } + } +} diff --git a/src/main/resources/com/webcodepro/applecommander/ui/UiBundle.properties b/src/main/resources/com/webcodepro/applecommander/ui/UiBundle.properties index 8e3f876..44187b6 100644 --- a/src/main/resources/com/webcodepro/applecommander/ui/UiBundle.properties +++ b/src/main/resources/com/webcodepro/applecommander/ui/UiBundle.properties @@ -102,7 +102,7 @@ CreateDirectoryMenuItem=Create Directory... CommandLineErrorMessage = Error: {0} CommandLineNoMatchMessage = {0}: No match. CommandLineStatus = {0} format; {1} bytes free; {2} bytes used. -CommandLineHelp = CommandLineHelp = AppleCommander command line options [{0}]:\n-i [] display information about image(s).\n-ls [] list brief directory of image(s).\n-l [] list directory of image(s).\n-ll [] list detailed directory of image(s).\n-e [] export file from image to stdout\n or to an output file.\n-x [] extract all files from image to directory.\n-g [] get raw file from image to stdout\n or to an output file.\n-p [[$|0x]] put stdin\n in filename on image, using file type and address [0x2000].\n-d delete file from image.\n-k lock file on image.\n-u unlock file on image.\n-n change volume name (ProDOS or Pascal).\n-cc65 put stdin with cc65 header\n in filename on image, using file type and address from header.\n-geos interpret stdin as a GEOS conversion file and\n place it on image (ProDOS only).\n-dos140 create a 140K DOS 3.3 image.\n-pro140 create a 140K ProDOS image.\n-pro800 create an 800K ProDOS image.\n-pas140 create a 140K Pascal image.\n-pas800 create an 800K Pascal image.\n-convert [] uncompress a ShrinkIt or Binary\n II file; or convert a DiskCopy 4.2 image into a ProDOS disk image. +CommandLineHelp = CommandLineHelp = AppleCommander command line options [{0}]:\n-i [] display information about image(s).\n-ls [] list brief directory of image(s).\n-l [] list directory of image(s).\n-ll [] list detailed directory of image(s).\n-e [] export file from image to stdout\n or to an output file.\n-x [] extract all files from image to directory.\n-g [] get raw file from image to stdout\n or to an output file.\n-p [[$|0x]] put stdin\n in filename on image, using file type and address [0x2000].\n-d delete file from image.\n-k lock file on image.\n-u unlock file on image.\n-n change volume name (ProDOS or Pascal).\n-cc65 put stdin with cc65 header\n in filename on image, using file type and address from header.\n-as [] put stdin with AppleSingle format\n in filename on image, using file type, address, and (optionally) name\n from the AppleSingle file.\n-geos interpret stdin as a GEOS conversion file and\n place it on image (ProDOS only).\n-dos140 create a 140K DOS 3.3 image.\n-pro140 create a 140K ProDOS image.\n-pro800 create an 800K ProDOS image.\n-pas140 create a 140K Pascal image.\n-pas800 create an 800K Pascal image.\n-convert [] uncompress a ShrinkIt or Binary\n II file; or convert a DiskCopy 4.2 image into a ProDOS disk image. CommandLineSDKReadOnly = SDK, SHK, and DC42 files are read-only. Use the convert option on them first. CommandLineDC42Bad = Unable to interpret this DiskCopy 42 image. diff --git a/src/test/java/com/webcodepro/applecommander/util/AppleSingleTest.java b/src/test/java/com/webcodepro/applecommander/util/AppleSingleTest.java new file mode 100644 index 0000000..970655b --- /dev/null +++ b/src/test/java/com/webcodepro/applecommander/util/AppleSingleTest.java @@ -0,0 +1,56 @@ +package com.webcodepro.applecommander.util; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +import com.webcodepro.applecommander.storage.Disk; +import com.webcodepro.applecommander.storage.DiskException; +import com.webcodepro.applecommander.storage.FileEntry; +import com.webcodepro.applecommander.storage.FormattedDisk; +import com.webcodepro.applecommander.storage.os.prodos.ProdosFileEntry; +import com.webcodepro.applecommander.ui.ac; +import com.webcodepro.applecommander.util.AppleSingle.ProdosFileInfo; + +import junit.framework.TestCase; + +public class AppleSingleTest extends TestCase { + private static final String AS_HELLO_BIN = "/hello.applesingle.bin"; + + public void testSampleFromCc65() throws IOException { + AppleSingle as = new AppleSingle(getClass().getResourceAsStream(AS_HELLO_BIN)); + + assertNull(as.getRealName()); + assertNull(as.getResourceFork()); + assertNotNull(as.getDataFork()); + assertNotNull(as.getProdosFileInfo()); + + ProdosFileInfo info = as.getProdosFileInfo(); + assertEquals(0xc3, info.getAccess()); + assertEquals(0x06, info.getFileType()); + assertEquals(0x0803, info.getAuxType()); + } + + public void testViaAcTool() throws IOException, DiskException { + // Create a file that the JVM *should* delete for us. + File tmpDiskImage = File.createTempFile("deleteme-", ".po"); + tmpDiskImage.deleteOnExit(); + String tmpImageName = tmpDiskImage.getAbsolutePath(); + + // Create disk + ac.createProDisk(tmpImageName, "DELETEME", Disk.APPLE_140KB_DISK); + + // Actually test the implementation! + ac.putAppleSingle(tmpImageName, "HELLO", getClass().getResourceAsStream(AS_HELLO_BIN)); + + Disk disk = new Disk(tmpImageName); + FormattedDisk formattedDisk = disk.getFormattedDisks()[0]; + List files = formattedDisk.getFiles(); + assertNotNull(files); + assertEquals(1, files.size()); + ProdosFileEntry file = (ProdosFileEntry)files.get(0); + assertEquals("HELLO", file.getFilename()); + assertEquals("BIN", file.getFiletype()); + assertEquals(0x0803, file.getAuxiliaryType()); + } +} diff --git a/src/test/resources/hello.applesingle.bin b/src/test/resources/hello.applesingle.bin new file mode 100644 index 0000000000000000000000000000000000000000..9bdc3cfa2a882515c45ee9a1118335a4eec755b2 GIT binary patch literal 2970 zcmc&0ZEO_Bb?byF}sQz$f0i6%u_ zpS`EO^X>Ya7~7;lTCLa4qx|;bGM9!6Yryg(l~b;w1xeMDqQ{dBI%@PePD3-TtfE> zMgm7->RGg^BmyouEIvRb!0OCUZ>5)bD}ADU0jcK@t_@q~3Sr4_NI`Il zno2PnR2to{MUjN){KGbcU?vW&y;X3VfGgf z!GQO$*DfM*``RQTpNVi@yNU)F9!_j~_yI2^;M^SwZjNJL*i?j{$G^h-F`m=|BR$Na zkC4LMO36K$gE$eFdoz7>=+7Lon4{hOWAGd5S2rMvUqEKBteAarmU_#HmghR5F><5Q zM?USZZ|RkL3*f%yNkz_?eUn+aGmvxU;yp1vU>@q06tkbAEJX@MofPG|B`hG^!j?$X z2%Pu`9qfSsisiW^Jk(1uI1KM)h0d_W7}3M$(Kz=7Oe`h=?L2ZC$_s&*Kn_>NK>QOq zq4Ex(+67czFl!H@^`i*>1FK#{+9b+XQ2r7Eqf5vfSMw`s zlB-rhnyc_SY?yND%KMz!@(zbB3y#0MlSHkmI^}MdiR3RMV@9hY18ES5R<^vABp@x+ z8Duf)6(p3R6awi$d%S|;m%xVAgzvB^oxN*g4~ZL$ zHif3ZJ=T6Que_JglvS(rtaOuKktlwXg*~5l_uLN$-9qLkZ7?2jZCP8uAXVW&B~{)} z?q%H1YG6Ga=WYUSu2DU_=H>0#gVYUF&=n7FUqVN(dilYi=Hc^RzS+Kru6p>AJ&CB) zXECMqRdo{O0AED*c|?48P8c_afpjPd_EjGbe)RL=ShBvqMUo{*xVDt2S0|)FFVtGP zqg}K&qJd4cZ0*fx;6+O4e&HEbXPl6=#6ZGJNYt+T)X#kDP4#o1h?&ur8=NUMPy4}~ zM?BpoQYLthnP5nw%nn8(k)tEOfk@ShR;XE21H7zgK_24a8B*qZNZ8__kCcQb;=uaJ zrw}H^VCd#yd^4ekC<@~Rar~dSYmp%lJpjBO5gv)*r@4tLsV`T_BQ`Z`2YDzlFdX)G zE5xK$s4niV0sA1AO2bk`asu%A#EMjD6ZIgNac7hvNd#AJOK)pW;;k{=XSUNiO9O&h zOT8(Ha)A~&q>|gLfXrn&sU)9KMZ7UyRF4!+M#x4pAAkZTwG$GF>(P;Eu3J&K+rpiA zj$3Xm)|qalqCmUMnh>!1Ru9yC=lW_ZBX^p4cg&lrJF3E|IGDAVq>e2nm}~58%r~kF zP8ACN$wDQkkSj+=KIW2U4z|k&RPwRTq`DQyH%4xo>0-MmYdL30rVGE7u$W@I(}8g6=3n+#ZM_;1E`Dxikjz;=O=D2qA95ftbqj)V3PtwFqzKik)hd*J6CX z;m7Zwg1;<~2;6pdoF7Q=qVS5_M+}ChsKbttJC*Dd`Mh6rQv!0qmDiO{{EM6Kb%MF( z=?UmJWTuiOm#A&gU}VjW2i9xc2w4ndD_87tz8Oq6wl8SRCQoT*cCxF$%GuPUmaL>x z3b`mNqB}k4KczwFwMtqc*FXrVeUo0XZjIRQqJj4iek(=~6UdU_Ds>ocZ=H*<| zw6%R{zC3p<7 zew@sl?&L06jIgH03JDunaI1AzRxBiE;o>wHCqljUCDpun@;N6!xm#kgW#`V_i|`M3 zzqWH*glSs3Z25}sJhigX8iE{0pRWd*#vNpc`)j( z)&mr~4|iqIxB$ElfmwyH3!aAnonL)_r~lLbp140W*E=uzq<^9B+y2#|X9LfL)_B%> z)_J}kPO|ISAJje{+K_m`w<+|Z|D~Fj1FwYOA?fijUizH;8Z1Fe(F!EY%G6hp_EV&$ zkk*Qnk!n?aZFcx2%I`zCTGgv$N)(8Xk-&}w&r|w64Ac_RLcIM3ygcA0VM4rUd74Fr zIal*A?XSsnFWgA48a%y`jE3J!hu??C&24zlUiTe71LUn^%;-shJZkSAEqe%O{n&`9 zzed{Y@RTq{iQn8?+$T$!HlxOU4&$?EQQJxQSzC2p{%yxQ?%3PWXEFL2xu@eGe*3TV ZEo?KR&<|pEb+pV4dc&Oh1`;>${{^U!ORE3? literal 0 HcmV?d00001