DIRSTUDIO-1296: Decode RFC 4517 Postal Address syntax upon export (#32)

diff --git a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportCsvRunnable.java b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportCsvRunnable.java
index 7d4382a..e6195ba 100644
--- a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportCsvRunnable.java
+++ b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportCsvRunnable.java
@@ -30,7 +30,9 @@
 import java.util.Map;
 
 import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.directory.api.ldap.model.constants.SchemaConstants;
 import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.schema.AttributeType;
 import org.apache.directory.studio.common.core.jobs.StudioProgressMonitor;
 import org.apache.directory.studio.connection.core.Connection;
 import org.apache.directory.studio.connection.core.jobs.StudioConnectionRunnableWithProgress;
@@ -41,6 +43,7 @@
 import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection;
 import org.apache.directory.studio.ldapbrowser.core.model.SearchParameter;
 import org.apache.directory.studio.ldapbrowser.core.utils.JNDIUtils;
+import org.apache.directory.studio.ldapbrowser.core.utils.Utils;
 import org.apache.directory.studio.ldifparser.LdifUtils;
 import org.apache.directory.studio.ldifparser.model.LdifEnumeration;
 import org.apache.directory.studio.ldifparser.model.container.LdifContainer;
@@ -288,6 +291,11 @@
             if ( attributeMap.containsKey( oidString ) )
             {
                 String value = attributeMap.get( oidString );
+                AttributeType type = browserConnection.getSchema().getAttributeTypeDescription( attributeName );
+                if ( SchemaConstants.POSTAL_ADDRESS_SYNTAX.equals( type.getSyntaxOid() ) )
+                {
+                    value = Utils.decodePostalAddress( value, lineSeparator );
+                }
                 appendValue( quoteCharacter, sb, value );
             }
 
diff --git a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportOdfRunnable.java b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportOdfRunnable.java
index 191eab3..abbed1d 100644
--- a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportOdfRunnable.java
+++ b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportOdfRunnable.java
@@ -26,7 +26,9 @@
 import java.util.Map;
 
 import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.directory.api.ldap.model.constants.SchemaConstants;
 import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.schema.AttributeType;
 import org.apache.directory.studio.common.core.jobs.StudioProgressMonitor;
 import org.apache.directory.studio.connection.core.Connection;
 import org.apache.directory.studio.connection.core.jobs.StudioConnectionRunnableWithProgress;
@@ -36,6 +38,7 @@
 import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection;
 import org.apache.directory.studio.ldapbrowser.core.model.SearchParameter;
 import org.apache.directory.studio.ldapbrowser.core.utils.JNDIUtils;
+import org.apache.directory.studio.ldapbrowser.core.utils.Utils;
 import org.apache.directory.studio.ldifparser.model.LdifEnumeration;
 import org.apache.directory.studio.ldifparser.model.container.LdifContainer;
 import org.apache.directory.studio.ldifparser.model.container.LdifContentRecord;
@@ -290,6 +293,13 @@
                 short cellNum = headerRowAttributeNameMap.get( attributeName ).shortValue();
                 Cell cell = row.getCellByIndex( cellNum );
                 cell.setValueType( ValueType.STRING.name() );
+                AttributeType type = browserConnection.getSchema().getAttributeTypeDescription( attributeName );
+                if ( SchemaConstants.POSTAL_ADDRESS_SYNTAX.equals( type.getSyntaxOid() ) )
+                {
+                    // https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html#__RefHeading__1017970_715980110
+                    value = Utils.decodePostalAddress( value, "\n" ); //$NON-NLS-1$
+                    cell.setTextWrapped( true );
+                }
                 cell.setStringValue( value );
             }
         }
diff --git a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportXlsRunnable.java b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportXlsRunnable.java
index cde55db..2030022 100644
--- a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportXlsRunnable.java
+++ b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/jobs/ExportXlsRunnable.java
@@ -27,7 +27,9 @@
 import java.util.Map;
 
 import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.directory.api.ldap.model.constants.SchemaConstants;
 import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.schema.AttributeType;
 import org.apache.directory.studio.common.core.jobs.StudioProgressMonitor;
 import org.apache.directory.studio.connection.core.Connection;
 import org.apache.directory.studio.connection.core.jobs.StudioConnectionRunnableWithProgress;
@@ -37,6 +39,7 @@
 import org.apache.directory.studio.ldapbrowser.core.model.IBrowserConnection;
 import org.apache.directory.studio.ldapbrowser.core.model.SearchParameter;
 import org.apache.directory.studio.ldapbrowser.core.utils.JNDIUtils;
+import org.apache.directory.studio.ldapbrowser.core.utils.Utils;
 import org.apache.directory.studio.ldifparser.model.LdifEnumeration;
 import org.apache.directory.studio.ldifparser.model.container.LdifContainer;
 import org.apache.directory.studio.ldifparser.model.container.LdifContentRecord;
@@ -45,6 +48,7 @@
 import org.apache.poi.hssf.usermodel.HSSFSheet;
 import org.apache.poi.hssf.usermodel.HSSFWorkbook;
 import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
 import org.eclipse.core.runtime.Preferences;
 
 
@@ -280,6 +284,9 @@
         Map<String, String> attributeMap = ExportCsvRunnable.getAttributeMap( null, record, valueDelimiter, "UTF-16", //$NON-NLS-1$
             binaryEncoding );
 
+        CellStyle wrapStyle = sheet.getWorkbook().createCellStyle();
+        wrapStyle.setWrapText( true );
+
         // output attributes
         HSSFRow row = sheet.createRow( sheet.getLastRowNum() + 1 );
         if ( exportDn )
@@ -303,6 +310,13 @@
             {
                 int cellNum = headerRowAttributeNameMap.get( attributeName ).shortValue();
                 HSSFCell cell = createStringCell( row, cellNum );
+                AttributeType type = browserConnection.getSchema().getAttributeTypeDescription( attributeName );
+                if ( SchemaConstants.POSTAL_ADDRESS_SYNTAX.equals( type.getSyntaxOid() ) )
+                {
+                    // https://poi.apache.org/components/spreadsheet/quick-guide.html#NewLinesInCells
+                    value = Utils.decodePostalAddress( value, "\n" ); //$NON-NLS-1$
+                    cell.setCellStyle( wrapStyle );
+                }
                 cell.setCellValue( value );
             }
         }
diff --git a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/utils/Utils.java b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/utils/Utils.java
index 2cc7bad..bb20a39 100644
--- a/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/utils/Utils.java
+++ b/plugins/ldapbrowser.core/src/main/java/org/apache/directory/studio/ldapbrowser/core/utils/Utils.java
@@ -657,4 +657,31 @@
             return LdifAttrValLine.create( attribute.getDescription(), value.getStringValue() );
         }
     }
+
+
+    /**
+     * Decodes the RFC 4517 Postal Address syntax.
+     *
+     * <pre>
+     * PostalAddress = line *( DOLLAR line )
+     * line          = 1*line-char
+     * line-char     = %x00-23
+     *                 / (%x5C "24")  ; escaped "$"
+     *                 / %x25-5B
+     *                 / (%x5C "5C")  ; escaped "\"
+     *                 / %x5D-7F
+     *                 / UTFMB
+     * </pre>
+     *
+     * @param input the encoded string
+     * @param separator the separator to output between address lines
+     * @return the decoded string
+     */
+    public static String decodePostalAddress( String input, String separator )
+    {
+        return input.replace( "$", separator ) //$NON-NLS-1$
+            .replace( "\\24", "$" ) //$NON-NLS-1$ //$NON-NLS-2$
+            .replace( "\\5C", "\\" ) //$NON-NLS-1$ //$NON-NLS-2$
+            .replace( "\\5c", "\\" ); //$NON-NLS-1$ //$NON-NLS-2$
+    }
 }
diff --git a/plugins/ldapbrowser.core/src/test/java/org/apache/directory/studio/ldapbrowser/core/utils/UtilsTest.java b/plugins/ldapbrowser.core/src/test/java/org/apache/directory/studio/ldapbrowser/core/utils/UtilsTest.java
new file mode 100644
index 0000000..d7c76ca
--- /dev/null
+++ b/plugins/ldapbrowser.core/src/test/java/org/apache/directory/studio/ldapbrowser/core/utils/UtilsTest.java
@@ -0,0 +1,54 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ *
+ */
+package org.apache.directory.studio.ldapbrowser.core.utils;
+
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.junit.jupiter.api.Test;
+
+
+public class UtilsTest
+{
+    @Test
+    public void testPostalAddressTrivial()
+    {
+        assertEquals( "abc", Utils.decodePostalAddress( "abc", "!" ) );
+    }
+
+
+    @Test
+    public void testPostalAddressEscaped()
+    {
+        assertEquals( "!", Utils.decodePostalAddress( "$", "!" ) );
+        assertEquals( "$", Utils.decodePostalAddress( "\\24", "!" ) );
+        assertEquals( "\\", Utils.decodePostalAddress( "\\5C", "!" ) );
+        assertEquals( "\\", Utils.decodePostalAddress( "\\5c", "!" ) );
+    }
+
+
+    @Test
+    public void testPostalAddressRfcExamples()
+    {
+        assertEquals( "1234 Main St.\nAnytown, CA 12345\nUSA",
+            Utils.decodePostalAddress( "1234 Main St.$Anytown, CA 12345$USA", "\n" ) );
+        assertEquals( "$1,000,000 Sweepstakes\nPO Box 1000000\nAnytown, CA 12345\nUSA",
+            Utils.decodePostalAddress( "\\241,000,000 Sweepstakes$PO Box 1000000$Anytown, CA 12345$USA", "\n" ) );
+    }
+}
diff --git a/tests/test.integration.ui/src/main/java/org/apache/directory/studio/test/integration/ui/ImportExportTest.java b/tests/test.integration.ui/src/main/java/org/apache/directory/studio/test/integration/ui/ImportExportTest.java
index fcf5291..0ff46aa 100644
--- a/tests/test.integration.ui/src/main/java/org/apache/directory/studio/test/integration/ui/ImportExportTest.java
+++ b/tests/test.integration.ui/src/main/java/org/apache/directory/studio/test/integration/ui/ImportExportTest.java
@@ -625,7 +625,9 @@
         store.setDefault( BrowserCoreConstants.PREFERENCE_FORMAT_CSV_ENCODING, "UTF-8" );
 
         URL url = Platform.getInstanceLocation().getURL();
-        final String file = url.getFile() + "ImportExportTest" + server.getType().name() + ".csv";
+        final String file = url.getFile()
+            + "ImportExportShouldPrefixFormulaWithApostropheTest"
+            + server.getType().name() + ".csv";
 
         browserViewBot.selectEntry( path( GERMAN_UMLAUT_DN ) );
 
@@ -647,6 +649,44 @@
 
 
     /**
+     * Export to CSV and checks that RFC 4517 Postal Address syntax is decoded.
+     */
+    @ParameterizedTest
+    @LdapServersSource
+    public void testExportCsvShouldDecodePostalAddress( TestLdapServer server ) throws Exception
+    {
+        connectionsViewBot.createTestConnection( server );
+        // set CSV encoding explicit to UTF-8, otherwise platform default encoding would be used
+        Preferences store = BrowserCorePlugin.getDefault().getPluginPreferences();
+        store.setDefault( BrowserCoreConstants.PREFERENCE_FORMAT_CSV_ENCODING, "UTF-8" );
+
+        URL url = Platform.getInstanceLocation().getURL();
+        final String file = url.getFile()
+            + "ImportExportShouldDecodePostalAddressTest"
+            + server.getType().name() + ".csv";
+
+        browserViewBot.selectEntry( path( USER1_DN ) );
+
+        // export CSV
+        ExportWizardBot wizardBot = browserViewBot.openExportCsvWizard();
+        assertTrue( wizardBot.isVisible() );
+        wizardBot.setReturningAttributes( "postalAddress" );
+        wizardBot.clickNextButton();
+        wizardBot.typeFile( file );
+        wizardBot.clickFinishButton();
+        wizardBot.waitTillExportFinished( file, 100 );
+
+        List<String> lines = FileUtils.readLines( new File( file ), StandardCharsets.UTF_8 );
+        // verify that the first line is header
+        assertEquals( "dn,postalAddress", lines.get( 0 ) );
+        // verify that the postal address is broken into several lines
+        assertEquals( "\"uid=user.1,ou=users,dc=example,dc=org\",\"Aaccf Amar", lines.get( 1 ) );
+        assertEquals( "27919 Broadway Street", lines.get( 2 ) );
+        assertEquals( "Tallahassee, DE  67698\"", lines.get( 3 ) );
+    }
+
+
+    /**
      * Test for DIRSTUDIO-1160.
      *
      * Attributes silently dropped and not imported when import LDIF and provider is Apache Directory LDAP API.