+ * Implements: file:append, file:append-binary, file:append-text, file:append-text-lines + */ +public class FileAppend extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file."); + + public static final FunctionSignature[] signatures = { + // file:append($file, $value) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append($file, $value, $options) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file with serialization options.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append."), + new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-binary($file, $value) + new FunctionSignature( + new QName("append-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends binary data to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value, $encoding) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file, separated by the platform line separator.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines, $encoding) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ) + }; + + public FileAppend(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + checkParentDir(path); + + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("append")) { + return append(path, args); + } else if (isCalledAs("append-binary")) { + return appendBinary(path, args); + } else if (isCalledAs("append-text")) { + return appendText(path, args); + } else if (isCalledAs("append-text-lines")) { + return appendTextLines(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence append(final Path path, final Sequence[] args) throws XPathException { + final Sequence value = args[1]; + final Properties outputProperties = new Properties(); + if (args.length > 2 && !args[2].isEmpty()) { + final Item optionsItem = args[2].itemAt(0); + if (optionsItem instanceof AbstractMapType) { + outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem)); + } else if (optionsItem instanceof NodeValue) { + SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties); + } + } + + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND)), + StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(sax, sax); + + for (final SequenceIterator i = value.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.NODE)) { + serializer.toSAX((NodeValue) item); + } else { + writer.write(item.getStringValue()); + } + } + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final IOException | SAXException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendBinary(final Path path, final Sequence[] args) throws XPathException { + final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0); + try (final OutputStream os = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + final InputStream is = binaryValue.getInputStream()) { + is.transferTo(os); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendText(final Path path, final Sequence[] args) throws XPathException { + final String text = args[1].getStringValue(); + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + writer.write(text); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + final String lineSep = System.lineSeparator(); + for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) { + writer.write(i.nextItem().getStringValue()); + writer.write(lineSep); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void checkParentDir(final Path path) throws XPathException { + final Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent directory does not exist: " + parent.toAbsolutePath()); + } + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java new file mode 100644 index 00000000000..412e16f4c0e --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java @@ -0,0 +1,323 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Base64BinaryValueType; +import org.exist.xquery.value.BinaryValueFromBinaryString; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +/** + * EXPath File Module 4.0 - Input functions. + *
+ * Implements: file:read-text, file:read-text-lines, file:read-binary + */ +public class FileIO extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, + "The path to the file."); + private static final FunctionParameterSequenceType ENCODING_PARAM = + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, + "The character encoding. Default: UTF-8."); + + public static final FunctionSignature[] signatures = { + // file:read-text($file as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text($file as xs:string, $encoding as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-binary($file as xs:string) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as binary.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary starting at the given byte offset.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer, $length as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary with offset and length.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0."), + new FunctionParameterSequenceType("length", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The number of bytes to read.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ) + }; + + public FileIO(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "File not found: " + path.toAbsolutePath()); + } + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("read-text")) { + return readText(path, args); + } else if (isCalledAs("read-text-lines")) { + return readTextLines(path, args); + } else if (isCalledAs("read-binary")) { + return readBinary(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence readText(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Normalize newlines per spec: CR or CRLF -> LF + final String normalized = content.replace("\r\n", "\n").replace("\r", "\n"); + return new StringValue(this, normalized); + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Split at newline boundaries per spec + final String[] lines = content.split("\r\n|\r|\n", -1); + final ValueSequence result = new ValueSequence(lines.length); + // If file ends with newline, last split element is empty - exclude it per spec + final int count = (lines.length > 0 && lines[lines.length - 1].isEmpty()) ? lines.length - 1 : lines.length; + for (int i = 0; i < count; i++) { + result.add(new StringValue(this, lines[i])); + } + return result; + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readBinary(final Path path, final Sequence[] args) throws XPathException { + final long offset = args.length > 1 && !args[1].isEmpty() ? args[1].itemAt(0).toJavaObject(Long.class) : 0; + final boolean hasLength = args.length > 2 && !args[2].isEmpty(); + final long length = hasLength ? args[2].itemAt(0).toJavaObject(Long.class) : -1; + + try { + final long fileSize = Files.size(path); + validateBinaryRange(offset, length, hasLength, fileSize); + + final byte[] data = readBinaryData(path, offset, hasLength, length, fileSize); + final String base64 = java.util.Base64.getEncoder().encodeToString(data); + return new BinaryValueFromBinaryString(this, new Base64BinaryValueType(), base64); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private void validateBinaryRange(final long offset, final long length, final boolean hasLength, final long fileSize) throws XPathException { + if (offset < 0 || offset > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset " + offset + " is out of range for file of size " + fileSize); + } + if (hasLength && length < 0) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Length must not be negative: " + length); + } + if (hasLength && offset + length > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset + length exceeds file size: " + (offset + length) + " > " + fileSize); + } + } + + private byte[] readBinaryData(final Path path, final long offset, final boolean hasLength, final long length, final long fileSize) throws IOException { + if (offset == 0 && !hasLength) { + return Files.readAllBytes(path); + } + try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) { + raf.seek(offset); + final int readLen = hasLength ? (int) length : (int) (fileSize - offset); + final byte[] data = new byte[readLen]; + raf.readFully(data); + return data; + } + } + + /** + * Reads a file as text with the given encoding. + * If fallback is true, malformed byte sequences and XML-illegal characters + * are replaced with U+FFFD. Otherwise, an IOException is thrown if the file + * contains malformed bytes or XML-illegal characters. + */ + private String readFileText(final Path path, final Charset encoding, final boolean fallback) throws IOException { + final String content; + if (fallback) { + final java.nio.charset.CharsetDecoder decoder = encoding.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE) + .replaceWith("\uFFFD"); + final byte[] bytes = Files.readAllBytes(path); + content = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + // Replace XML-illegal characters with U+FFFD + return replaceXmlIllegalChars(content); + } else { + content = Files.readString(path, encoding); + // Check for XML-illegal characters + checkXmlIllegalChars(content); + return content; + } + } + + /** + * Check if a string contains characters illegal in XML 1.0 and throw IOException if so. + * XML 1.0 allows: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + */ + private void checkXmlIllegalChars(final String text) throws IOException { + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + if (c >= 0xFFFE) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + } + } + + /** + * Replace characters illegal in XML 1.0 with U+FFFD. + */ + private String replaceXmlIllegalChars(final String text) { + final StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if ((c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) || c >= 0xFFFE) { + sb.append('\uFFFD'); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java new file mode 100644 index 00000000000..ab3546f7fcd --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java @@ -0,0 +1,521 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.file.expath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +/** + * EXPath File Module 4.0 - File/directory manipulation and listing functions. + *
+ * Implements: file:copy, file:move, file:delete, file:create-dir, file:create-temp-dir,
+ * file:create-temp-file, file:list, file:children, file:descendants
+ */
+public class FileManipulation extends BasicFunction {
+
+ private static final FunctionParameterSequenceType PATH_PARAM =
+ new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file or directory path.");
+
+ public static final FunctionSignature[] signatures = {
+ // file:copy($source, $target)
+ new FunctionSignature(
+ new QName("copy", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Copies a file or directory. If the target exists it is overwritten.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."),
+ new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:move($source, $target)
+ new FunctionSignature(
+ new QName("move", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Moves a file or directory.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."),
+ new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:delete($path)
+ new FunctionSignature(
+ new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Deletes a file or empty directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:delete($path, $recursive)
+ new FunctionSignature(
+ new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Deletes a file or directory. If $recursive is true, non-empty directories are removed recursively.",
+ new SequenceType[]{
+ PATH_PARAM,
+ new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE,
+ "If true, delete directories recursively. Default: false.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:create-dir($dir)
+ new FunctionSignature(
+ new QName("create-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Creates a directory, including any necessary parent directories.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:create-temp-dir($prefix, $suffix)
+ new FunctionSignature(
+ new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Creates a temporary directory in the system default temp directory.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."),
+ new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.")
+ ),
+ // file:create-temp-dir($prefix, $suffix, $dir)
+ new FunctionSignature(
+ new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Creates a temporary directory.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."),
+ new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name."),
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.")
+ ),
+ // file:create-temp-file($prefix, $suffix)
+ new FunctionSignature(
+ new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Creates a temporary file in the system default temp directory.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."),
+ new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.")
+ ),
+ // file:create-temp-file($prefix, $suffix, $dir)
+ new FunctionSignature(
+ new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Creates a temporary file.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."),
+ new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name."),
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.")
+ ),
+ // file:list($dir)
+ new FunctionSignature(
+ new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Lists the contents of a directory as relative paths.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.")
+ ),
+ // file:list($dir, $recursive)
+ new FunctionSignature(
+ new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Lists the contents of a directory, optionally recursively.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."),
+ new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.")
+ ),
+ // file:list($dir, $recursive, $pattern)
+ new FunctionSignature(
+ new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Lists the contents of a directory matching a glob pattern.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."),
+ new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false."),
+ new FunctionParameterSequenceType("pattern", Type.STRING, Cardinality.ZERO_OR_ONE, "A glob pattern to filter results.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of matching directory contents.")
+ ),
+ // file:children($path)
+ new FunctionSignature(
+ new QName("children", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the paths of immediate children of a directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of children.")
+ ),
+ // file:descendants($path)
+ new FunctionSignature(
+ new QName("descendants", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the paths of all descendants of a directory recursively.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of all descendants.")
+ ),
+ // file:list-roots()
+ new FunctionSignature(
+ new QName("list-roots", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the root directories of the file system.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The root directories.")
+ )
+ };
+
+ public FileManipulation(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ ExpathFileModuleHelper.checkDbaRole(context, this);
+
+ if (isCalledAs("list-roots")) {
+ return listRoots();
+ }
+
+ final String pathStr = args[0].getStringValue();
+ final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context);
+
+ if (isCalledAs("copy")) {
+ return copy(path, args);
+ } else if (isCalledAs("move")) {
+ return move(path, args);
+ } else if (isCalledAs("delete")) {
+ return delete(path, args);
+ } else if (isCalledAs("create-dir")) {
+ return createDir(path);
+ } else if (isCalledAs("create-temp-dir")) {
+ return createTempDir(args);
+ } else if (isCalledAs("create-temp-file")) {
+ return createTempFile(args);
+ } else if (isCalledAs("list")) {
+ return list(path, args);
+ } else if (isCalledAs("children")) {
+ return children(path);
+ } else if (isCalledAs("descendants")) {
+ return descendants(path);
+ }
+
+ throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart());
+ }
+
+ private Sequence copy(final Path source, final Sequence[] args) throws XPathException {
+ if (!Files.exists(source)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Source does not exist: " + source.toAbsolutePath());
+ }
+ final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context);
+
+ // Check target parent directory exists
+ final Path targetParent = target.toAbsolutePath().getParent();
+ if (targetParent != null && !Files.isDirectory(targetParent)) {
+ throw new XPathException(this, ExpathFileErrorCode.NO_DIR,
+ "Target parent directory does not exist: " + targetParent);
+ }
+
+ try {
+ if (Files.isDirectory(source)) {
+ copyDirectory(source, target);
+ } else {
+ // If target is an existing directory, copy into it
+ final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target;
+ Files.copy(source, actualTarget, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private void copyDirectory(final Path source, final Path target) throws IOException {
+ Files.walkFileTree(source, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException {
+ final Path targetDir = target.resolve(source.relativize(dir));
+ Files.createDirectories(targetDir);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
+ Files.copy(file, target.resolve(source.relativize(file)), StandardCopyOption.REPLACE_EXISTING);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
+ private Sequence move(final Path source, final Sequence[] args) throws XPathException {
+ if (!Files.exists(source)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Source does not exist: " + source.toAbsolutePath());
+ }
+ final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context);
+
+ // Check target parent directory exists
+ final Path targetParent = target.toAbsolutePath().getParent();
+ if (targetParent != null && !Files.isDirectory(targetParent)) {
+ throw new XPathException(this, ExpathFileErrorCode.NO_DIR,
+ "Target parent directory does not exist: " + targetParent);
+ }
+
+ try {
+ // If target is an existing directory, move into it
+ final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target;
+ Files.move(source, actualTarget, StandardCopyOption.REPLACE_EXISTING);
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private Sequence delete(final Path path, final Sequence[] args) throws XPathException {
+ if (!Files.exists(path)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Path does not exist: " + path.toAbsolutePath());
+ }
+ final boolean recursive = args.length > 1 && !args[1].isEmpty()
+ && args[1].itemAt(0).toJavaObject(Boolean.class);
+
+ try {
+ if (Files.isDirectory(path)) {
+ if (recursive) {
+ try (final Stream
+ * Implements: file:name, file:parent, file:path-to-native, file:path-to-uri, file:resolve-path
+ */
+public class FilePaths extends BasicFunction {
+
+ private static final FunctionParameterSequenceType PATH_PARAM =
+ new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file path.");
+
+ public static final FunctionSignature[] signatures = {
+ // file:name($path as xs:string) as xs:string
+ new FunctionSignature(
+ new QName("name", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the name of a file or directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file or directory name.")
+ ),
+ // file:parent($path as xs:string) as xs:string?
+ new FunctionSignature(
+ new QName("parent", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the parent directory of a path.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the parent directory path, or empty for root.")
+ ),
+ // file:path-to-native($path as xs:string) as xs:string
+ new FunctionSignature(
+ new QName("path-to-native", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the native, canonical path.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the native path.")
+ ),
+ // file:path-to-uri($path as xs:string) as xs:anyURI
+ new FunctionSignature(
+ new QName("path-to-uri", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the path as a file:// URI.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.ANY_URI, Cardinality.EXACTLY_ONE, "the file:// URI.")
+ ),
+ // file:resolve-path($path as xs:string) as xs:string
+ new FunctionSignature(
+ new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Resolves a relative path against the current working directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.")
+ ),
+ // file:resolve-path($path as xs:string, $base as xs:string) as xs:string
+ new FunctionSignature(
+ new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Resolves a relative path against a base directory.",
+ new SequenceType[]{
+ PATH_PARAM,
+ new FunctionParameterSequenceType("base", Type.STRING, Cardinality.ZERO_OR_ONE, "The base directory to resolve against.")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.")
+ )
+ };
+
+ public FilePaths(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ ExpathFileModuleHelper.checkDbaRole(context, this);
+
+ final String pathStr = args[0].getStringValue();
+ final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context);
+
+ if (isCalledAs("name")) {
+ final Path fileName = path.getFileName();
+ return new StringValue(this, fileName != null ? fileName.toString() : "");
+ } else if (isCalledAs("parent")) {
+ final Path absPath = path.toAbsolutePath().normalize();
+ final Path parent = absPath.getParent();
+ if (parent == null) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return new StringValue(this, parent.toString() + File.separator);
+ } else if (isCalledAs("path-to-native")) {
+ if (!Files.exists(path)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Path does not exist: " + path.toAbsolutePath());
+ }
+ try {
+ return new StringValue(this, path.toRealPath().toString());
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ } else if (isCalledAs("path-to-uri")) {
+ final Path abs = path.toAbsolutePath().normalize();
+ return new AnyURIValue(this, abs.toUri().toString());
+ } else if (isCalledAs("resolve-path")) {
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final Path base = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context);
+ return new StringValue(this, base.resolve(path).toAbsolutePath().normalize().toString());
+ }
+ return new StringValue(this, path.toAbsolutePath().normalize().toString());
+ }
+
+ throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart());
+ }
+}
diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java
new file mode 100644
index 00000000000..e612a24a9a0
--- /dev/null
+++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java
@@ -0,0 +1,184 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.modules.file.expath;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Date;
+import java.util.stream.Stream;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.DateTimeValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * EXPath File Module 4.0 - File Properties functions.
+ *
+ * Implements: file:exists, file:is-dir, file:is-file, file:is-absolute, file:last-modified, file:size
+ */
+public class FileProperties extends BasicFunction {
+
+ private static final FunctionParameterSequenceType PATH_PARAM =
+ new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE,
+ "The file path.");
+
+ public static final FunctionSignature[] signatures = {
+ // file:exists($path as xs:string) as xs:boolean
+ new FunctionSignature(
+ new QName("exists", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Tests whether a path exists.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true if the path exists.")
+ ),
+ // file:is-dir($path as xs:string) as xs:boolean
+ new FunctionSignature(
+ new QName("is-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Tests whether a path points to a directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true if the path is a directory.")
+ ),
+ // file:is-file($path as xs:string) as xs:boolean
+ new FunctionSignature(
+ new QName("is-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Tests whether a path points to a regular file.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true if the path is a regular file.")
+ ),
+ // file:is-absolute($path as xs:string) as xs:boolean
+ new FunctionSignature(
+ new QName("is-absolute", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Tests whether a path is absolute.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true if the path is absolute.")
+ ),
+ // file:last-modified($path as xs:string) as xs:dateTime
+ new FunctionSignature(
+ new QName("last-modified", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the last modification time of a file or directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE,
+ "the last modification time.")
+ ),
+ // file:size($path as xs:string) as xs:integer
+ new FunctionSignature(
+ new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the byte size of a file, or 0 for a directory.",
+ new SequenceType[]{PATH_PARAM},
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE,
+ "the file size in bytes.")
+ ),
+ // file:size($path as xs:string, $recursive as xs:boolean) as xs:integer
+ new FunctionSignature(
+ new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the byte size of a file, or for a directory the recursive size if $recursive is true.",
+ new SequenceType[]{
+ PATH_PARAM,
+ new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE,
+ "If true and path is a directory, compute recursive size.")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE,
+ "the file or directory size in bytes.")
+ )
+ };
+
+ public FileProperties(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ ExpathFileModuleHelper.checkDbaRole(context, this);
+
+ final String pathStr = args[0].getStringValue();
+ final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context);
+
+ if (isCalledAs("exists")) {
+ return BooleanValue.valueOf(Files.exists(path));
+ } else if (isCalledAs("is-dir")) {
+ return BooleanValue.valueOf(Files.isDirectory(path));
+ } else if (isCalledAs("is-file")) {
+ return BooleanValue.valueOf(Files.isRegularFile(path));
+ } else if (isCalledAs("is-absolute")) {
+ return BooleanValue.valueOf(path.isAbsolute());
+ } else if (isCalledAs("last-modified")) {
+ if (!Files.exists(path)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Path does not exist: " + path.toAbsolutePath());
+ }
+ try {
+ final FileTime ft = Files.getLastModifiedTime(path);
+ return new DateTimeValue(this, new Date(ft.toMillis()));
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ } else if (isCalledAs("size")) {
+ if (!Files.exists(path)) {
+ throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND,
+ "Path does not exist: " + path.toAbsolutePath());
+ }
+ try {
+ if (Files.isDirectory(path)) {
+ final boolean recursive = args.length > 1 && !args[1].isEmpty()
+ && args[1].itemAt(0).toJavaObject(Boolean.class);
+ if (recursive) {
+ try (final Stream
+ * Implements: file:dir-separator, file:line-separator, file:path-separator,
+ * file:temp-dir, file:base-dir, file:current-dir
+ */
+public class FileSystemProperties extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ // file:dir-separator() as xs:string
+ new FunctionSignature(
+ new QName("dir-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the directory separator used by the operating system.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the directory separator.")
+ ),
+ // file:line-separator() as xs:string
+ new FunctionSignature(
+ new QName("line-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the line separator used by the operating system.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the line separator.")
+ ),
+ // file:path-separator() as xs:string
+ new FunctionSignature(
+ new QName("path-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the path separator used by the operating system.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the path separator.")
+ ),
+ // file:temp-dir() as xs:string
+ new FunctionSignature(
+ new QName("temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the path of the temporary directory.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the temporary directory path.")
+ ),
+ // file:base-dir() as xs:string?
+ new FunctionSignature(
+ new QName("base-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the base directory of the current query, or empty if not available.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the base directory path.")
+ ),
+ // file:current-dir() as xs:string
+ new FunctionSignature(
+ new QName("current-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Returns the current working directory.",
+ new SequenceType[]{},
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the current working directory.")
+ )
+ };
+
+ public FileSystemProperties(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ ExpathFileModuleHelper.checkDbaRole(context, this);
+
+ if (isCalledAs("dir-separator")) {
+ return new StringValue(this, File.separator);
+ } else if (isCalledAs("line-separator")) {
+ return new StringValue(this, System.lineSeparator());
+ } else if (isCalledAs("path-separator")) {
+ return new StringValue(this, File.pathSeparator);
+ } else if (isCalledAs("temp-dir")) {
+ return new StringValue(this, System.getProperty("java.io.tmpdir") + File.separator);
+ } else if (isCalledAs("base-dir")) {
+ try {
+ final String baseURI = context.getBaseURI().getStringValue();
+ if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) {
+ final Path basePath = Paths.get(new URI(baseURI));
+ final Path parent = basePath.getParent();
+ if (parent != null) {
+ return new StringValue(this, parent.toString() + File.separator);
+ }
+ }
+ } catch (final Exception e) {
+ // Fall through to return empty
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ } else if (isCalledAs("current-dir")) {
+ // If a file: base URI is set (e.g., sandpit), use its directory as the working directory
+ try {
+ final String baseURI = context.getBaseURI().getStringValue();
+ if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) {
+ final Path basePath = Paths.get(new URI(baseURI));
+ final Path dir = java.nio.file.Files.isDirectory(basePath) ? basePath : basePath.getParent();
+ if (dir != null) {
+ return new StringValue(this, dir.toString() + File.separator);
+ }
+ }
+ } catch (final Exception ignored) {
+ // Fall through to JVM CWD
+ }
+ return new StringValue(this, System.getProperty("user.dir") + File.separator);
+ }
+
+ throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart());
+ }
+}
diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java
new file mode 100644
index 00000000000..1b24cae555a
--- /dev/null
+++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java
@@ -0,0 +1,296 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.modules.file.expath;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.exist.dom.QName;
+import org.exist.storage.serializers.Serializer;
+import org.exist.util.serializer.SAXSerializer;
+import org.exist.util.serializer.SerializerPool;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.util.SerializerUtils;
+import org.exist.xquery.value.*;
+import org.xml.sax.SAXException;
+
+/**
+ * EXPath File Module 4.0 - Write functions.
+ *
+ * Implements: file:write, file:write-text, file:write-text-lines, file:write-binary
+ */
+public class FileWrite extends BasicFunction {
+
+ private static final FunctionParameterSequenceType FILE_PARAM =
+ new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file.");
+
+ public static final FunctionSignature[] signatures = {
+ // file:write($file, $value)
+ new FunctionSignature(
+ new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a serialized sequence to a file. Creates the file if it does not exist, overwrites it otherwise.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write($file, $value, $options)
+ new FunctionSignature(
+ new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a serialized sequence to a file with serialization options.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write."),
+ new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-text($file, $value)
+ new FunctionSignature(
+ new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a string to a file. Creates the file if it does not exist, overwrites it otherwise.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-text($file, $value, $encoding)
+ new FunctionSignature(
+ new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a string to a file with the specified encoding.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write."),
+ new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-text-lines($file, $values)
+ new FunctionSignature(
+ new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a sequence of strings as lines to a file, separated by the platform line separator.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-text-lines($file, $values, $encoding)
+ new FunctionSignature(
+ new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes a sequence of strings as lines to a file with the specified encoding.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write."),
+ new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-binary($file, $value)
+ new FunctionSignature(
+ new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes binary data to a file. Creates the file if it does not exist, overwrites it otherwise.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ ),
+ // file:write-binary($file, $value, $offset)
+ new FunctionSignature(
+ new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX),
+ "Writes binary data to a file at the given offset.",
+ new SequenceType[]{
+ FILE_PARAM,
+ new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write."),
+ new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The byte offset at which to start writing. Default: 0.")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.")
+ )
+ };
+
+ public FileWrite(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ ExpathFileModuleHelper.checkDbaRole(context, this);
+
+ final String pathStr = args[0].getStringValue();
+ final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context);
+
+ checkParentDir(path);
+
+ if (Files.isDirectory(path)) {
+ throw new XPathException(this, ExpathFileErrorCode.IS_DIR,
+ "Path is a directory: " + path.toAbsolutePath());
+ }
+
+ if (isCalledAs("write")) {
+ return write(path, args);
+ } else if (isCalledAs("write-text")) {
+ return writeText(path, args);
+ } else if (isCalledAs("write-text-lines")) {
+ return writeTextLines(path, args);
+ } else if (isCalledAs("write-binary")) {
+ return writeBinary(path, args);
+ }
+
+ throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart());
+ }
+
+ private Sequence write(final Path path, final Sequence[] args) throws XPathException {
+ final Sequence value = args[1];
+ final Properties outputProperties = new Properties();
+ if (args.length > 2 && !args[2].isEmpty()) {
+ final Item optionsItem = args[2].itemAt(0);
+ if (optionsItem instanceof AbstractMapType) {
+ outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem));
+ } else if (optionsItem instanceof NodeValue) {
+ SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties);
+ }
+ }
+
+ final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class);
+ try {
+ final Serializer serializer = context.getBroker().borrowSerializer();
+ try (final Writer writer = new OutputStreamWriter(
+ new BufferedOutputStream(Files.newOutputStream(path)), StandardCharsets.UTF_8)) {
+ sax.setOutput(writer, outputProperties);
+ serializer.setProperties(outputProperties);
+ serializer.setSAXHandlers(sax, sax);
+
+ for (final SequenceIterator i = value.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ if (Type.subTypeOf(item.getType(), Type.NODE)) {
+ serializer.toSAX((NodeValue) item);
+ } else {
+ writer.write(item.getStringValue());
+ }
+ }
+ } finally {
+ context.getBroker().returnSerializer(serializer);
+ }
+ } catch (final IOException | SAXException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ } finally {
+ SerializerPool.getInstance().returnObject(sax);
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private Sequence writeText(final Path path, final Sequence[] args) throws XPathException {
+ final String text = args[1].getStringValue();
+ final Charset encoding = getEncoding(args, 2);
+ try {
+ Files.writeString(path, text, encoding);
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private Sequence writeTextLines(final Path path, final Sequence[] args) throws XPathException {
+ final Charset encoding = getEncoding(args, 2);
+ try (final Writer writer = new OutputStreamWriter(
+ new BufferedOutputStream(Files.newOutputStream(path)), encoding)) {
+ final String lineSep = System.lineSeparator();
+ for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) {
+ writer.write(i.nextItem().getStringValue());
+ writer.write(lineSep);
+ }
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private Sequence writeBinary(final Path path, final Sequence[] args) throws XPathException {
+ final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0);
+ final long offset = args.length > 2 && !args[2].isEmpty() ? args[2].itemAt(0).toJavaObject(Long.class) : 0;
+
+ try {
+ if (offset == 0) {
+ try (final OutputStream os = Files.newOutputStream(path);
+ final InputStream is = binaryValue.getInputStream()) {
+ is.transferTo(os);
+ }
+ } else {
+ if (offset < 0) {
+ throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE,
+ "Offset must not be negative: " + offset);
+ }
+ if (Files.exists(path)) {
+ final long fileSize = Files.size(path);
+ if (offset > fileSize) {
+ throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE,
+ "Offset " + offset + " exceeds file size " + fileSize);
+ }
+ }
+ try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw");
+ final InputStream is = binaryValue.getInputStream()) {
+ raf.seek(offset);
+ is.transferTo(new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ raf.write(b);
+ }
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ raf.write(b, off, len);
+ }
+ });
+ }
+ }
+ } catch (final IOException e) {
+ throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage());
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private void checkParentDir(final Path path) throws XPathException {
+ final Path parent = path.getParent();
+ if (parent != null && !Files.isDirectory(parent)) {
+ throw new XPathException(this, ExpathFileErrorCode.NO_DIR,
+ "Parent directory does not exist: " + parent.toAbsolutePath());
+ }
+ }
+
+ private Charset getEncoding(final Sequence[] args, final int index) throws XPathException {
+ if (args.length > index && !args[index].isEmpty()) {
+ return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this);
+ }
+ return StandardCharsets.UTF_8;
+ }
+}
diff --git a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml
index 399137a7230..625c11b567b 100644
--- a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml
+++ b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml
@@ -750,6 +750,7 @@