/* * Syncany, www.syncany.org * Copyright (C) 2011-2015 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.operations.up; import java.io.File; import java.io.IOException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.syncany.chunk.Chunk; import org.syncany.chunk.Deduper; import org.syncany.chunk.DeduperListener; import org.syncany.chunk.MultiChunk; import org.syncany.config.Config; import org.syncany.config.LocalEventBus; import org.syncany.database.ChunkEntry; import org.syncany.database.ChunkEntry.ChunkChecksum; import org.syncany.database.DatabaseVersion; import org.syncany.database.FileContent; import org.syncany.database.FileContent.FileChecksum; import org.syncany.database.FileVersion; import org.syncany.database.FileVersion.FileStatus; import org.syncany.database.FileVersion.FileType; import org.syncany.database.FileVersionComparator; import org.syncany.database.FileVersionComparator.FileProperties; import org.syncany.database.FileVersionComparator.FileVersionComparison; import org.syncany.database.MemoryDatabase; import org.syncany.database.MultiChunkEntry; import org.syncany.database.MultiChunkEntry.MultiChunkId; import org.syncany.database.PartialFileHistory; import org.syncany.database.PartialFileHistory.FileHistoryId; import org.syncany.database.SqlDatabase; import org.syncany.operations.daemon.messages.UpIndexChangesDetectedSyncExternalEvent; import org.syncany.operations.daemon.messages.UpIndexEndSyncExternalEvent; import org.syncany.operations.daemon.messages.UpIndexStartSyncExternalEvent; import org.syncany.util.EnvironmentUtil; import org.syncany.util.FileUtil; import org.syncany.util.StringUtil; /** * The indexer combines the chunking process with the corresponding database * lookups for the resulting chunks. It implements the deduplication mechanism * of Syncany. * * <p>The class takes a list of files as input and uses the {@link Deduper} to * break these files into individual chunks. By implementing the {@link DeduperListener}, * it reacts on chunking events and creates a new database version (with the newly * added/changed/removed files. This functionality is entirely implemented by the * {@link #index(List) index()} method. * * <p>The class uses the currently loaded {@link MemoryDatabase} as well as a potential * dirty database into account. Lookups for chunks and file histories are performed * on both databases. * * @author Philipp C. Heckel <philipp.heckel@gmail.com> */ public class Indexer { private static final Logger logger = Logger.getLogger(Indexer.class.getSimpleName()); private static final String DEFAULT_POSIX_PERMISSIONS_FILE = "rw-r--r--"; private static final String DEFAULT_POSIX_PERMISSIONS_FOLDER = "rwxr-xr-x"; private static final String DEFAULT_DOS_ATTRIBUTES = "--a-"; private Config config; private Deduper deduper; private SqlDatabase localDatabase; private LocalEventBus eventBus; public Indexer(Config config, Deduper deduper) { this.config = config; this.deduper = deduper; this.localDatabase = new SqlDatabase(config); this.eventBus = LocalEventBus.getInstance(); } /** * This method implements the index/deduplication functionality of Syncany. It uses a {@link Deduper} * to break files down, compares them to the local database and creates a new {@link DatabaseVersion} * as a result. * * <p>Depending on what has changed, the new database version will contain new instances of * {@link PartialFileHistory}, {@link FileVersion}, {@link FileContent}, {@link ChunkEntry} and * {@link MultiChunkEntry}. * * @param files List of files to be deduplicated * @return New database version containing new/changed/deleted entities * @throws IOException If the chunking/deduplication cannot read/process any of the files */ public DatabaseVersion index(List<File> files) throws IOException { DatabaseVersion newDatabaseVersion = new DatabaseVersion(); // Load file history cache List<PartialFileHistory> fileHistoriesWithLastVersion = localDatabase.getFileHistoriesWithLastVersion(); // TODO [medium] This should be in FileHistoryDao Map<FileChecksum, List<PartialFileHistory>> fileChecksumCache = fillFileChecksumCache(fileHistoriesWithLastVersion); Map<String, PartialFileHistory> filePathCache = fillFilePathCache(fileHistoriesWithLastVersion); // Find and index new files deduper.deduplicate(files, new IndexerDeduperListener(newDatabaseVersion, fileChecksumCache, filePathCache)); // Find and remove deleted files removeDeletedFiles(newDatabaseVersion, fileHistoriesWithLastVersion); return newDatabaseVersion; } private Map<String, PartialFileHistory> fillFilePathCache(List<PartialFileHistory> fileHistoriesWithLastVersion) { Map<String, PartialFileHistory> filePathCache = new HashMap<String, PartialFileHistory>(); for (PartialFileHistory fileHistory : fileHistoriesWithLastVersion) { filePathCache.put(fileHistory.getLastVersion().getPath(), fileHistory); } return filePathCache; } private Map<FileChecksum, List<PartialFileHistory>> fillFileChecksumCache(List<PartialFileHistory> fileHistoriesWithLastVersion) { Map<FileChecksum, List<PartialFileHistory>> fileChecksumCache = new HashMap<FileChecksum, List<PartialFileHistory>>(); for (PartialFileHistory fileHistory : fileHistoriesWithLastVersion) { FileChecksum fileChecksum = fileHistory.getLastVersion().getChecksum(); if (fileChecksum != null) { List<PartialFileHistory> fileHistoriesWithSameChecksum = fileChecksumCache.get(fileChecksum); if (fileHistoriesWithSameChecksum == null) { fileHistoriesWithSameChecksum = new ArrayList<PartialFileHistory>(); } fileHistoriesWithSameChecksum.add(fileHistory); fileChecksumCache.put(fileChecksum, fileHistoriesWithSameChecksum); } } return fileChecksumCache; } private void removeDeletedFiles(DatabaseVersion newDatabaseVersion, List<PartialFileHistory> fileHistoriesWithLastVersion) { logger.log(Level.FINER, "- Looking for deleted files ..."); for (PartialFileHistory fileHistory : fileHistoriesWithLastVersion) { // Ignore this file history if it has been updated in this database version before (file probably renamed!) if (newDatabaseVersion.getFileHistory(fileHistory.getFileHistoryId()) != null) { continue; } // Check if file exists, remove if it doesn't FileVersion lastLocalVersion = fileHistory.getLastVersion(); File lastLocalVersionOnDisk = new File(config.getLocalDir() + File.separator + lastLocalVersion.getPath()); // Ignore this file history if the last version is marked "DELETED" if (lastLocalVersion.getStatus() == FileStatus.DELETED) { continue; } // Add this file history if a new file with this name has been added (file type change) PartialFileHistory newFileWithSameName = getFileHistoryByPathFromDatabaseVersion(newDatabaseVersion, fileHistory.getLastVersion() .getPath()); // If file has VANISHED, mark as DELETED if (!FileUtil.exists(lastLocalVersionOnDisk) || newFileWithSameName != null) { PartialFileHistory deletedFileHistory = new PartialFileHistory(fileHistory.getFileHistoryId()); FileVersion deletedVersion = lastLocalVersion.clone(); deletedVersion.setStatus(FileStatus.DELETED); deletedVersion.setVersion(fileHistory.getLastVersion().getVersion() + 1); deletedVersion.setUpdated(new Date()); logger.log(Level.FINER, " + Deleted: Adding DELETED version: {0}", deletedVersion); logger.log(Level.FINER, " based on: {0}", lastLocalVersion); deletedFileHistory.addFileVersion(deletedVersion); newDatabaseVersion.addFileHistory(deletedFileHistory); } } } private PartialFileHistory getFileHistoryByPathFromDatabaseVersion(DatabaseVersion databaseVersion, String path) { // TODO [medium] Extremely performance intensive, because this is called inside a loop above. Implement better caching for database version!!! for (PartialFileHistory fileHistory : databaseVersion.getFileHistories()) { FileVersion lastVersion = fileHistory.getLastVersion(); if (lastVersion.getStatus() != FileStatus.DELETED && lastVersion.getPath().equals(path)) { return fileHistory; } } return null; } private class IndexerDeduperListener implements DeduperListener { private FileVersionComparator fileVersionComparator; private SecureRandom secureRandom; private DatabaseVersion newDatabaseVersion; private Map<FileChecksum, List<PartialFileHistory>> fileChecksumCache; private Map<String, PartialFileHistory> filePathCache; private ChunkEntry chunkEntry; private MultiChunkEntry multiChunkEntry; private FileContent fileContent; private FileProperties startFileProperties; private FileProperties endFileProperties; public IndexerDeduperListener(DatabaseVersion newDatabaseVersion, Map<FileChecksum, List<PartialFileHistory>> fileChecksumCache, Map<String, PartialFileHistory> filePathCache) { this.fileVersionComparator = new FileVersionComparator(config.getLocalDir(), config.getChunker().getChecksumAlgorithm()); this.secureRandom = new SecureRandom(); this.newDatabaseVersion = newDatabaseVersion; this.fileChecksumCache = fileChecksumCache; this.filePathCache = filePathCache; } @Override public boolean onFileFilter(File file) { logger.log(Level.FINER, "- +File {0}", file); startFileProperties = fileVersionComparator.captureFileProperties(file, null, false); // Check if file has vanished if (!startFileProperties.exists() || startFileProperties.isLocked()) { logger.log(Level.FINER, "- /File: {0}", file); logger.log(Level.INFO, " * NOT ADDING because file has VANISHED (exists = {0}) or is LOCKED (locked = {1}).", new Object[] { startFileProperties.exists(), startFileProperties.isLocked() }); resetFileEnd(); return false; } // Content if (startFileProperties.getType() == FileType.FILE) { logger.log(Level.FINER, "- +FileContent: {0}", file); fileContent = new FileContent(); } return true; } @Override public boolean onFileStart(File file, int fileIndex) { boolean processFile = startFileProperties.getType() == FileType.FILE; // Ignore directories and symlinks! // We could fire an event here, but firing for every file // is very exhausting for the event bus. return processFile; } @Override public void onFileEnd(File file, byte[] rawFileChecksum) { // Get file attributes (get them while file exists) // Note: Do NOT move any File-methods (file.anything()) below the file.exists()-part, // because the file could vanish! FileChecksum fileChecksum = (rawFileChecksum != null) ? new FileChecksum(rawFileChecksum) : null; endFileProperties = fileVersionComparator.captureFileProperties(file, fileChecksum, false); // Check if file has vanished boolean fileIsLocked = endFileProperties.isLocked(); boolean fileVanished = !endFileProperties.exists(); boolean fileHasChanged = startFileProperties.getSize() != endFileProperties.getSize() || startFileProperties.getLastModified() != endFileProperties.getLastModified(); if (fileVanished || fileIsLocked || fileHasChanged) { logger.log(Level.FINER, "- /File: {0}", file); logger.log(Level.INFO, " * NOT ADDING because file has VANISHED (" + !endFileProperties.exists() + "), is LOCKED (" + endFileProperties.isLocked() + "), or has CHANGED (" + fileHasChanged + ")"); resetFileEnd(); return; } // If it's still there, add it to the database addFileVersion(endFileProperties); // Reset resetFileEnd(); } private void addFileVersion(FileProperties fileProperties) { if (fileProperties.getChecksum() != null) { logger.log(Level.FINER, "- /File: {0} (checksum {1})", new Object[] { fileProperties.getRelativePath(), fileProperties.getChecksum() }); } else { logger.log(Level.FINER, "- /File: {0} (directory/symlink/0-byte-file)", fileProperties.getRelativePath()); } // 1. Determine if file already exists in database PartialFileHistory lastFileHistory = guessLastFileHistory(fileProperties); FileVersion lastFileVersion = (lastFileHistory != null) ? lastFileHistory.getLastVersion() : null; // 2. Create new file history/version PartialFileHistory fileHistory = createNewFileHistory(lastFileHistory); FileVersion fileVersion = createNewFileVersion(lastFileVersion, fileProperties); // 3. Compare new and last version FileProperties lastFileVersionProperties = fileVersionComparator.captureFileProperties(lastFileVersion); FileVersionComparison lastToNewFileVersionComparison = fileVersionComparator.compare(fileProperties, lastFileVersionProperties, true); boolean newVersionDiffersFromToLastVersion = !lastToNewFileVersionComparison.areEqual(); if (newVersionDiffersFromToLastVersion) { fileHistory.addFileVersion(fileVersion); newDatabaseVersion.addFileHistory(fileHistory); logger.log(Level.INFO, " * Added file version: " + fileVersion); logger.log(Level.INFO, " based on file version: " + lastFileVersion); fireHasChangesEvent(); } else { logger.log(Level.INFO, " * NOT ADDING file version: " + fileVersion); logger.log(Level.INFO, " b/c IDENTICAL prev.: " + lastFileVersion); } // 4. Add file content (if not a directory) if (fileProperties.getChecksum() != null && fileContent != null) { fileContent.setSize(fileProperties.getSize()); fileContent.setChecksum(fileProperties.getChecksum()); // Check if content already exists, throw gathered content away if it does! FileContent existingContent = localDatabase.getFileContent(fileProperties.getChecksum(), false); if (existingContent == null) { newDatabaseVersion.addFileContent(fileContent); } else { // Uses existing content (already in database); ref. by checksum } } } private void fireHasChangesEvent() { boolean firstNewFileDetected = newDatabaseVersion.getFileHistories().size() == 1; if (firstNewFileDetected) { // Only fires once! eventBus.post(new UpIndexChangesDetectedSyncExternalEvent(config.getLocalDir().getAbsolutePath())); } } private PartialFileHistory createNewFileHistory(PartialFileHistory lastFileHistory) { if (lastFileHistory == null) { FileHistoryId newFileHistoryId = FileHistoryId.secureRandomFileId(); return new PartialFileHistory(newFileHistoryId); } else { return new PartialFileHistory(lastFileHistory.getFileHistoryId()); } } private FileVersion createNewFileVersion(FileVersion lastFileVersion, FileProperties fileProperties) { FileVersion fileVersion = null; // Version if (lastFileVersion == null) { fileVersion = new FileVersion(); fileVersion.setVersion(1L); fileVersion.setStatus(FileStatus.NEW); } else { fileVersion = lastFileVersion.clone(); fileVersion.setVersion(lastFileVersion.getVersion() + 1); } // Simple attributes fileVersion.setPath(fileProperties.getRelativePath()); fileVersion.setLinkTarget(fileProperties.getLinkTarget()); fileVersion.setType(fileProperties.getType()); fileVersion.setSize(fileProperties.getSize()); fileVersion.setChecksum(fileProperties.getChecksum()); fileVersion.setLastModified(new Date(fileProperties.getLastModified())); fileVersion.setUpdated(new Date()); // Permissions if (EnvironmentUtil.isWindows()) { fileVersion.setDosAttributes(fileProperties.getDosAttributes()); if (fileVersion.getType() == FileType.FOLDER) { fileVersion.setPosixPermissions(DEFAULT_POSIX_PERMISSIONS_FOLDER); } else { fileVersion.setPosixPermissions(DEFAULT_POSIX_PERMISSIONS_FILE); } } else if (EnvironmentUtil.isUnixLikeOperatingSystem()) { fileVersion.setPosixPermissions(fileProperties.getPosixPermissions()); fileVersion.setDosAttributes(DEFAULT_DOS_ATTRIBUTES); } // Status if (lastFileVersion != null) { if (fileVersion.getType() == FileType.FILE && FileChecksum.fileChecksumEquals(fileVersion.getChecksum(), lastFileVersion.getChecksum())) { fileVersion.setStatus(FileStatus.CHANGED); } else if (!fileVersion.getPath().equals(lastFileVersion.getPath())) { fileVersion.setStatus(FileStatus.RENAMED); } else { fileVersion.setStatus(FileStatus.CHANGED); } } return fileVersion; } private void resetFileEnd() { fileContent = null; startFileProperties = null; endFileProperties = null; } private PartialFileHistory guessLastFileHistory(FileProperties fileProperties) { if (fileProperties.getType() == FileType.FILE) { return guessLastFileHistoryForFile(fileProperties); } else if (fileProperties.getType() == FileType.SYMLINK) { return guessLastFileHistoryForSymlink(fileProperties); } else if (fileProperties.getType() == FileType.FOLDER) { return guessLastFileHistoryForFolder(fileProperties); } else { throw new RuntimeException("This should not happen."); } } private PartialFileHistory guessLastFileHistoryForSymlink(FileProperties fileProperties) { return guessLastFileHistoryForFolderOrSymlink(fileProperties); } private PartialFileHistory guessLastFileHistoryForFolder(FileProperties fileProperties) { return guessLastFileHistoryForFolderOrSymlink(fileProperties); } private PartialFileHistory guessLastFileHistoryForFolderOrSymlink(FileProperties fileProperties) { PartialFileHistory lastFileHistory = filePathCache.get(fileProperties.getRelativePath()); if (lastFileHistory == null) { logger.log(Level.FINER, " * No old file history found, starting new history (path: " + fileProperties.getRelativePath() + ", " + fileProperties.getType() + ")"); return null; } else { FileVersion lastFileVersion = lastFileHistory.getLastVersion(); if (lastFileVersion.getStatus() != FileStatus.DELETED && lastFileVersion.getType() == fileProperties.getType()) { logger.log(Level.FINER, " * Found old file history " + lastFileHistory.getFileHistoryId() + " (by path: " + fileProperties.getRelativePath() + "), " + fileProperties.getType() + ", appending new version."); return lastFileHistory; } else { logger.log(Level.FINER, " * No old file history found, starting new history (path: " + fileProperties.getRelativePath() + ", " + fileProperties.getType() + ")"); return null; } } } /** * Tries to guess a matching file history, first by path and then by matching checksum. * * <p>If the path matches the path of an existing file in the database, the file history * from the database is used, and a new file version is appended. If there is no file * in the database with that path, checksums are compared. * * <p>If there are more than one file with the same checksum (potential matches), the file * with the closest path is chosen. */ private PartialFileHistory guessLastFileHistoryForFile(FileProperties fileProperties) { PartialFileHistory lastFileHistory = null; // a) Try finding a file history for which the last version has the same path lastFileHistory = filePathCache.get(fileProperties.getRelativePath()); // b) If that fails, try finding files with a matching checksum if (lastFileHistory == null) { if (fileProperties.getChecksum() != null) { Collection<PartialFileHistory> fileHistoriesWithSameChecksum = fileChecksumCache.get(fileProperties.getChecksum()); if (fileHistoriesWithSameChecksum != null && fileHistoriesWithSameChecksum.size() > 0) { lastFileHistory = guessLastFileHistoryForFileWithMatchingChecksum(fileProperties, fileHistoriesWithSameChecksum); // Remove the lastFileHistory we are basing this one on from the // cache, so no other history will be fileHistoriesWithSameChecksum.remove(lastFileHistory); if (fileHistoriesWithSameChecksum.isEmpty()) { fileChecksumCache.remove(fileProperties.getChecksum()); } } } if (lastFileHistory == null) { logger.log(Level.FINER, " * No old file history found, starting new history (path: " + fileProperties.getRelativePath() + ", checksum: " + fileProperties.getChecksum() + ")"); return null; } else { logger.log(Level.FINER, " * Found old file history " + lastFileHistory.getFileHistoryId() + " (by checksum: " + fileProperties.getChecksum() + "), appending new version."); return lastFileHistory; } } else { if (fileProperties.getType() != lastFileHistory.getLastVersion().getType()) { logger.log(Level.FINER, " * No old file history found, starting new history (path: " + fileProperties.getRelativePath() + ", checksum: " + fileProperties.getChecksum() + ")"); return null; } else { logger.log(Level.FINER, " * Found old file history " + lastFileHistory.getFileHistoryId() + " (by path: " + fileProperties.getRelativePath() + "), appending new version."); return lastFileHistory; } } } private PartialFileHistory guessLastFileHistoryForFileWithMatchingChecksum(FileProperties fileProperties, Collection<PartialFileHistory> fileHistoriesWithSameChecksum) { PartialFileHistory lastFileHistory = null; // Check if they do not exist anymore --> assume it has moved! // We choose the best fileHistory to base on as follows: // 1. Ensure that it was modified at the same time and is the same size // 2. Check the fileHistory was deleted and the file does not actually exists // 3. Choose the one with the longest matching tail of the path to the new path for (PartialFileHistory fileHistoryWithSameChecksum : fileHistoriesWithSameChecksum) { FileVersion lastVersion = fileHistoryWithSameChecksum.getLastVersion(); if (fileProperties.getLastModified() != lastVersion.getLastModified().getTime() || fileProperties.getSize() != lastVersion.getSize()) { continue; } File lastVersionOnLocalDisk = new File(config.getLocalDir() + File.separator + lastVersion.getPath()); if (lastVersion.getStatus() != FileStatus.DELETED && !FileUtil.exists(lastVersionOnLocalDisk)) { if (lastFileHistory == null) { lastFileHistory = fileHistoryWithSameChecksum; } else { String filePath = fileProperties.getRelativePath(); String currentPreviousPath = lastFileHistory.getLastVersion().getPath(); String candidatePreviousPath = fileHistoryWithSameChecksum.getLastVersion().getPath(); for (int i = 0; i < filePath.length(); i++) { if (!filePath.regionMatches(filePath.length() - i, candidatePreviousPath, candidatePreviousPath.length() - i, i)) { // The candidate no longer matches, take the current path. break; } if (!filePath.regionMatches(filePath.length() - i, currentPreviousPath, currentPreviousPath.length() - i, i)) { // The current previous path no longer matches, take the new candidate lastFileHistory = fileHistoryWithSameChecksum; break; } } } } } return lastFileHistory; } @Override public void onMultiChunkOpen(MultiChunk multiChunk) { logger.log(Level.FINER, "- +MultiChunk {0}", multiChunk.getId()); multiChunkEntry = new MultiChunkEntry(multiChunk.getId(), 0); // size unknown so far } @Override public void onMultiChunkWrite(MultiChunk multiChunk, Chunk chunk) { logger.log(Level.FINER, "- Chunk > MultiChunk: {0} > {1}", new Object[] { StringUtil.toHex(chunk.getChecksum()), multiChunk.getId() }); multiChunkEntry.addChunk(chunkEntry.getChecksum()); } @Override public void onMultiChunkClose(MultiChunk multiChunk) { logger.log(Level.FINER, "- /MultiChunk {0}", multiChunk.getId()); multiChunkEntry.setSize(multiChunk.getSize()); newDatabaseVersion.addMultiChunk(multiChunkEntry); multiChunkEntry = null; } @Override public File getMultiChunkFile(MultiChunkId multiChunkId) { return config.getCache().getEncryptedMultiChunkFile(multiChunkId); } @Override public MultiChunkId createNewMultiChunkId(Chunk firstChunk) { byte[] newMultiChunkId = new byte[firstChunk.getChecksum().length]; secureRandom.nextBytes(newMultiChunkId); return new MultiChunkId(newMultiChunkId); } @Override public void onFileAddChunk(File file, Chunk chunk) { logger.log(Level.FINER, "- Chunk > FileContent: {0} > {1}", new Object[] { StringUtil.toHex(chunk.getChecksum()), file }); fileContent.addChunk(new ChunkChecksum(chunk.getChecksum())); } @Override public void onStart(int fileCount) { eventBus.post(new UpIndexStartSyncExternalEvent(config.getLocalDir().getAbsolutePath(), fileCount)); } @Override public void onFinish() { eventBus.post(new UpIndexEndSyncExternalEvent(config.getLocalDir().getAbsolutePath())); } /** * Checks if chunk already exists in all database versions * Afterwards checks if chunk exists in new introduced database version. */ @Override public boolean onChunk(Chunk chunk) { ChunkChecksum chunkChecksum = new ChunkChecksum(chunk.getChecksum()); chunkEntry = localDatabase.getChunk(chunkChecksum); if (chunkEntry == null) { chunkEntry = newDatabaseVersion.getChunk(chunkChecksum); if (chunkEntry == null) { logger.log(Level.FINER, "- Chunk new: {0}", chunkChecksum.toString()); chunkEntry = new ChunkEntry(chunkChecksum, chunk.getSize()); newDatabaseVersion.addChunk(chunkEntry); return true; } } logger.log(Level.FINER, "- Chunk exists: {0}", StringUtil.toHex(chunk.getChecksum())); return false; } } }