diff options
44 files changed, 1282 insertions, 108 deletions
diff --git a/OWNCLOUD.cmake b/OWNCLOUD.cmake index 56a11057a..f560db47d 100644 --- a/OWNCLOUD.cmake +++ b/OWNCLOUD.cmake @@ -5,6 +5,7 @@ set( APPLICATION_DOMAIN "owncloud.com" ) set( APPLICATION_VENDOR "ownCloud" ) set( APPLICATION_UPDATE_URL "https://updates.owncloud.com/client/" CACHE string "URL for updater" ) set( APPLICATION_ICON_NAME "owncloud" ) +set( APPLICATION_PLACEHOLDER_SUFFIX "owncloud" CACHE STRING "Placeholder suffix (not including the .)") set( LINUX_PACKAGE_SHORTNAME "owncloud" ) diff --git a/admin/win/nsi/lib/fileassoc.nsh b/admin/win/nsi/lib/fileassoc.nsh new file mode 100644 index 000000000..87e6caf10 --- /dev/null +++ b/admin/win/nsi/lib/fileassoc.nsh @@ -0,0 +1,120 @@ +; fileassoc.nsh +; File association helper macros +; Written by Saivert +; See http://nsis.sourceforge.net/FileAssoc +; +; Features automatic backup system and UPDATEFILEASSOC macro for +; shell change notification. +; +; |> How to use <| +; To associate a file with an application so you can double-click it in explorer, use +; the APP_ASSOCIATE macro like this: +; +; Example: +; !insertmacro APP_ASSOCIATE "txt" "myapp.textfile" "Description of txt files" \ +; "$INSTDIR\myapp.exe,0" "Open with myapp" "$INSTDIR\myapp.exe $\"%1$\"" +; +; Never insert the APP_ASSOCIATE macro multiple times, it is only ment +; to associate an application with a single file and using the +; the "open" verb as default. To add more verbs (actions) to a file +; use the APP_ASSOCIATE_ADDVERB macro. +; +; Example: +; !insertmacro APP_ASSOCIATE_ADDVERB "myapp.textfile" "edit" "Edit with myapp" \ +; "$INSTDIR\myapp.exe /edit $\"%1$\"" +; +; To have access to more options when registering the file association use the +; APP_ASSOCIATE_EX macro. Here you can specify the verb and what verb is to be the +; standard action (default verb). +; +; And finally: To remove the association from the registry use the APP_UNASSOCIATE +; macro. Here is another example just to wrap it up: +; !insertmacro APP_UNASSOCIATE "txt" "myapp.textfile" +; +; |> Note <| +; When defining your file class string always use the short form of your application title +; then a period (dot) and the type of file. This keeps the file class sort of unique. +; Examples: +; Winamp.Playlist +; NSIS.Script +; Photoshop.JPEGFile +; +; |> Tech info <| +; The registry key layout for a file association is: +; HKEY_CLASSES_ROOT +; <applicationID> = <"description"> +; shell +; <verb> = <"menu-item text"> +; command = <"command string"> +; + +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 HKCR ".${EXT}" "" + WriteRegStr HKCR ".${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr HKCR ".${EXT}" "" "${FILECLASS}" + + WriteRegStr HKCR "${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr HKCR "${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr HKCR "${FILECLASS}\shell" "" "open" + WriteRegStr HKCR "${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr HKCR "${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_EX EXT FILECLASS DESCRIPTION ICON VERB DEFAULTVERB SHELLNEW COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 HKCR ".${EXT}" "" + WriteRegStr HKCR ".${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr HKCR ".${EXT}" "" "${FILECLASS}" + StrCmp "${SHELLNEW}" "0" +2 + WriteRegStr HKCR ".${EXT}\ShellNew" "NullFile" "" + + WriteRegStr HKCR "${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr HKCR "${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr HKCR "${FILECLASS}\shell" "" `${DEFAULTVERB}` + WriteRegStr HKCR "${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` + WriteRegStr HKCR "${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_ADDVERB FILECLASS VERB COMMANDTEXT COMMAND + WriteRegStr HKCR "${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` + WriteRegStr HKCR "${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_REMOVEVERB FILECLASS VERB + DeleteRegKey HKCR `${FILECLASS}\shell\${VERB}` +!macroend + + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 HKCR ".${EXT}" `${FILECLASS}_backup` + WriteRegStr HKCR ".${EXT}" "" "$R0" + + DeleteRegKey HKCR `${FILECLASS}` +!macroend + +!macro APP_ASSOCIATE_GETFILECLASS OUTPUT EXT + ReadRegStr ${OUTPUT} HKCR ".${EXT}" "" +!macroend + + +; !defines for use with SHChangeNotify +!ifdef SHCNE_ASSOCCHANGED +!undef SHCNE_ASSOCCHANGED +!endif +!define SHCNE_ASSOCCHANGED 0x08000000 +!ifdef SHCNF_FLUSH +!undef SHCNF_FLUSH +!endif +!define SHCNF_FLUSH 0x1000 + +!macro UPDATEFILEASSOC +; Using the system.dll plugin to call the SHChangeNotify Win32 API function so we +; can update the shell. + System::Call "shell32::SHChangeNotify(i,i,i,i) (${SHCNE_ASSOCCHANGED}, ${SHCNF_FLUSH}, 0, 0)" +!macroend + +;EOF diff --git a/cmake/modules/NSIS.template.in b/cmake/modules/NSIS.template.in index 967eebdf5..8a2edf965 100644 --- a/cmake/modules/NSIS.template.in +++ b/cmake/modules/NSIS.template.in @@ -7,6 +7,8 @@ !define APPLICATION_CMD_EXECUTABLE "@APPLICATION_EXECUTABLE@cmd.exe" !define APPLICATION_DOMAIN "@APPLICATION_DOMAIN@" !define APPLICATION_LICENSE "@APPLICATION_LICENSE@" +!define APPLICATION_PLACEHOLDER_SUFFIX "@APPLICATION_PLACEHOLDER_SUFFIX@" +!define APPLICATION_PLACEHOLDER_FILECLASS "@APPLICATION_EXECUTABLE@.@APPLICATION_PLACEHOLDER_SUFFIX@" !define WIN_SETUP_BITMAP_PATH "@WIN_SETUP_BITMAP_PATH@" !define CRASHREPORTER_EXECUTABLE "@CRASHREPORTER_EXECUTABLE@" @@ -100,6 +102,8 @@ ReserveFile "${NSISDIR}\Plugins\InstallOptions.dll" !include Library.nsh ;Used by the COM registration for shell extensions !include x64.nsh ;Used to determine the right arch for the shell extensions +!include ${source_path}/admin/win/nsi/lib/fileassoc.nsh + ;----------------------------------------------------------------------------- ; Memento selections stored in registry. ;----------------------------------------------------------------------------- @@ -466,6 +470,9 @@ Section "${APPLICATION_NAME}" SEC_APPLICATION ;CSync configs File "${SOURCE_PATH}/sync-exclude.lst" + ;Add file association + !insertmacro APP_ASSOCIATE "${APPLICATION_PLACEHOLDER_SUFFIX}" "${APPLICATION_PLACEHOLDER_FILECLASS}" "Placeholder for Remote File" "$INSTDIR\${APPLICATION_EXECUTABLE},0" "Download" "$INSTDIR\${APPLICATION_EXECUTABLE} $\"%1$\"" + SectionEnd !ifdef OPTION_SECTION_SC_SHELL_EXT @@ -643,6 +650,9 @@ Section Uninstall DeleteRegKey HKCR "${APPLICATION_NAME}" + ;Remove file association + !insertmacro APP_UNASSOCIATE "${APPLICATION_PLACEHOLDER_SUFFIX}" "${APPLICATION_PLACEHOLDER_FILECLASS}" + ;Shell extension !ifdef OPTION_SECTION_SC_SHELL_EXT !define LIBRARY_COM diff --git a/config.h.in b/config.h.in index 804d5f68c..c78d52059 100644 --- a/config.h.in +++ b/config.h.in @@ -18,6 +18,8 @@ #cmakedefine APPLICATION_EXECUTABLE "@APPLICATION_EXECUTABLE@" #cmakedefine APPLICATION_UPDATE_URL "@APPLICATION_UPDATE_URL@" #cmakedefine APPLICATION_ICON_NAME "@APPLICATION_ICON_NAME@" +#cmakedefine APPLICATION_PLACEHOLDER_SUFFIX "@APPLICATION_PLACEHOLDER_SUFFIX@" +#define APPLICATION_DOTPLACEHOLDER_SUFFIX "." APPLICATION_PLACEHOLDER_SUFFIX #cmakedefine ZLIB_FOUND @ZLIB_FOUND@ diff --git a/doc/conffile.rst b/doc/conffile.rst index 8cef1d272..ca1ea84d7 100644 --- a/doc/conffile.rst +++ b/doc/conffile.rst @@ -49,6 +49,9 @@ Some interesting values that can be set on the configuration file are: | ``moveToTrash`` | ``false`` | If non-locally deleted files should be moved to trash instead of deleting them completely. | | | | This option only works on linux | +---------------------------------+---------------+--------------------------------------------------------------------------------------------------------+ +| ``showExperimentalOptions`` | ``false`` | Whether to show experimental options that are still undergoing testing in the user interface. | +| | | Turning this on does not enable experimental behavior on its own. It does enable user inferface options that can be used to opt in to experimental features. | ++---------------------------------+---------------+--------------------------------------------------------------------------------------------------------+ +----------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/mirall.desktop.in b/mirall.desktop.in index 944d6a662..a5cb39f45 100644 --- a/mirall.desktop.in +++ b/mirall.desktop.in @@ -8,77 +8,7 @@ GenericName=Folder Sync Icon=@APPLICATION_EXECUTABLE@ Keywords=@APPLICATION_NAME@;syncing;file;sharing; X-GNOME-Autostart-Delay=3 - - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - - -# Translations - +MimeType=application/x-@APPLICATION_EXECUTABLE@ # Translations Comment[oc]=@APPLICATION_NAME@ sincronizacion del client diff --git a/src/csync/csync.h b/src/csync/csync.h index aba98ea85..5a9f49e3d 100644 --- a/src/csync/csync.h +++ b/src/csync/csync.h @@ -137,7 +137,9 @@ enum ItemType { ItemTypeFile = 0, ItemTypeSoftLink = 1, ItemTypeDirectory = 2, - ItemTypeSkip = 3 + ItemTypeSkip = 3, + ItemTypePlaceholder = 4, + ItemTypePlaceholderDownload = 5 }; diff --git a/src/csync/csync_exclude.cpp b/src/csync/csync_exclude.cpp index e3146a099..c64589fff 100644 --- a/src/csync/csync_exclude.cpp +++ b/src/csync/csync_exclude.cpp @@ -33,9 +33,12 @@ #include "csync_misc.h" #include "common/utility.h" +#include "../version.h" #include <QString> #include <QFileInfo> +#include <QFile> +#include <QDir> /** Expands C-like escape sequences (in place) @@ -240,6 +243,7 @@ static CSYNC_EXCLUDE_TYPE _csync_excluded_common(const char *path, bool excludeC using namespace OCC; ExcludedFiles::ExcludedFiles() + : _clientVersion(MIRALL_VERSION_MAJOR, MIRALL_VERSION_MINOR, MIRALL_VERSION_PATCH) { // Windows used to use PathMatchSpec which allows *foo to match abc/deffoo. _wildcardsMatchSlash = Utility::isWindows(); @@ -278,6 +282,34 @@ void ExcludedFiles::setWildcardsMatchSlash(bool onoff) prepare(); } +void ExcludedFiles::setClientVersion(ExcludedFiles::Version version) +{ + _clientVersion = version; +} + +void ExcludedFiles::setupPlaceholderExclude( + const QString &excludeFile, const QByteArray &placeholderExtension) +{ + if (!QFile::exists(excludeFile)) { + // Ensure the parent paths exist + QDir().mkpath(QFileInfo(excludeFile).dir().absolutePath()); + } else { + // Does the exclude file contain the exclude already? + QFile file(excludeFile); + file.open(QIODevice::ReadOnly | QIODevice::Text); + auto data = file.readAll(); + file.close(); + if (data.contains("\n*" + placeholderExtension + "\n")) + return; + } + + // Add it to the file + QFile file(excludeFile); + file.open(QIODevice::ReadWrite | QIODevice::Append); + file.write("\n#!version < 2.5.0\n*" + placeholderExtension + "\n"); + file.close(); +} + bool ExcludedFiles::reloadExcludeFiles() { _allExcludes.clear(); @@ -290,6 +322,10 @@ bool ExcludedFiles::reloadExcludeFiles() } while (!f.atEnd()) { QByteArray line = f.readLine().trimmed(); + if (line.startsWith("#!version")) { + if (!versionDirectiveKeepNextLine(line)) + f.readLine(); + } if (line.isEmpty() || line.startsWith('#')) continue; csync_exclude_expand_escapes(line); @@ -301,6 +337,32 @@ bool ExcludedFiles::reloadExcludeFiles() return success; } +bool ExcludedFiles::versionDirectiveKeepNextLine(const QByteArray &directive) const +{ + if (!directive.startsWith("#!version")) + return true; + QByteArrayList args = directive.split(' '); + if (args.size() != 3) + return true; + QByteArray op = args[1]; + QByteArrayList argVersions = args[2].split('.'); + if (argVersions.size() != 3) + return true; + + auto argVersion = std::make_tuple(argVersions[0].toInt(), argVersions[1].toInt(), argVersions[2].toInt()); + if (op == "<=") + return _clientVersion <= argVersion; + if (op == "<") + return _clientVersion < argVersion; + if (op == ">") + return _clientVersion > argVersion; + if (op == ">=") + return _clientVersion >= argVersion; + if (op == "==") + return _clientVersion == argVersion; + return true; +} + bool ExcludedFiles::isExcluded( const QString &filePath, const QString &basePath, diff --git a/src/csync/csync_exclude.h b/src/csync/csync_exclude.h index 249ec7bff..fba766b6f 100644 --- a/src/csync/csync_exclude.h +++ b/src/csync/csync_exclude.h @@ -66,6 +66,8 @@ class OCSYNC_EXPORT ExcludedFiles : public QObject { Q_OBJECT public: + typedef std::tuple<int, int, int> Version; + ExcludedFiles(); ~ExcludedFiles(); @@ -115,6 +117,11 @@ public: void setWildcardsMatchSlash(bool onoff); /** + * Sets the client version, only used for testing. + */ + void setClientVersion(Version version); + + /** * Generate a hook for traversal exclude pattern matching * that csync can use. * @@ -124,6 +131,12 @@ public: auto csyncTraversalMatchFun() const -> std::function<CSYNC_EXCLUDE_TYPE(const char *path, ItemType filetype)>; + /** + * Adds the exclude that skips placeholder files in older versions + * to the user exclude file. + */ + static void setupPlaceholderExclude(const QString &excludeFile, const QByteArray &placeholderExtension); + public slots: /** * Reloads the exclude patterns from the registered paths. @@ -132,6 +145,23 @@ public slots: private: /** + * Returns true if the version directive indicates the next line + * should be skipped. + * + * A version directive has the form "#!version <op> <version>" + * where <op> can be <, <=, ==, >, >= and <version> can be any version + * like 2.5.0. + * + * Example: + * + * #!version < 2.5.0 + * myexclude + * + * Would enable the "myexclude" pattern only for versions before 2.5.0. + */ + bool versionDirectiveKeepNextLine(const QByteArray &directive) const; + + /** * @brief Match the exclude pattern against the full path. * * @param Path is folder-relative, should not start with a /. @@ -216,6 +246,12 @@ private: */ bool _wildcardsMatchSlash = false; + /** + * The client version. Used to evaluate version-dependent excludes, + * see versionDirectiveKeepNextLine(). + */ + Version _clientVersion; + friend class ExcludedFilesTest; }; diff --git a/src/csync/csync_private.h b/src/csync/csync_private.h index 2daeaa813..5426fba1b 100644 --- a/src/csync/csync_private.h +++ b/src/csync/csync_private.h @@ -196,6 +196,16 @@ struct OCSYNC_EXPORT csync_s { bool upload_conflict_files = false; + /** + * Whether new remote files should start out as placeholders. + */ + bool new_files_are_placeholders = false; + + /** + * The suffix to use for placeholder files. + */ + QByteArray placeholder_suffix; + csync_s(const char *localUri, OCC::SyncJournalDb *statedb); ~csync_s(); int reinitialize(); diff --git a/src/csync/csync_reconcile.cpp b/src/csync/csync_reconcile.cpp index 78c2fab03..cf254ff57 100644 --- a/src/csync/csync_reconcile.cpp +++ b/src/csync/csync_reconcile.cpp @@ -110,7 +110,7 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) break; } - csync_file_stat_t *other = other_tree->findFile(cur->path);; + csync_file_stat_t *other = other_tree->findFile(cur->path); if (!other) { /* Check the renamed path as well. */ @@ -122,6 +122,31 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) /* If it is ignored, other->instruction will be IGNORE so this one will also be ignored */ } + // If the user adds a file locally check whether a placeholder for that name exists. + // If so, go to "potential conflict" mode by switching the remote entry to be a + // real file. + if (!other + && ctx->current == LOCAL_REPLICA + && cur->instruction == CSYNC_INSTRUCTION_NEW + && cur->type != ItemTypePlaceholder) { + // Check if we have a placeholder entry in the remote tree + auto placeholderPath = cur->path; + placeholderPath.append(ctx->placeholder_suffix); + other = other_tree->findFile(placeholderPath); + if (!other) { + /* Check the renamed path as well. */ + other = other_tree->findFile(csync_rename_adjust_parent_path(ctx, placeholderPath)); + } + if (other && other->type == ItemTypePlaceholder) { + qCInfo(lcReconcile) << "Found placeholder for local" << cur->path << "in remote tree"; + other->path = cur->path; + other->type = ItemTypePlaceholderDownload; + other->instruction = CSYNC_INSTRUCTION_EVAL; + } else { + other = nullptr; + } + } + /* file only found on current replica */ if (!other) { switch(cur->instruction) { @@ -141,6 +166,15 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) cur->instruction = CSYNC_INSTRUCTION_NEW; break; } + /* If the local placeholder is gone it should be reestablished. + * Unless the base file is seen in the local tree now. */ + if (cur->type == ItemTypePlaceholder + && ctx->current == REMOTE_REPLICA + && cur->path.endsWith(ctx->placeholder_suffix) + && !other_tree->findFile(cur->path.left(cur->path.size() - ctx->placeholder_suffix.size()))) { + cur->instruction = CSYNC_INSTRUCTION_NEW; + break; + } cur->instruction = CSYNC_INSTRUCTION_REMOVE; break; case CSYNC_INSTRUCTION_EVAL_RENAME: { @@ -369,7 +403,10 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) cur->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA; other->instruction = CSYNC_INSTRUCTION_NONE; } else { - cur->instruction = CSYNC_INSTRUCTION_SYNC; + if (cur->instruction != CSYNC_INSTRUCTION_NEW + && cur->instruction != CSYNC_INSTRUCTION_SYNC) { + cur->instruction = CSYNC_INSTRUCTION_SYNC; + } other->instruction = CSYNC_INSTRUCTION_NONE; } break; @@ -388,6 +425,17 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx) if (cur->instruction == CSYNC_INSTRUCTION_EVAL) cur->instruction = CSYNC_INSTRUCTION_NEW; break; + case CSYNC_INSTRUCTION_NONE: + // NONE/NONE on placeholders might become a REMOVE if the base file + // is found in the local tree. + if (cur->type == ItemTypePlaceholder + && other->instruction == CSYNC_INSTRUCTION_NONE + && ctx->current == LOCAL_REPLICA + && cur->path.endsWith(ctx->placeholder_suffix) + && ctx->local.files.findFile(cur->path.left(cur->path.size() - ctx->placeholder_suffix.size()))) { + cur->instruction = CSYNC_INSTRUCTION_REMOVE; + } + break; default: break; } diff --git a/src/csync/csync_update.cpp b/src/csync/csync_update.cpp index 852ef0c20..2f180dda4 100644 --- a/src/csync/csync_update.cpp +++ b/src/csync/csync_update.cpp @@ -47,6 +47,7 @@ #include "common/asserts.h" #include <QtCore/QTextCodec> +#include <QtCore/QFile> // Needed for PRIu64 on MinGW in C++ mode. #define __STDC_FORMAT_MACROS @@ -186,21 +187,64 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f return -1; } + // The db entry might be for a placeholder, so look for that on the + // remote side. If we find one, change the current fs to look like a + // placeholder too, because that's what one would see if the remote + // db was filled from the database. + if (ctx->current == REMOTE_REPLICA && !base.isValid() && fs->type == ItemTypeFile) { + auto placeholderPath = fs->path; + placeholderPath.append(ctx->placeholder_suffix); + ctx->statedb->getFileRecord(placeholderPath, &base); + if (base.isValid() && base._type == ItemTypePlaceholder) { + fs->type = ItemTypePlaceholder; + fs->path = placeholderPath; + } else { + base = OCC::SyncJournalFileRecord(); + } + } + if(base.isValid()) { /* there is an entry in the database */ /* we have an update! */ qCInfo(lcUpdate, "Database entry found, compare: %" PRId64 " <-> %" PRId64 ", etag: %s <-> %s, inode: %" PRId64 " <-> %" PRId64 - ", size: %" PRId64 " <-> %" PRId64 ", perms: %x <-> %x" - ", checksum: %s <-> %s , ignore: %d", + ", size: %" PRId64 " <-> %" PRId64 + ", perms: %x <-> %x" + ", type: %d <-> %d" + ", checksum: %s <-> %s" + ", ignore: %d", ((int64_t) fs->modtime), ((int64_t) base._modtime), fs->etag.constData(), base._etag.constData(), (uint64_t) fs->inode, (uint64_t) base._inode, - (uint64_t) fs->size, (uint64_t) base._fileSize, *reinterpret_cast<short*>(&fs->remotePerm), *reinterpret_cast<short*>(&base._remotePerm), fs->checksumHeader.constData(), - base._checksumHeader.constData(), base._serverHasIgnoredFiles); + (uint64_t) fs->size, (uint64_t) base._fileSize, + *reinterpret_cast<short*>(&fs->remotePerm), *reinterpret_cast<short*>(&base._remotePerm), + fs->type, base._type, + fs->checksumHeader.constData(), base._checksumHeader.constData(), + base._serverHasIgnoredFiles ); + + // If the db suggests a placeholder should be downloaded, + // treat the file as new on the remote. + if (ctx->current == REMOTE_REPLICA && base._type == ItemTypePlaceholderDownload) { + fs->instruction = CSYNC_INSTRUCTION_NEW; + fs->type = ItemTypePlaceholderDownload; + goto out; + } + + // If what the db thinks is a placeholder is actually a file/dir, + // treat it as new locally. + if (ctx->current == LOCAL_REPLICA + && (base._type == ItemTypePlaceholder || base._type == ItemTypePlaceholderDownload) + && fs->type != ItemTypePlaceholder) { + fs->instruction = CSYNC_INSTRUCTION_EVAL; + goto out; + } + if (ctx->current == REMOTE_REPLICA && fs->etag != base._etag) { fs->instruction = CSYNC_INSTRUCTION_EVAL; - // Preserve the EVAL flag later on if the type has changed. - if (base._type != fs->type) { + if (fs->type == ItemTypePlaceholder) { + // If the local thing is a placeholder, we just update the metadata + fs->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA; + } else if (base._type != fs->type) { + // Preserve the EVAL flag later on if the type has changed. fs->child_modified = true; } @@ -321,10 +365,19 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f if (!base.isValid()) return; + if (base._type == ItemTypePlaceholderDownload) { + // Remote rename of a placeholder file we have locally scheduled + // for download. We just consider this NEW but mark it for download. + fs->type = ItemTypePlaceholderDownload; + done = true; + return; + } + // Some things prohibit rename detection entirely. // Since we don't do the same checks again in reconcile, we can't // just skip the candidate, but have to give up completely. - if (base._type != fs->type) { + if (base._type != fs->type + && base._type != ItemTypePlaceholder) { qCWarning(lcUpdate, "file types different, not a rename"); done = true; return; @@ -336,6 +389,14 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f return; } + // Now we know there is a sane rename candidate. + + // Rename of a placeholder + if (base._type == ItemTypePlaceholder && fs->type == ItemTypeFile) { + fs->type = ItemTypePlaceholder; + fs->path.append(ctx->placeholder_suffix); + } + // Record directory renames if (fs->type == ItemTypeDirectory) { // If the same folder was already renamed by a different entry, @@ -365,6 +426,15 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f return 1; } } + + // Turn new remote files into placeholders if the option is enabled. + if (ctx->new_files_are_placeholders + && fs->instruction == CSYNC_INSTRUCTION_NEW + && fs->type == ItemTypeFile) { + fs->type = ItemTypePlaceholder; + fs->path.append(ctx->placeholder_suffix); + } + goto out; } } @@ -467,7 +537,7 @@ int csync_walker(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> fs) { return rc; } -static bool fill_tree_from_db(CSYNC *ctx, const char *uri) +static bool fill_tree_from_db(CSYNC *ctx, const char *uri, bool singleFile = false) { int64_t count = 0; QByteArray skipbase; @@ -522,9 +592,19 @@ static bool fill_tree_from_db(CSYNC *ctx, const char *uri) ++count; }; - if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) { - ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; - return false; + if (singleFile) { + OCC::SyncJournalFileRecord record; + if (ctx->statedb->getFileRecord(QByteArray(uri), &record) && record.isValid()) { + rowCallback(record); + } else { + ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; + return false; + } + } else { + if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) { + ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR; + return false; + } } qInfo(lcUpdate, "%" PRId64 " entries read below path %s from db.", count, uri); @@ -661,6 +741,21 @@ int csync_ftw(CSYNC *ctx, const char *uri, csync_walker_fn fn, fullpath = QByteArray() % uri % '/' % filename; } + // When encountering placeholder files, read the relevant + // entry from the db instead. + if (ctx->current == LOCAL_REPLICA + && dirent->type == ItemTypeFile + && filename.endsWith(ctx->placeholder_suffix)) { + QByteArray db_uri = fullpath.mid(strlen(ctx->local.uri) + 1); + + if( ! fill_tree_from_db(ctx, db_uri.constData(), true) ) { + qCWarning(lcUpdate) << "Placeholder without db entry for" << filename; + QFile::remove(fullpath); + } + + continue; + } + /* if the filename starts with a . we consider it a hidden file * For windows, the hidden state is also discovered within the vio * local stat function. diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 1d98182f0..1745e1b31 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -345,5 +345,15 @@ if(NOT BUILD_OWNCLOUD_OSX_BUNDLE AND NOT WIN32) configure_file(${CMAKE_SOURCE_DIR}/mirall.desktop.in ${CMAKE_CURRENT_BINARY_DIR}/${APPLICATION_EXECUTABLE}.desktop) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${APPLICATION_EXECUTABLE}.desktop DESTINATION ${DATADIR}/applications ) + + configure_file(owncloud.xml.in ${APPLICATION_EXECUTABLE}.xml) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${APPLICATION_EXECUTABLE}.xml DESTINATION ${DATADIR}/mime/packages ) + + find_package(ECM 1.2.0 CONFIG) + set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH} ${ECM_KDE_MODULE_DIR} "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + find_package(SharedMimeInfo) + if(SharedMimeInfo_FOUND) + update_xdg_mimetypes( ${DATADIR}/mime/packages ) + endif(SharedMimeInfo_FOUND) endif() diff --git a/src/gui/accountsettings.cpp b/src/gui/accountsettings.cpp index 7f4b262f4..c4af55c8b 100644 --- a/src/gui/accountsettings.cpp +++ b/src/gui/accountsettings.cpp @@ -378,6 +378,7 @@ void AccountSettings::slotFolderWizardAccepted() folderWizard->field(QLatin1String("sourceFolder")).toString()); definition.targetPath = FolderDefinition::prepareTargetPath( folderWizard->property("targetPath").toString()); + definition.usePlaceholders = folderWizard->property("usePlaceholders").toBool(); { QDir dir(definition.localPath); diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 09822a492..703f1f843 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -37,6 +37,7 @@ #include "updater/ocupdater.h" #include "owncloudsetupwizard.h" #include "version.h" +#include "csync_exclude.h" #include "config.h" @@ -172,6 +173,9 @@ Application::Application(int &argc, char **argv) if (!AbstractNetworkJob::httpTimeout) AbstractNetworkJob::httpTimeout = cfg.timeout(); + ExcludedFiles::setupPlaceholderExclude( + cfg.excludeFile(ConfigFile::UserScope), APPLICATION_DOTPLACEHOLDER_SUFFIX); + _folderManager.reset(new FolderMan); connect(this, &SharedTools::QtSingleApplication::messageReceived, this, &Application::slotParseMessage); @@ -462,6 +466,9 @@ void Application::parseOptions(const QStringList &options) _debugMode = true; } else if (option == QLatin1String("--version")) { _versionOnly = true; + } else if (option.endsWith(QStringLiteral(APPLICATION_DOTPLACEHOLDER_SUFFIX))) { + // placeholder file, open it after the Folder were created (if the app is not terminated) + QTimer::singleShot(0, this, [this, option] { openPlaceholder(option); }); } else { showHint("Unrecognized option '" + option.toStdString() + "'"); } @@ -627,5 +634,29 @@ void Application::showSettingsDialog() _gui->slotShowSettings(); } +void Application::openPlaceholder(const QString &filename) +{ + QString placeholderExt = QStringLiteral(APPLICATION_DOTPLACEHOLDER_SUFFIX); + if (!filename.endsWith(placeholderExt)) { + qWarning(lcApplication) << "Can only handle file ending in .owncloud. Unable to open" << filename; + return; + } + QString relativePath; + auto folder = FolderMan::instance()->folderForPath(filename, &relativePath); + if (!folder) { + qWarning(lcApplication) << "Can't find sync folder for" << filename; + // TODO: show a QMessageBox for errors + return; + } + folder->downloadPlaceholder(relativePath); + QString normalName = filename.left(filename.size() - placeholderExt.size()); + auto con = QSharedPointer<QMetaObject::Connection>::create(); + *con = QObject::connect(folder, &Folder::syncFinished, [con, normalName] { + QObject::disconnect(*con); + if (QFile::exists(normalName)) { + QDesktopServices::openUrl(QUrl::fromLocalFile(normalName)); + } + }); +} } // namespace OCC diff --git a/src/gui/application.h b/src/gui/application.h index c61f7645e..744bc1819 100644 --- a/src/gui/application.h +++ b/src/gui/application.h @@ -72,6 +72,11 @@ public slots: // TODO: this should not be public void slotownCloudWizardDone(int); void slotCrash(); + /** + * Will download a placeholder file, and open the result. + * The argument is the filename of the placeholder file (including the extension) + */ + void openPlaceholder(const QString &filename); protected: void parseOptions(const QStringList &); diff --git a/src/gui/folder.cpp b/src/gui/folder.cpp index 660609747..32f562a09 100644 --- a/src/gui/folder.cpp +++ b/src/gui/folder.cpp @@ -501,6 +501,26 @@ void Folder::slotWatchedPathChanged(const QString &path) scheduleThisFolderSoon(); } +void Folder::downloadPlaceholder(const QString &_relativepath) +{ + qCInfo(lcFolder) << "Download placeholder: " << _relativepath; + auto relativepath = _relativepath.toUtf8(); + + // Set in the database that we should download the file + SyncJournalFileRecord record; + _journal.getFileRecord(relativepath, &record); + if (!record.isValid()) + return; + record._type = ItemTypePlaceholderDownload; + _journal.setFileRecord(record); + + // Make sure we go over that file during the discovery + _journal.avoidReadFromDbOnNextSync(relativepath); + + // Schedule a sync (Folder man will start the sync in a few ms) + slotScheduleThisFolder(); +} + void Folder::saveToSettings() const { // Remove first to make sure we don't get duplicates @@ -684,6 +704,8 @@ void Folder::setSyncOptions() opt._newBigFolderSizeLimit = newFolderLimit.first ? newFolderLimit.second * 1000LL * 1000LL : -1; // convert from MB to B opt._confirmExternalStorage = cfgFile.confirmExternalStorage(); opt._moveFilesToTrash = cfgFile.moveToTrash(); + opt._newFilesArePlaceholders = _definition.usePlaceholders; + opt._placeholderSuffix = QStringLiteral(APPLICATION_DOTPLACEHOLDER_SUFFIX); QByteArray chunkSizeEnv = qgetenv("OWNCLOUD_CHUNK_SIZE"); if (!chunkSizeEnv.isEmpty()) { @@ -1106,6 +1128,7 @@ void FolderDefinition::save(QSettings &settings, const FolderDefinition &folder) settings.setValue(QLatin1String("targetPath"), folder.targetPath); settings.setValue(QLatin1String("paused"), folder.paused); settings.setValue(QLatin1String("ignoreHiddenFiles"), folder.ignoreHiddenFiles); + settings.setValue(QLatin1String("usePlaceholders"), folder.usePlaceholders); // Happens only on Windows when the explorer integration is enabled. if (!folder.navigationPaneClsid.isNull()) @@ -1126,6 +1149,7 @@ bool FolderDefinition::load(QSettings &settings, const QString &alias, folder->paused = settings.value(QLatin1String("paused")).toBool(); folder->ignoreHiddenFiles = settings.value(QLatin1String("ignoreHiddenFiles"), QVariant(true)).toBool(); folder->navigationPaneClsid = settings.value(QLatin1String("navigationPaneClsid")).toUuid(); + folder->usePlaceholders = settings.value(QLatin1String("usePlaceholders")).toBool(); settings.endGroup(); // Old settings can contain paths with native separators. In the rest of the diff --git a/src/gui/folder.h b/src/gui/folder.h index 95710aaa9..276cb2d9d 100644 --- a/src/gui/folder.h +++ b/src/gui/folder.h @@ -65,6 +65,8 @@ public: bool paused; /// whether the folder syncs hidden files bool ignoreHiddenFiles; + /// New files are downloaded as placeholders + bool usePlaceholders = false; /// The CLSID where this folder appears in registry for the Explorer navigation pane entry. QUuid navigationPaneClsid; @@ -282,6 +284,12 @@ public slots: */ void slotWatchedPathChanged(const QString &path); + /** + * Mark a placeholder as being ready for download, and start a sync. + * relativePath is the patch to the placeholder file (includeing the extension) + */ + void downloadPlaceholder(const QString &relativepath); + private slots: void slotSyncStarted(); void slotSyncFinished(bool); diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index a198f79e4..ca0b08dae 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -934,7 +934,7 @@ Folder *FolderMan::addFolderInternal(FolderDefinition folderDefinition, return folder; } -Folder *FolderMan::folderForPath(const QString &path) +Folder *FolderMan::folderForPath(const QString &path, QString *relativePath) { QString absolutePath = QDir::cleanPath(path) + QLatin1Char('/'); @@ -942,10 +942,16 @@ Folder *FolderMan::folderForPath(const QString &path) const QString folderPath = folder->cleanPath() + QLatin1Char('/'); if (absolutePath.startsWith(folderPath, (Utility::isWindows() || Utility::isMac()) ? Qt::CaseInsensitive : Qt::CaseSensitive)) { + if (relativePath) { + *relativePath = absolutePath.mid(folderPath.length()); + relativePath->chop(1); // we added a '/' above + } return folder; } } + if (relativePath) + relativePath->clear(); return 0; } diff --git a/src/gui/folderman.h b/src/gui/folderman.h index 3eb6024e2..ae9c48f89 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -77,8 +77,13 @@ public: /** Removes a folder */ void removeFolder(Folder *); - /** Returns the folder which the file or directory stored in path is in */ - Folder *folderForPath(const QString &path); + /** + * Returns the folder which the file or directory stored in path is in + * + * Optionally, the path relative to the found folder is returned in + * relativePath. + */ + Folder *folderForPath(const QString &path, QString *relativePath = nullptr); /** * returns a list of local files that exist on the local harddisk for an diff --git a/src/gui/folderwizard.cpp b/src/gui/folderwizard.cpp index 549d2095f..6bb40f685 100644 --- a/src/gui/folderwizard.cpp +++ b/src/gui/folderwizard.cpp @@ -36,6 +36,7 @@ #include <QTreeWidget> #include <QVBoxLayout> #include <QEvent> +#include <QCheckBox> #include <stdlib.h> @@ -481,6 +482,12 @@ FolderWizardSelectiveSync::FolderWizardSelectiveSync(const AccountPtr &account) QVBoxLayout *layout = new QVBoxLayout(this); _selectiveSync = new SelectiveSyncWidget(account, this); layout->addWidget(_selectiveSync); + + if (ConfigFile().showExperimentalOptions()) { + _placeholderCheckBox = new QCheckBox(tr("Create placeholders instead of downloading files (experimental)")); + connect(_placeholderCheckBox, &QCheckBox::clicked, this, &FolderWizardSelectiveSync::placeholderCheckboxClicked); + layout->addWidget(_placeholderCheckBox); + } } FolderWizardSelectiveSync::~FolderWizardSelectiveSync() @@ -508,6 +515,7 @@ void FolderWizardSelectiveSync::initializePage() bool FolderWizardSelectiveSync::validatePage() { wizard()->setProperty("selectiveSyncBlackList", QVariant(_selectiveSync->createBlackList())); + wizard()->setProperty("usePlaceholders", QVariant(_placeholderCheckBox->isChecked())); return true; } @@ -521,6 +529,18 @@ void FolderWizardSelectiveSync::cleanupPage() QWizardPage::cleanupPage(); } +void FolderWizardSelectiveSync::placeholderCheckboxClicked() +{ + // The click has already had an effect on the box, so if it's + // checked it was newly activated. + if (_placeholderCheckBox->isChecked()) { + OwncloudWizard::askExperimentalPlaceholderFeature([this](bool enable) { + if (!enable) + _placeholderCheckBox->setChecked(false); + }); + } +} + // ==================================================================================== diff --git a/src/gui/folderwizard.h b/src/gui/folderwizard.h index b6f056f56..9e12d4749 100644 --- a/src/gui/folderwizard.h +++ b/src/gui/folderwizard.h @@ -25,6 +25,8 @@ #include "ui_folderwizardsourcepage.h" #include "ui_folderwizardtargetpage.h" +class QCheckBox; + namespace OCC { class SelectiveSyncWidget; @@ -128,8 +130,12 @@ public: virtual void initializePage() Q_DECL_OVERRIDE; virtual void cleanupPage() Q_DECL_OVERRIDE; +private slots: + void placeholderCheckboxClicked(); + private: SelectiveSyncWidget *_selectiveSync; + QCheckBox *_placeholderCheckBox; }; /** diff --git a/src/gui/owncloud.xml.in b/src/gui/owncloud.xml.in new file mode 100644 index 000000000..8bc9b48fe --- /dev/null +++ b/src/gui/owncloud.xml.in @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info"> + <mime-type type="application/x-@APPLICATION_EXECUTABLE@"> + <comment>@APPLICATION_NAME@ placeholders</comment> + <glob pattern="*.@APPLICATION_PLACEHOLDER_SUFFIX@"/> + </mime-type> +</mime-info> diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 165d4f99a..e6e520401 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -1034,7 +1034,8 @@ void ownCloudGui::raiseDialog(QWidget *raiseWidget) void ownCloudGui::slotShowShareDialog(const QString &sharePath, const QString &localPath, ShareDialogStartPage startPage) { - const auto folder = FolderMan::instance()->folderForPath(localPath); + QString file; + const auto folder = FolderMan::instance()->folderForPath(localPath, &file); if (!folder) { qCWarning(lcApplication) << "Could not open share dialog for" << localPath << "no responsible folder found"; return; @@ -1045,7 +1046,6 @@ void ownCloudGui::slotShowShareDialog(const QString &sharePath, const QString &l const auto accountState = folder->accountState(); - const QString file = localPath.mid(folder->cleanPath().length() + 1); SyncJournalFileRecord fileRecord; bool resharingAllowed = true; // lets assume the good diff --git a/src/gui/owncloudsetupwizard.cpp b/src/gui/owncloudsetupwizard.cpp index a1632fc3f..147a41686 100644 --- a/src/gui/owncloudsetupwizard.cpp +++ b/src/gui/owncloudsetupwizard.cpp @@ -617,6 +617,7 @@ void OwncloudSetupWizard::slotAssistantFinished(int result) folderDefinition.localPath = localFolder; folderDefinition.targetPath = FolderDefinition::prepareTargetPath(_remoteFolder); folderDefinition.ignoreHiddenFiles = folderMan->ignoreHiddenFiles(); + folderDefinition.usePlaceholders = _ocWizard->usePlaceholderSync(); if (folderMan->navigationPaneHelper().showInExplorerNavigationPane()) folderDefinition.navigationPaneClsid = QUuid::createUuid(); diff --git a/src/gui/socketapi.cpp b/src/gui/socketapi.cpp index cc3382e73..585e4e833 100644 --- a/src/gui/socketapi.cpp +++ b/src/gui/socketapi.cpp @@ -656,6 +656,22 @@ void SocketApi::copyUrlToClipboard(const QString &link) QApplication::clipboard()->setText(link); } +void SocketApi::command_DOWNLOAD_PLACEHOLDER(const QString &filesArg, SocketListener *) +{ + QStringList files = filesArg.split(QLatin1Char('\x1e')); // Record Separator + auto placeholderSuffix = QStringLiteral(APPLICATION_DOTPLACEHOLDER_SUFFIX); + + for (const auto &file : files) { + if (!file.endsWith(placeholderSuffix)) + continue; + QString relativePath; + auto folder = FolderMan::instance()->folderForPath(file, &relativePath); + if (folder) { + folder->downloadPlaceholder(relativePath); + } + } +} + void SocketApi::emailPrivateLink(const QString &link) { Utility::openEmailComposer( @@ -734,11 +750,10 @@ SocketApi::FileData SocketApi::FileData::get(const QString &localFile) if (data.localPath.endsWith(QLatin1Char('/'))) data.localPath.chop(1); - data.folder = FolderMan::instance()->folderForPath(data.localPath); + data.folder = FolderMan::instance()->folderForPath(data.localPath, &data.folderRelativePath); if (!data.folder) return data; - data.folderRelativePath = data.localPath.mid(data.folder->cleanPath().length() + 1); data.accountRelativePath = QDir(data.folder->remotePath()).filePath(data.folderRelativePath); return data; @@ -763,14 +778,47 @@ SyncJournalFileRecord SocketApi::FileData::journalRecord() const void SocketApi::command_GET_MENU_ITEMS(const QString &argument, OCC::SocketListener *listener) { listener->sendMessage(QString("GET_MENU_ITEMS:BEGIN")); - bool hasSeveralFiles = argument.contains(QLatin1Char('\x1e')); // Record Separator - FileData fileData = hasSeveralFiles ? FileData{} : FileData::get(argument); - bool isOnTheServer = fileData.journalRecord().isValid(); - auto flagString = isOnTheServer ? QLatin1String("::") : QLatin1String(":d:"); - if (fileData.folder && fileData.folder->accountState()->isConnected()) { - sendSharingContextMenuOptions(fileData, listener); - listener->sendMessage(QLatin1String("MENU_ITEM:OPEN_PRIVATE_LINK") + flagString + tr("Open in browser")); + QStringList files = argument.split(QLatin1Char('\x1e')); // Record Separator + + // Find the common sync folder. + // syncFolder will be null if files are in different folders. + Folder *folder = nullptr; + for (const auto &file : files) { + auto f = FolderMan::instance()->folderForPath(file); + if (f != folder) { + if (!folder) { + folder = f; + } else { + folder = nullptr; + break; + } + } } + + // Some options only show for single files + if (files.size() == 1) { + FileData fileData = FileData::get(files.first()); + bool isOnTheServer = fileData.journalRecord().isValid(); + auto flagString = isOnTheServer ? QLatin1String("::") : QLatin1String(":d:"); + + if (fileData.folder && fileData.folder->accountState()->isConnected()) { + sendSharingContextMenuOptions(fileData, listener); + listener->sendMessage(QLatin1String("MENU_ITEM:OPEN_PRIVATE_LINK") + flagString + tr("Open in browser")); + } + } + + // Placeholder download action + if (folder) { + auto placeholderSuffix = QStringLiteral(APPLICATION_DOTPLACEHOLDER_SUFFIX); + bool hasPlaceholderFile = false; + for (const auto &file : files) { + if (file.endsWith(placeholderSuffix)) + hasPlaceholderFile = true; + } + if (hasPlaceholderFile) + listener->sendMessage(QLatin1String("MENU_ITEM:DOWNLOAD_PLACEHOLDER::") + tr("Download file(s)", "", files.size())); + } + listener->sendMessage(QString("GET_MENU_ITEMS:END")); } diff --git a/src/gui/socketapi.h b/src/gui/socketapi.h index c0ebeda94..20ab82672 100644 --- a/src/gui/socketapi.h +++ b/src/gui/socketapi.h @@ -105,6 +105,7 @@ private: Q_INVOKABLE void command_COPY_PRIVATE_LINK(const QString &localFile, SocketListener *listener); Q_INVOKABLE void command_EMAIL_PRIVATE_LINK(const QString &localFile, SocketListener *listener); Q_INVOKABLE void command_OPEN_PRIVATE_LINK(const QString &localFile, SocketListener *listener); + Q_INVOKABLE void command_DOWNLOAD_PLACEHOLDER(const QString &filesArg, SocketListener *listener); // Fetch the private link and call targetFun void fetchPrivateLinkUrlHelper(const QString &localFile, const std::function<void(const QString &url)> &targetFun); diff --git a/src/gui/wizard/owncloudadvancedsetuppage.cpp b/src/gui/wizard/owncloudadvancedsetuppage.cpp index 9257918dd..2b2824558 100644 --- a/src/gui/wizard/owncloudadvancedsetuppage.cpp +++ b/src/gui/wizard/owncloudadvancedsetuppage.cpp @@ -17,6 +17,7 @@ #include <QFileDialog> #include <QUrl> #include <QTimer> +#include <QMessageBox> #include "QProgressIndicator.h" @@ -59,6 +60,7 @@ OwncloudAdvancedSetupPage::OwncloudAdvancedSetupPage() connect(_ui.rSyncEverything, &QAbstractButton::clicked, this, &OwncloudAdvancedSetupPage::slotSyncEverythingClicked); connect(_ui.rSelectiveSync, &QAbstractButton::clicked, this, &OwncloudAdvancedSetupPage::slotSelectiveSyncClicked); + connect(_ui.rPlaceholderSync, &QAbstractButton::clicked, this, &OwncloudAdvancedSetupPage::slotPlaceholderSyncClicked); connect(_ui.bSelectiveSync, &QAbstractButton::clicked, this, &OwncloudAdvancedSetupPage::slotSelectiveSyncClicked); QIcon appIcon = theme->applicationIcon(); @@ -102,6 +104,14 @@ void OwncloudAdvancedSetupPage::initializePage() { WizardCommon::initErrorLabel(_ui.errorLabel); + if (!ConfigFile().showExperimentalOptions()) { + // If the layout were wrapped in a widget, the auto-grouping of the + // radio buttons no longer works and there are surprising margins. + // Just manually hide the button and remove the layout. + _ui.rPlaceholderSync->hide(); + _ui.wSyncStrategy->layout()->removeItem(_ui.lPlaceholderSync); + } + _checking = false; _ui.lSelectiveSyncSizeLabel->setText(QString()); _ui.lSyncEverythingSizeLabel->setText(QString()); @@ -222,6 +232,11 @@ QStringList OwncloudAdvancedSetupPage::selectiveSyncBlacklist() const return _selectiveSyncBlacklist; } +bool OwncloudAdvancedSetupPage::usePlaceholderSync() const +{ + return _ui.rPlaceholderSync->isChecked(); +} + bool OwncloudAdvancedSetupPage::isConfirmBigFolderChecked() const { return _ui.rSyncEverything->isChecked() && _ui.confCheckBoxSize->isChecked(); @@ -292,9 +307,6 @@ void OwncloudAdvancedSetupPage::slotSelectFolder() void OwncloudAdvancedSetupPage::slotSelectiveSyncClicked() { - // Because clicking on it also changes it, restore it to the previous state in case the user cancelled the dialog - _ui.rSyncEverything->setChecked(_selectiveSyncBlacklist.isEmpty()); - AccountPtr acc = static_cast<OwncloudWizard *>(wizard())->account(); SelectiveSyncDialog *dlg = new SelectiveSyncDialog(acc, _remoteFolder, _selectiveSyncBlacklist, this); @@ -317,7 +329,7 @@ void OwncloudAdvancedSetupPage::slotSelectiveSyncClicked() if (updateBlacklist) { if (!_selectiveSyncBlacklist.isEmpty()) { _ui.rSelectiveSync->blockSignals(true); - _ui.rSelectiveSync->setChecked(true); + setRadioChecked(_ui.rSelectiveSync); _ui.rSelectiveSync->blockSignals(false); auto s = dlg->estimatedSize(); if (s > 0) { @@ -326,17 +338,29 @@ void OwncloudAdvancedSetupPage::slotSelectiveSyncClicked() _ui.lSelectiveSyncSizeLabel->setText(QString()); } } else { - _ui.rSyncEverything->setChecked(true); + setRadioChecked(_ui.rSyncEverything); _ui.lSelectiveSyncSizeLabel->setText(QString()); } wizard()->setProperty("blacklist", _selectiveSyncBlacklist); } } +void OwncloudAdvancedSetupPage::slotPlaceholderSyncClicked() +{ + OwncloudWizard::askExperimentalPlaceholderFeature([this](bool enable) { + if (!enable) + return; + + _ui.lSelectiveSyncSizeLabel->setText(QString()); + _selectiveSyncBlacklist.clear(); + setRadioChecked(_ui.rPlaceholderSync); + }); +} + void OwncloudAdvancedSetupPage::slotSyncEverythingClicked() { _ui.lSelectiveSyncSizeLabel->setText(QString()); - _ui.rSyncEverything->setChecked(true); + setRadioChecked(_ui.rSyncEverything); _selectiveSyncBlacklist.clear(); } @@ -345,4 +369,18 @@ void OwncloudAdvancedSetupPage::slotQuotaRetrieved(const QVariantMap &result) _ui.lSyncEverythingSizeLabel->setText(tr("(%1)").arg(Utility::octetsToString(result["size"].toDouble()))); } +void OwncloudAdvancedSetupPage::setRadioChecked(QRadioButton *radio) +{ + // We don't want clicking the radio buttons to immediately adjust the checked state + // for selective sync and placeholder sync, so we keep them uncheckable until + // they should be checked. + radio->setCheckable(true); + radio->setChecked(true); + + if (radio != _ui.rSelectiveSync) + _ui.rSelectiveSync->setCheckable(false); + if (radio != _ui.rPlaceholderSync) + _ui.rPlaceholderSync->setCheckable(false); +} + } // namespace OCC diff --git a/src/gui/wizard/owncloudadvancedsetuppage.h b/src/gui/wizard/owncloudadvancedsetuppage.h index a51c51b1b..3db9cdffe 100644 --- a/src/gui/wizard/owncloudadvancedsetuppage.h +++ b/src/gui/wizard/owncloudadvancedsetuppage.h @@ -41,6 +41,7 @@ public: bool validatePage() Q_DECL_OVERRIDE; QString localFolder() const; QStringList selectiveSyncBlacklist() const; + bool usePlaceholderSync() const; bool isConfirmBigFolderChecked() const; void setRemoteFolder(const QString &remoteFolder); void setMultipleFoldersExist(bool exist); @@ -56,9 +57,12 @@ private slots: void slotSelectFolder(); void slotSyncEverythingClicked(); void slotSelectiveSyncClicked(); + void slotPlaceholderSyncClicked(); void slotQuotaRetrieved(const QVariantMap &result); private: + void setRadioChecked(QRadioButton *radio); + void setupCustomization(); void updateStatus(); bool dataChanged(); diff --git a/src/gui/wizard/owncloudadvancedsetuppage.ui b/src/gui/wizard/owncloudadvancedsetuppage.ui index a36704c38..b63c05620 100644 --- a/src/gui/wizard/owncloudadvancedsetuppage.ui +++ b/src/gui/wizard/owncloudadvancedsetuppage.ui @@ -227,7 +227,7 @@ </widget> </item> <item row="0" column="1" colspan="2"> - <widget class="QWidget" name="widget" native="true"> + <widget class="QWidget" name="wSyncStrategy" native="true"> <layout class="QVBoxLayout" name="verticalLayout"> <property name="leftMargin"> <number>0</number> @@ -343,6 +343,9 @@ <property name="text"> <string/> </property> + <property name="checkable"> + <bool>false</bool> + </property> </widget> </item> <item> @@ -377,6 +380,39 @@ </item> </layout> </item> + <item> + <layout class="QHBoxLayout" name="lPlaceholderSync"> + <item> + <widget class="QRadioButton" name="rPlaceholderSync"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Create placeholders instead of downloading files (experimental)</string> + </property> + <property name="checkable"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> </layout> </widget> </item> diff --git a/src/gui/wizard/owncloudwizard.cpp b/src/gui/wizard/owncloudwizard.cpp index 991b5fc18..d35838cc4 100644 --- a/src/gui/wizard/owncloudwizard.cpp +++ b/src/gui/wizard/owncloudwizard.cpp @@ -14,6 +14,7 @@ */ #include "account.h" +#include "config.h" #include "configfile.h" #include "theme.h" @@ -31,6 +32,7 @@ #include <QtCore> #include <QtGui> +#include <QMessageBox> #include <stdlib.h> @@ -113,6 +115,11 @@ QStringList OwncloudWizard::selectiveSyncBlacklist() const return _advancedSetupPage->selectiveSyncBlacklist(); } +bool OwncloudWizard::usePlaceholderSync() const +{ + return _advancedSetupPage->usePlaceholderSync(); +} + bool OwncloudWizard::isConfirmBigFolderChecked() const { return _advancedSetupPage->isConfirmBigFolderChecked(); @@ -245,4 +252,25 @@ AbstractCredentials *OwncloudWizard::getCredentials() const return 0; } +void OwncloudWizard::askExperimentalPlaceholderFeature(const std::function<void(bool enable)> &callback) +{ + auto msgBox = new QMessageBox( + QMessageBox::Warning, + tr("Enable experimental feature?"), + tr("When the \"synchronize placeholders\" mode is enabled no files will be downloaded initially. " + "Instead, a tiny \"%1\" file will be created for each file on the server. " + "The contents can be downloaded by running these files or by using their context menu." + "\n\n" + "This is a new, experimental mode. If you decide to use it, please report any " + "issues that come up.") + .arg(APPLICATION_DOTPLACEHOLDER_SUFFIX)); + msgBox->addButton(tr("Enable experimental mode"), QMessageBox::AcceptRole); + msgBox->addButton(tr("Stay safe"), QMessageBox::RejectRole); + connect(msgBox, &QMessageBox::finished, msgBox, [callback, msgBox](int result) { + callback(result == QMessageBox::AcceptRole); + msgBox->deleteLater(); + }); + msgBox->open(); +} + } // end namespace diff --git a/src/gui/wizard/owncloudwizard.h b/src/gui/wizard/owncloudwizard.h index e82817a03..f51a756e9 100644 --- a/src/gui/wizard/owncloudwizard.h +++ b/src/gui/wizard/owncloudwizard.h @@ -63,6 +63,7 @@ public: QString ocUrl() const; QString localFolder() const; QStringList selectiveSyncBlacklist() const; + bool usePlaceholderSync() const; bool isConfirmBigFolderChecked() const; void enableFinishOnResultWidget(bool enable); @@ -70,6 +71,13 @@ public: void displayError(const QString &, bool retryHTTPonly); AbstractCredentials *getCredentials() const; + /** + * Shows a dialog explaining the placeholder mode and warning about it + * being experimental. Calles the callback with true if enabling was + * chosen. + */ + static void askExperimentalPlaceholderFeature(const std::function<void(bool enable)> &callback); + // FIXME: Can those be local variables? // Set from the OwncloudSetupPage, later used from OwncloudHttpCredsPage QSslKey _clientSslKey; diff --git a/src/libsync/configfile.cpp b/src/libsync/configfile.cpp index d82e22e01..d0dadbf0f 100644 --- a/src/libsync/configfile.cpp +++ b/src/libsync/configfile.cpp @@ -65,6 +65,7 @@ static const char minChunkSizeC[] = "minChunkSize"; static const char maxChunkSizeC[] = "maxChunkSize"; static const char targetChunkUploadDurationC[] = "targetChunkUploadDuration"; static const char automaticLogDirC[] = "logToTemporaryLogDir"; +static const char showExperimentalOptionsC[] = "showExperimentalOptions"; static const char proxyHostC[] = "Proxy/host"; static const char proxyTypeC[] = "Proxy/type"; @@ -749,6 +750,12 @@ void ConfigFile::setAutomaticLogDir(bool enabled) settings.setValue(QLatin1String(automaticLogDirC), enabled); } +bool ConfigFile::showExperimentalOptions() const +{ + QSettings settings(configFile(), QSettings::IniFormat); + return settings.value(QLatin1String(showExperimentalOptionsC), false).toBool(); +} + QString ConfigFile::certificatePath() const { return retrieveData(QString(), QLatin1String(certPath)).toString(); diff --git a/src/libsync/configfile.h b/src/libsync/configfile.h index 901dab528..99aa6d5f1 100644 --- a/src/libsync/configfile.h +++ b/src/libsync/configfile.h @@ -92,6 +92,9 @@ public: bool automaticLogDir() const; void setAutomaticLogDir(bool enabled); + // Whether experimental UI options should be shown + bool showExperimentalOptions() const; + // proxy settings void setProxyType(int proxyType, const QString &host = QString(), diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index 6ac8da4ae..3cd8ab0be 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -589,6 +589,11 @@ QString OwncloudPropagator::getFilePath(const QString &tmp_file_name) const return _localDir + tmp_file_name; } +QString OwncloudPropagator::addPlaceholderSuffix(const QString &fileName) const +{ + return fileName + _syncOptions._placeholderSuffix; +} + void OwncloudPropagator::scheduleNextJob() { QTimer::singleShot(0, this, &OwncloudPropagator::scheduleNextJobImpl); diff --git a/src/libsync/owncloudpropagator.h b/src/libsync/owncloudpropagator.h index e5c42d284..b4bc470d2 100644 --- a/src/libsync/owncloudpropagator.h +++ b/src/libsync/owncloudpropagator.h @@ -451,6 +451,7 @@ public: bool hasCaseClashAccessibilityProblem(const QString &relfile); QString getFilePath(const QString &tmp_file_name) const; + QString addPlaceholderSuffix(const QString &fileName) const; /** Creates the job for an item. */ diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index 0601e9fba..77ce71386 100644 --- a/src/libsync/propagatedownload.cpp +++ b/src/libsync/propagatedownload.cpp @@ -348,6 +348,29 @@ void PropagateDownloadFile::start() qCDebug(lcPropagateDownload) << _item->_file << propagator()->_activeJobList.count(); _stopwatch.start(); + // For placeholder files just create the file and be done + if (_item->_type == ItemTypePlaceholder) { + auto fn = propagator()->getFilePath(_item->_file); + qCDebug(lcPropagateDownload) << "creating placeholder file" << fn; + QFile file(fn); + file.open(QFile::ReadWrite); + file.write("stub"); + file.close(); + updateMetadata(false); + return; + } + + // If we want to download something that used to be a placeholder, + // wipe the placeholder and proceed with a normal download + if (_item->_type == ItemTypePlaceholderDownload) { + auto placeholder = propagator()->addPlaceholderSuffix(_item->_file); + auto fn = propagator()->getFilePath(placeholder); + qCDebug(lcPropagateDownload) << "Downloading file that used to be a placeholder" << fn; + QFile::remove(fn); + propagator()->_journal->deleteFileRecord(placeholder); + _item->_type = ItemTypeFile; + } + if (_deleteExisting) { deleteExistingFolder(); diff --git a/src/libsync/propagatorjobs.cpp b/src/libsync/propagatorjobs.cpp index 1634599d4..b5b99f1d3 100644 --- a/src/libsync/propagatorjobs.cpp +++ b/src/libsync/propagatorjobs.cpp @@ -116,7 +116,6 @@ void PropagateLocalRemove::start() return; QString filename = propagator()->_localDir + _item->_file; - qCDebug(lcPropagateLocalRemove) << filename; if (propagator()->localFileNameClash(_item->_file)) { diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index 665ae9652..64481ca60 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -614,7 +614,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other, if (remote) { QString filePath = _localPath + item->_file; - if (other) { + if (other && other->type != ItemTypePlaceholder && other->type != ItemTypePlaceholderDownload) { // Even if the mtime is different on the server, we always want to keep the mtime from // the file system in the DB, this is to avoid spurious upload on the next sync item->_modtime = other->modtime; @@ -851,6 +851,14 @@ void SyncEngine::startSync() return shouldDiscoverLocally(path); }; + _csync_ctx->new_files_are_placeholders = _syncOptions._newFilesArePlaceholders; + _csync_ctx->placeholder_suffix = _syncOptions._placeholderSuffix.toUtf8(); + if (_csync_ctx->new_files_are_placeholders && _csync_ctx->placeholder_suffix.isEmpty()) { + csyncError(tr("Using placeholder files, but placeholder suffix is not set")); + finalize(false); + return; + } + bool ok; auto selectiveSyncBlackList = _journal->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); if (ok) { diff --git a/src/libsync/syncengine.h b/src/libsync/syncengine.h index 478a4703f..e00034f4d 100644 --- a/src/libsync/syncengine.h +++ b/src/libsync/syncengine.h @@ -76,6 +76,7 @@ public: bool isSyncRunning() const { return _syncRunning; } + SyncOptions syncOptions() const { return _syncOptions; } void setSyncOptions(const SyncOptions &options) { _syncOptions = options; } bool ignoreHiddenFiles() const { return _csync_ctx->ignore_hidden_files; } void setIgnoreHiddenFiles(bool ignore) { _csync_ctx->ignore_hidden_files = ignore; } diff --git a/src/libsync/syncoptions.h b/src/libsync/syncoptions.h index 676bfbeb7..ea48cd83e 100644 --- a/src/libsync/syncoptions.h +++ b/src/libsync/syncoptions.h @@ -36,6 +36,10 @@ struct SyncOptions /** If remotely deleted files are needed to move to trash */ bool _moveFilesToTrash = false; + /** Create a placeholder for new files instead of downloading */ + bool _newFilesArePlaceholders = false; + QString _placeholderSuffix = ".owncloud"; + /** The initial un-adjusted chunk size in bytes for chunked uploads, both * for old and new chunking algorithm, which classifies the item to be chunked * diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bb184412b..2df611817 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -42,6 +42,7 @@ owncloud_add_test(ExcludedFiles "") owncloud_add_test(FileSystem "") owncloud_add_test(Utility "") owncloud_add_test(SyncEngine "syncenginetestutils.h") +owncloud_add_test(SyncPlaceholders "syncenginetestutils.h") owncloud_add_test(SyncMove "syncenginetestutils.h") owncloud_add_test(SyncConflict "syncenginetestutils.h") owncloud_add_test(SyncFileStatusTracker "syncenginetestutils.h") diff --git a/test/csync/csync_tests/check_csync_exclude.cpp b/test/csync/csync_tests/check_csync_exclude.cpp index 95b6e9a31..2c3147de3 100644 --- a/test/csync/csync_tests/check_csync_exclude.cpp +++ b/test/csync/csync_tests/check_csync_exclude.cpp @@ -22,6 +22,8 @@ #include <time.h> #include <sys/time.h> +#include <QTemporaryDir> + #define CSYNC_TEST 1 #include "csync_exclude.cpp" @@ -625,6 +627,81 @@ static void check_csync_exclude_expand_escapes(void **state) assert_true(0 == strcmp(line.constData(), "\\")); } +static void check_placeholder_exclude(void **state) +{ + (void)state; + + auto readFile = [](const QString &file) { + QFile f(file); + f.open(QIODevice::ReadOnly | QIODevice::Text); + return f.readAll(); + }; + + QTemporaryDir tempDir; + QString path; + QByteArray expected = "\n#!version < 2.5.0\n*.owncloud\n"; + + // Case 1: No file exists yet, parent dirs are missing too + path = tempDir.filePath("foo/bar/exclude.lst"); + ExcludedFiles::setupPlaceholderExclude(path, ".owncloud"); + + assert_true(QFile::exists(path)); + assert_true(readFile(path) == expected); + + // Case 2: Running it again + ExcludedFiles::setupPlaceholderExclude(path, ".owncloud"); + assert_true(readFile(path) == expected); + + // Case 3: File exists, has some data + { + QFile f(path); + f.open(QIODevice::WriteOnly | QIODevice::Truncate); + f.write("# bla\nmyexclude\n\nanotherexclude"); + f.close(); + } + ExcludedFiles::setupPlaceholderExclude(path, ".owncloud"); + assert_true(readFile(path) == "# bla\nmyexclude\n\nanotherexclude" + expected); + + // Case 4: Running it again still does nothing + ExcludedFiles::setupPlaceholderExclude(path, ".owncloud"); + assert_true(readFile(path) == "# bla\nmyexclude\n\nanotherexclude" + expected); + + // Case 5: Verify that reading this file doesn't actually include the exclude + ExcludedFiles excludes; + excludes.addExcludeFilePath(path); + excludes.reloadExcludeFiles(); + assert_false(excludes._allExcludes.contains("*.owncloud")); + assert_true(excludes._allExcludes.contains("myexclude")); +} + +static void check_version_directive(void **state) +{ + (void)state; + + ExcludedFiles excludes; + excludes.setClientVersion(ExcludedFiles::Version(2, 5, 0)); + + std::vector<std::pair<const char *, bool>> tests = { + { "#!version == 2.5.0", true }, + { "#!version == 2.6.0", false }, + { "#!version < 2.6.0", true }, + { "#!version <= 2.6.0", true }, + { "#!version > 2.6.0", false }, + { "#!version >= 2.6.0", false }, + { "#!version < 2.4.0", false }, + { "#!version <= 2.4.0", false }, + { "#!version > 2.4.0", true }, + { "#!version >= 2.4.0", true }, + { "#!version < 2.5.0", false }, + { "#!version <= 2.5.0", true }, + { "#!version > 2.5.0", false }, + { "#!version >= 2.5.0", true }, + }; + for (auto test : tests) { + assert_true(excludes.versionDirectiveKeepNextLine(test.first) == test.second); + } +} + }; // class ExcludedFilesTest int torture_run_tests(void) @@ -643,6 +720,8 @@ int torture_run_tests(void) cmocka_unit_test_setup_teardown(T::check_csync_is_windows_reserved_word, T::setup_init, T::teardown), cmocka_unit_test_setup_teardown(T::check_csync_excluded_performance, T::setup_init, T::teardown), cmocka_unit_test(T::check_csync_exclude_expand_escapes), + cmocka_unit_test(T::check_placeholder_exclude), + cmocka_unit_test(T::check_version_directive), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/test/testsyncplaceholders.cpp b/test/testsyncplaceholders.cpp new file mode 100644 index 000000000..454ef45ac --- /dev/null +++ b/test/testsyncplaceholders.cpp @@ -0,0 +1,437 @@ +/* + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + * + */ + +#include <QtTest> +#include "syncenginetestutils.h" +#include <syncengine.h> + +using namespace OCC; + +SyncFileItemPtr findItem(const QSignalSpy &spy, const QString &path) +{ + for (const QList<QVariant> &args : spy) { + auto item = args[0].value<SyncFileItemPtr>(); + if (item->destination() == path) + return item; + } + return SyncFileItemPtr(new SyncFileItem); +} + +bool itemInstruction(const QSignalSpy &spy, const QString &path, const csync_instructions_e instr) +{ + auto item = findItem(spy, path); + return item->_instruction == instr; +} + +SyncJournalFileRecord dbRecord(FakeFolder &folder, const QString &path) +{ + SyncJournalFileRecord record; + folder.syncJournal().getFileRecord(path, &record); + return record; +} + +class TestSyncPlaceholders : public QObject +{ + Q_OBJECT + +private slots: + void testPlaceholderLifecycle_data() + { + QTest::addColumn<bool>("doLocalDiscovery"); + + QTest::newRow("full local discovery") << true; + QTest::newRow("skip local discovery") << false; + } + + void testPlaceholderLifecycle() + { + QFETCH(bool, doLocalDiscovery); + + FakeFolder fakeFolder{FileInfo()}; + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + if (!doLocalDiscovery) + fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem); + }; + cleanup(); + + // Create a placeholder for a new remote file + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1", 64); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1.owncloud", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._type, ItemTypePlaceholder); + cleanup(); + + // Another sync doesn't actually lead to changes + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._type, ItemTypePlaceholder); + QVERIFY(completeSpy.isEmpty()); + cleanup(); + + // Not even when the remote is rediscovered + fakeFolder.syncJournal().forceRemoteDiscoveryNextSync(); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._type, ItemTypePlaceholder); + QVERIFY(completeSpy.isEmpty()); + cleanup(); + + // Neither does a remote change + fakeFolder.remoteModifier().appendByte("A/a1"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1.owncloud", CSYNC_INSTRUCTION_UPDATE_METADATA)); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._type, ItemTypePlaceholder); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._fileSize, 65); + cleanup(); + + // If the local placeholder file is removed, it'll just be recreated + if (!doLocalDiscovery) + fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem, { "A" }); + fakeFolder.localModifier().remove("A/a1.owncloud"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(itemInstruction(completeSpy, "A/a1.owncloud", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._type, ItemTypePlaceholder); + QCOMPARE(dbRecord(fakeFolder, "A/a1.owncloud")._fileSize, 65); + cleanup(); + + // Remote rename is propagated + fakeFolder.remoteModifier().rename("A/a1", "A/a1m"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1m")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a1m.owncloud")); + QVERIFY(!fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1m")); + QVERIFY(itemInstruction(completeSpy, "A/a1m.owncloud", CSYNC_INSTRUCTION_RENAME)); + QCOMPARE(dbRecord(fakeFolder, "A/a1m.owncloud")._type, ItemTypePlaceholder); + cleanup(); + + // Remote remove is propagated + fakeFolder.remoteModifier().remove("A/a1m"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1m.owncloud")); + QVERIFY(!fakeFolder.currentRemoteState().find("A/a1m")); + QVERIFY(itemInstruction(completeSpy, "A/a1m.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(!dbRecord(fakeFolder, "A/a1.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a1m.owncloud").isValid()); + cleanup(); + + // Edge case: Local placeholder but no db entry for some reason + fakeFolder.remoteModifier().insert("A/a2", 64); + fakeFolder.remoteModifier().insert("A/a3", 64); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a2.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a3.owncloud")); + cleanup(); + + fakeFolder.syncEngine().journal()->deleteFileRecord("A/a2.owncloud"); + fakeFolder.syncEngine().journal()->deleteFileRecord("A/a3.owncloud"); + fakeFolder.remoteModifier().remove("A/a3"); + fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::FilesystemOnly); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a2.owncloud")); + QVERIFY(itemInstruction(completeSpy, "A/a2.owncloud", CSYNC_INSTRUCTION_NEW)); + QVERIFY(dbRecord(fakeFolder, "A/a2.owncloud").isValid()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a3.owncloud")); + QVERIFY(!dbRecord(fakeFolder, "A/a3.owncloud").isValid()); + cleanup(); + } + + void testPlaceholderConflict() + { + FakeFolder fakeFolder{ FileInfo() }; + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + }; + cleanup(); + + // Create a placeholder for a new remote file + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1", 64); + fakeFolder.remoteModifier().insert("A/a2", 64); + fakeFolder.remoteModifier().mkdir("B"); + fakeFolder.remoteModifier().insert("B/b1", 64); + fakeFolder.remoteModifier().insert("B/b2", 64); + fakeFolder.remoteModifier().mkdir("C"); + fakeFolder.remoteModifier().insert("C/c1", 64); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("B/b2.owncloud")); + cleanup(); + + // A: the correct file and a conflicting file are added, placeholders stay + // B: same setup, but the placeholders are deleted by the user + // C: user adds a *directory* locally + fakeFolder.localModifier().insert("A/a1", 64); + fakeFolder.localModifier().insert("A/a2", 30); + fakeFolder.localModifier().insert("B/b1", 64); + fakeFolder.localModifier().insert("B/b2", 30); + fakeFolder.localModifier().remove("B/b1.owncloud"); + fakeFolder.localModifier().remove("B/b2.owncloud"); + fakeFolder.localModifier().mkdir("C/c1"); + fakeFolder.localModifier().insert("C/c1/foo"); + QVERIFY(fakeFolder.syncOnce()); + + // Everything is CONFLICT since mtimes are different even for a1/b1 + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_CONFLICT)); + QVERIFY(itemInstruction(completeSpy, "A/a2", CSYNC_INSTRUCTION_CONFLICT)); + QVERIFY(itemInstruction(completeSpy, "B/b1", CSYNC_INSTRUCTION_CONFLICT)); + QVERIFY(itemInstruction(completeSpy, "B/b2", CSYNC_INSTRUCTION_CONFLICT)); + QVERIFY(itemInstruction(completeSpy, "C/c1", CSYNC_INSTRUCTION_CONFLICT)); + + // no placeholder files should remain + QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a2.owncloud")); + QVERIFY(!fakeFolder.currentLocalState().find("B/b1.owncloud")); + QVERIFY(!fakeFolder.currentLocalState().find("B/b2.owncloud")); + QVERIFY(!fakeFolder.currentLocalState().find("C/c1.owncloud")); + + // conflict files should exist + QCOMPARE(fakeFolder.syncJournal().conflictRecordPaths().size(), 3); + + // nothing should have the placeholder tag + QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "A/a2")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "B/b1")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "B/b2")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "C/c1")._type, ItemTypeFile); + QVERIFY(!dbRecord(fakeFolder, "A/a1.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a2.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "B/b1.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "B/b2.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "C/c1.owncloud").isValid()); + + cleanup(); + } + + void testWithNormalSync() + { + FakeFolder fakeFolder{FileInfo::A12_B12_C12_S12()}; + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + }; + cleanup(); + + // No effect sync + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + cleanup(); + + // Existing files are propagated just fine in both directions + fakeFolder.localModifier().appendByte("A/a1"); + fakeFolder.localModifier().insert("A/a3"); + fakeFolder.remoteModifier().appendByte("A/a2"); + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + cleanup(); + + // New files on the remote create placeholders + fakeFolder.remoteModifier().insert("A/new"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/new")); + QVERIFY(fakeFolder.currentLocalState().find("A/new.owncloud")); + QVERIFY(fakeFolder.currentRemoteState().find("A/new")); + QVERIFY(itemInstruction(completeSpy, "A/new.owncloud", CSYNC_INSTRUCTION_NEW)); + QCOMPARE(dbRecord(fakeFolder, "A/new.owncloud")._type, ItemTypePlaceholder); + cleanup(); + } + + void testPlaceholderDownload() + { + FakeFolder fakeFolder{FileInfo()}; + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &))); + + auto cleanup = [&]() { + completeSpy.clear(); + }; + cleanup(); + + auto triggerDownload = [&](const QByteArray &path) { + auto &journal = fakeFolder.syncJournal(); + SyncJournalFileRecord record; + journal.getFileRecord(path + ".owncloud", &record); + if (!record.isValid()) + return; + record._type = ItemTypePlaceholderDownload; + journal.setFileRecord(record); + }; + + // Create a placeholder for remote files + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1"); + fakeFolder.remoteModifier().insert("A/a2"); + fakeFolder.remoteModifier().insert("A/a3"); + fakeFolder.remoteModifier().insert("A/a4"); + fakeFolder.remoteModifier().insert("A/a5"); + fakeFolder.remoteModifier().insert("A/a6"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a2.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a3.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a4.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a5.owncloud")); + QVERIFY(fakeFolder.currentLocalState().find("A/a6.owncloud")); + cleanup(); + + // Download by changing the db entry + triggerDownload("A/a1"); + triggerDownload("A/a2"); + triggerDownload("A/a3"); + triggerDownload("A/a4"); + triggerDownload("A/a5"); + triggerDownload("A/a6"); + fakeFolder.remoteModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().remove("A/a3"); + fakeFolder.remoteModifier().rename("A/a4", "A/a4m"); + fakeFolder.localModifier().insert("A/a5"); + fakeFolder.localModifier().insert("A/a6"); + fakeFolder.localModifier().remove("A/a6.owncloud"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW)); + QVERIFY(itemInstruction(completeSpy, "A/a1.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a2", CSYNC_INSTRUCTION_NEW)); + QVERIFY(itemInstruction(completeSpy, "A/a2.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a3.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a4.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a4m", CSYNC_INSTRUCTION_NEW)); + QVERIFY(itemInstruction(completeSpy, "A/a5", CSYNC_INSTRUCTION_CONFLICT)); + QVERIFY(itemInstruction(completeSpy, "A/a5.owncloud", CSYNC_INSTRUCTION_REMOVE)); + QVERIFY(itemInstruction(completeSpy, "A/a6", CSYNC_INSTRUCTION_CONFLICT)); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "A/a2")._type, ItemTypeFile); + QVERIFY(!dbRecord(fakeFolder, "A/a3").isValid()); + QCOMPARE(dbRecord(fakeFolder, "A/a4m")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "A/a5")._type, ItemTypeFile); + QCOMPARE(dbRecord(fakeFolder, "A/a6")._type, ItemTypeFile); + QVERIFY(!dbRecord(fakeFolder, "A/a1.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a2.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a3.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a4.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a5.owncloud").isValid()); + QVERIFY(!dbRecord(fakeFolder, "A/a6.owncloud").isValid()); + } + + // Check what might happen if an older sync client encounters placeholders + void testOldVersion1() + { + FakeFolder fakeFolder{ FileInfo() }; + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + + // Create a placeholder + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1"); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + + // Simulate an old client by switching the type of all ItemTypePlaceholder + // entries in the db to an invalid type. + auto &db = fakeFolder.syncJournal(); + SyncJournalFileRecord rec; + db.getFileRecord(QByteArray("A/a1.owncloud"), &rec); + QVERIFY(rec.isValid()); + QCOMPARE(rec._type, ItemTypePlaceholder); + rec._type = static_cast<ItemType>(-1); + db.setFileRecord(rec); + + // Also switch off new files becoming placeholders + syncOptions._newFilesArePlaceholders = false; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + + // A sync that doesn't do remote discovery has no effect + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(fakeFolder.currentRemoteState().find("A/a1")); + QVERIFY(!fakeFolder.currentRemoteState().find("A/a1.owncloud")); + + // But with a remote discovery the placeholders will be removed and + // the remote files will be downloaded. + db.forceRemoteDiscoveryNextSync(); + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentLocalState().find("A/a1")); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud")); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + } + + // Older versions may leave db entries for foo and foo.owncloud + void testOldVersion2() + { + FakeFolder fakeFolder{ FileInfo() }; + + // Sync a file + fakeFolder.remoteModifier().mkdir("A"); + fakeFolder.remoteModifier().insert("A/a1"); + QVERIFY(fakeFolder.syncOnce()); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + + // Create the placeholder too + // In the wild, the new version would create the placeholder and the db entry + // while the old version would download the plain file. + fakeFolder.localModifier().insert("A/a1.owncloud"); + auto &db = fakeFolder.syncJournal(); + SyncJournalFileRecord rec; + db.getFileRecord(QByteArray("A/a1"), &rec); + rec._type = ItemTypePlaceholder; + rec._path = "A/a1.owncloud"; + db.setFileRecord(rec); + + SyncOptions syncOptions; + syncOptions._newFilesArePlaceholders = true; + fakeFolder.syncEngine().setSyncOptions(syncOptions); + + // Check that a sync removes the placeholder and its db entry + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud")); + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + QVERIFY(!dbRecord(fakeFolder, "A/a1.owncloud").isValid()); + } +}; + +QTEST_GUILESS_MAIN(TestSyncPlaceholders) +#include "testsyncplaceholders.moc" |