From bb978ed33fea68e775ec278a130181ca43813d9b Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Thu, 31 Oct 2024 21:07:53 +0100 Subject: Add single merge Single as in merge in one direction, a source and a target. Split merge in to calculating (done by SingleMerge) which returns a number of steps to do. Modifier then applies the steps. Still thinking of pair or two-way merge, need a database for that or you can never remove any files. --- .../main/java/org/the_jk/cleversync/io/Action.kt | 26 +++ .../main/java/org/the_jk/cleversync/io/Modifier.kt | 152 ++++++++++++++++ .../java/org/the_jk/cleversync/io/SingleMerge.kt | 198 +++++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt (limited to 'libs/io/src/main') diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt new file mode 100644 index 0000000..fdce54d --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt @@ -0,0 +1,26 @@ +package org.the_jk.cleversync.io + +sealed interface Action { + // Remove file, noop if name no longer exists + data class RemoveFile(val name: String): Action + + // Remove link, noop if name no longer exists + data class RemoveLink(val name: String): Action + + // Remove directory and its content recursively. Fails if + // not a directory. Noop if name no longer exists. + data class RemoveDir(val name: String): Action + + // Create a symlink called name pointing to target. noop if link pointing to target already exists. + // If overwrite is true, existing link will be overwritten. Fails if a file or directory + // exists with the name. + data class Link(val name: String, val target: String, val overwrite: Boolean = false): Action + + // Create file called name in target and copy content from source. If overwrite is true + // any file already existing will be overwritten with new content. + data class Copy(val name: String, val overwrite: Boolean = false): Action + + // Change directory to name and execute actions there. If create is true and target directory + // does not exist it is created. + data class ChangeDir(val name: String, val actions: List, val create: Boolean = false): Action +} diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt new file mode 100644 index 0000000..d0138b1 --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt @@ -0,0 +1,152 @@ +package org.the_jk.cleversync.io + +import org.the_jk.cleversync.PathUtils +import java.io.IOException + +object Modifier { + fun apply(target: ModifiableDirectory, source: Directory, actions: List): List { + val errors = mutableListOf() + apply(target, source, actions, "", errors) + return errors + } + + @Suppress("CyclomaticComplexMethod", "LongMethod", "NestedBlockDepth") + private fun apply( + target: ModifiableDirectory, + source: Directory, + actions: List, + path: String, + errors: MutableList, + ) { + actions.forEach { action -> + when (action) { + is Action.ChangeDir -> { + val newPath = PathUtils.join(path, action.name) + val sourceDir = source.openDir(action.name) + if (sourceDir == null) { + errors.add("$path/${action.name}: Source directory does not exist") + } else { + val targetDir = if (action.create) { + try { + target.createDirectory(action.name) + } catch (_: IOException) { + target.modifiableOpenDir(action.name) + } + } else { + target.modifiableOpenDir(action.name) + } + if (targetDir == null) { + errors.add( + if (action.create) { + "$newPath: Unable to create directory" + } else { + "$newPath: Target directory does not exist" + } + ) + } else { + apply( + targetDir, + sourceDir, + action.actions, + newPath, + errors + ) + } + } + } + is Action.Copy -> { + val sourceFile = source.openFile(action.name) + if (sourceFile == null) { + errors.add("$path/${action.name}: Unable to open file") + } else { + try { + target.createFile(action.name) + } catch (e: IOException) { + if (action.overwrite) { + target.modifiableOpenFile(action.name) + } else { + errors.add("$path/${action.name}: Unable to create file: ${e.message}") + null + } + }?.let { targetFile -> + try { + val buffer = ByteArray(BUFFER_SIZE) + targetFile.write().use { output -> + sourceFile.read().use { input -> + val got = input.read(buffer) + output.write(buffer, 0, got) + } + output.flush() + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Error writing file: ${e.message}") + } + } + } + } + is Action.Link -> { + try { + target.createLink(action.name, action.target) + } catch (e: IOException) { + val targetLink = target.modifiableOpenLink(action.name) + if (targetLink == null) { + errors.add("$path/${action.name}: Unable to create link: ${e.message}") + } else { + if (targetLink.resolve().path == action.target) { + // No need to do anything + } else { + try { + targetLink.target(action.target) + } catch (e2: IOException) { + errors.add("$path/${action.name}: Unable modify link: ${e2.message}") + } + } + } + } + } + is Action.RemoveDir -> { + try { + if (!target.removeDirectory(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove dir") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove dir: ${e.message}") + } + } + is Action.RemoveFile -> { + try { + if (!target.removeFile(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove file") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove file: ${e.message}") + } + } + is Action.RemoveLink -> { + try { + if (!target.removeLink(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove link") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove link: ${e.message}") + } + } + } + } + } + + private fun exists(target: Directory, name: String): Boolean { + val content = target.list() + return content.directories.any { it.name == name } || + content.files.any { it.name == name } || + content.links.any { it.name == name } + } + + private const val BUFFER_SIZE = 65535 +} diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt new file mode 100644 index 0000000..33c87fc --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt @@ -0,0 +1,198 @@ +package org.the_jk.cleversync.io + +import org.the_jk.cleversync.PathUtils +import java.time.Instant + +object SingleMerge { + fun calculate(target: Directory, source: Directory, options: Options = Options()): List { + return visit(target, source.list(), source, "/", options) + } + + private fun visit( + target: Directory?, + source: Directory.Content, + root: Directory, + path: String, + options: Options, + ) : List { + val targetContent = target?.list() + val targetNames = if (targetContent != null && options.deleteFilesOnlyInTarget) { + val ret = mutableMapOf() + targetContent.directories.forEach { ret[it.name] = Type.DIRECTORY } + targetContent.files.forEach { ret[it.name] = Type.FILE } + targetContent.links.forEach { ret[it.name] = Type.LINK } + ret + } else null + return buildList { + source.directories.forEach { sourceDir -> + targetNames?.remove(sourceDir.name) + visitDir(targetContent, sourceDir.name, sourceDir.list(), this@buildList, root, path, options) + } + source.files.forEach { sourceFile -> + targetNames?.remove(sourceFile.name) + visitFile(targetContent, sourceFile.name, sourceFile.size, sourceFile.lastModified, this@buildList) + } + source.links.forEach { sourceLink -> + targetNames?.remove(sourceLink.name) + visitLink(targetContent, sourceLink, this@buildList, root, path, options) + } + targetNames?.forEach { (name, type) -> + when (type) { + Type.DIRECTORY -> add(Action.RemoveDir(name)) + Type.FILE -> add(Action.RemoveFile(name)) + Type.LINK -> add(Action.RemoveLink(name)) + } + } + } + } + + @Suppress("LongParameterList") + private fun visitDir( + targetContent: Directory.Content?, + sourceName: String, + sourceContent: Directory.Content, + actions: MutableList, + root: Directory, + path: String, + options: Options, + ) { + val sourcePath = PathUtils.join(path, sourceName) + if (targetContent == null) { + actions.add( + Action.ChangeDir( + sourceName, + visit(null, sourceContent, root, sourcePath, options), + create = true, + ), + ) + } else { + val targetDir = targetContent.directories.find { it.name == sourceName } + if (targetDir != null) { + actions.add( + Action.ChangeDir(sourceName, visit(targetDir, sourceContent, root, sourcePath, options)), + ) + } else { + if (targetContent.files.any { it.name == sourceName }) { + actions.add(Action.RemoveFile(sourceName)) + } else if (targetContent.links.any { it.name == sourceName }) { + actions.add(Action.RemoveLink(sourceName)) + } + actions.add( + Action.ChangeDir( + sourceName, + visit(null, sourceContent, root, sourcePath, options), + create = true, + ), + ) + } + } + } + + private fun visitFile( + targetContent: Directory.Content?, + sourceName: String, + sourceSize: ULong, + sourceLastModified: Instant, + actions: MutableList, + ) { + if (targetContent == null) { + actions.add(Action.Copy(sourceName)) + } else { + val targetFile = targetContent.files.find { it.name == sourceName } + if (targetFile != null) { + if (targetFile.size != sourceSize || sourceLastModified > targetFile.lastModified) { + actions.add(Action.Copy(sourceName, overwrite = true)) + } + } else { + if (targetContent.directories.any { it.name == sourceName }) { + actions.add(Action.RemoveDir(sourceName)) + } else if (targetContent.links.any { it.name == sourceName }) { + actions.add(Action.RemoveLink(sourceName)) + } + actions.add(Action.Copy(sourceName)) + } + } + } + + @Suppress("LongParameterList", "NestedBlockDepth") + private fun visitLink( + targetContent: Directory.Content?, + sourceLink: Link, + actions: MutableList, + root: Directory, + path: String, + options: Options, + ) { + val linkTarget = sourceLink.resolve() + if (!options.resolveLinks && insideRoot(root, path, linkTarget.path)) { + if (targetContent == null) { + actions.add(Action.Link(sourceLink.name, linkTarget.path)) + } else { + val targetLink = + targetContent.links.find { it.name == sourceLink.name } + if (targetLink != null) { + if (targetLink.resolve().path != linkTarget.path) { + actions.add( + Action.Link( + sourceLink.name, + linkTarget.path, + overwrite = true + ) + ) + } + } else { + if (targetContent.directories.any { it.name == sourceLink.name }) { + actions.add(Action.RemoveDir(sourceLink.name)) + } else if (targetContent.files.any { it.name == sourceLink.name }) { + actions.add(Action.RemoveFile(sourceLink.name)) + } + actions.add(Action.Link(sourceLink.name, linkTarget.path)) + } + } + } else { + when (linkTarget) { + is Link.DirectoryTarget -> { + visitDir( + targetContent, + sourceLink.name, + linkTarget.directory.list(), + actions, + root, + path, + options, + ) + } + + is Link.FileTarget -> { + visitFile( + targetContent, + sourceLink.name, + linkTarget.file.size, + linkTarget.file.lastModified, + actions, + ) + } + + is Link.NoTarget -> Unit + } + } + } + + @Suppress("UNUSED_PARAMETER") + private fun insideRoot(root: Directory, path: String, linkTarget: String): Boolean { + val fakeRoot = "/--root--" + val resolved = PathUtils.resolve(PathUtils.join(PathUtils.join(fakeRoot, path.substring(1)), linkTarget)) + return resolved.startsWith("$fakeRoot/") + } + + data class Options( + val deleteFilesOnlyInTarget: Boolean = false, + val resolveLinks: Boolean = false, + ) + + private enum class Type { + DIRECTORY, + FILE, + LINK, + } +} -- cgit v1.2.3-70-g09d2