commit 00d53f648ec13f3738fb1fd7d597d07470fee578 Author: skilion Date: Tue Sep 1 20:45:34 2015 +0200 first commit diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9a040386 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +DC = dmd +DFLAGS = -unittest -debug -g -od./bin -of./bin/$@ -L-lcurl -L-lsqlite3 + +SOURCES = \ + src/cache.d \ + src/config.d \ + src/main.d \ + src/onedrive.d \ + src/sqlite.d \ + src/sync.d \ + src/util.d + +onedrive: $(SOURCES) + $(DC) $(DFLAGS) $(SOURCES) diff --git a/Makefile.win b/Makefile.win new file mode 100644 index 00000000..0a9f98f1 --- /dev/null +++ b/Makefile.win @@ -0,0 +1,14 @@ +DC = dmd +DFLAGS = -debug -g -od./bin -of./bin/$@ lib/sqlite3.lib + +SOURCES = \ + src/cache.d \ + src/config.d \ + src/main.d \ + src/onedrive.d \ + src/sqlite.d \ + src/sync.d \ + src/util.d + +onedrive: $(SOURCES) + $(DC) $(DFLAGS) $(SOURCES) diff --git a/lib/sqlite3.lib b/lib/sqlite3.lib new file mode 100644 index 00000000..3cec9c80 Binary files /dev/null and b/lib/sqlite3.lib differ diff --git a/onedrive.conf b/onedrive.conf new file mode 100644 index 00000000..29e9b881 --- /dev/null +++ b/onedrive.conf @@ -0,0 +1,3 @@ +client_id = "000000004C15842F" +client_secret = "5vWj5xi6rYZM61X81Z9OyXAmjGhVS6Py" +sync_dir = "~/OneDrive/" diff --git a/src/cache.d b/src/cache.d new file mode 100644 index 00000000..6ea70ef6 --- /dev/null +++ b/src/cache.d @@ -0,0 +1,163 @@ +module cache; + +import std.datetime: SysTime, time_t; +import sqlite; + +enum ItemType +{ + file, + dir +} + +struct Item +{ + string id; + string path; + string name; + ItemType type; + string eTag; + string cTag; + SysTime mtime; + string parentId; + string crc32; +} + +struct ItemCache +{ + Database db; + Statement insertItemStmt; + Statement selectItemByIdStmt; + Statement selectItemByPathStmt; + + void init() + { + db = Database("cache.db"); + db.exec("CREATE TABLE IF NOT EXISTS item ( + id TEXT PRIMARY KEY, + path TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + eTag TEXT NOT NULL, + cTag TEXT NOT NULL, + mtime TEXT NOT NULL, + parentId TEXT NOT NULL, + crc32 TEXT + )"); + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON item (path)"); + insertItemStmt = db.prepare("INSERT OR REPLACE INTO item (id, path, name, type, eTag, cTag, mtime, parentId, crc32) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + selectItemByIdStmt = db.prepare("SELECT id, path, name, type, eTag, cTag, mtime, parentId, crc32 FROM item WHERE id = ?"); + selectItemByPathStmt = db.prepare("SELECT id, path, name, type, eTag, cTag, mtime, parentId, crc32 FROM item WHERE path = ?"); + } + + void insert(const(char)[] id, const(char)[] name, ItemType type, const(char)[] eTag, const(char)[] cTag, const(char)[] mtime, const(char)[] parentId, const(char)[] crc32) + { + with (insertItemStmt) { + bind(1, id); + bind(2, computePath(name, parentId)); + bind(3, name); + string typeStr = void; + final switch (type) { + case ItemType.file: typeStr = "file"; break; + case ItemType.dir: typeStr = "dir"; break; + } + bind(4, typeStr); + bind(5, eTag); + bind(6, cTag); + bind(7, mtime); + bind(8, parentId); + bind(9, crc32); + exec(); + } + } + + bool selectById(const(char)[] id, out Item item) + { + selectItemByIdStmt.bind(1, id); + auto r = selectItemByIdStmt.exec(); + if (!r.empty) { + item = buildItem(r); + return true; + } + return false; + } + + bool selectByPath(const(char)[] path, out Item item) + { + selectItemByPathStmt.bind(1, path); + auto r = selectItemByPathStmt.exec(); + if (!r.empty) { + item = buildItem(r); + return true; + } + return false; + } + + void updateModifiedTime(const(char)[] id, const(char)[] mtime) + { + auto s = db.prepare("UPDATE mtime FROM item WHERE id = ?"); + s.bind(1, id); + s.exec(); + } + + void deleteById(const(char)[] id) + { + auto s = db.prepare("DELETE FROM item WHERE id = ?"); + s.bind(1, id); + s.exec(); + } + + // returns true if the item has the specified parent + bool hasParent(T)(const(char)[] itemId, T parentId) + if (is(T : const(char)[]) || is(T : const(char[])[])) + { + auto s = db.prepare("SELECT parentId FROM item WHERE id = ?"); + while (true) { + s.bind(1, itemId); + auto r = s.exec(); + if (r.empty) break; + auto currParentId = r.front[0]; + static if (is(T : const(char)[])) { + if (currParentId == parentId) return true; + } else { + foreach (id; parentId) if (currParentId == id) return true; + } + itemId = currParentId.dup; + } + return false; + } + + private Item buildItem(Statement.Result result) + { + assert(!result.empty && result.front.length == 9); + Item item = { + id: result.front[0].dup, + path: result.front[1].dup, + name: result.front[2].dup, + eTag: result.front[4].dup, + cTag: result.front[5].dup, + mtime: SysTime.fromISOExtString(result.front[6]), + parentId: result.front[7].dup, + crc32: result.front[8].dup + }; + switch (result.front[3]) { + case "file": item.type = ItemType.file; break; + case "dir": item.type = ItemType.dir; break; + default: assert(0); + } + return item; + } + + private string computePath(const(char)[] name, const(char)[] parentId) + { + auto s = db.prepare("SELECT name, parentId FROM item WHERE id = ?"); + string path = name.dup; + while (true) { + s.bind(1, parentId); + auto r = s.exec(); + if (r.empty) break; + path = r.front[0].idup ~ "/" ~ path; + parentId = r.front[1].dup; + } + return path; + } +} diff --git a/src/config.d b/src/config.d new file mode 100644 index 00000000..def78456 --- /dev/null +++ b/src/config.d @@ -0,0 +1,64 @@ +import std.regex, std.stdio, std.file; + +final class Config +{ + private string filename; + private string[string] values; + + this(string filename) + { + this.filename = filename; + load(); + } + + string get(string key) + { + return values[key]; + } + + void set(string key, string value) + { + values[key] = value; + } + + void load() + { + values = null; + scope (failure) return; + auto file = File(filename, "r"); + auto r = regex("(?:^\\s*)(\\w+)(?:\\s*=\\s*\")(.*)(?:\"\\s*$)"); + foreach (line; file.byLine()) { + auto c = matchFirst(line, r); + if (!c.empty) { + c.popFront(); // skip whole match + string key = c.front.dup; + c.popFront(); + values[key] = c.front.dup; + } + } + } + + void save() + { + if (exists(filename)) { + string bkpFilename = filename ~ "~"; + rename(filename, bkpFilename); + } + auto file = File(filename, "w"); + foreach (key, value; values) { + file.writeln(key, " = \"", value, "\""); + } + } +} + +unittest +{ + auto cfg = new Config("/tmp/test.conf"); + cfg.set("test1", "1"); + cfg.set("test2", "2"); + cfg.set("test1", "3"); + cfg.save(); + cfg.load(); + assert(cfg.get("test1") == "3"); + assert(cfg.get("test2") == "2"); +} diff --git a/src/main.d b/src/main.d new file mode 100644 index 00000000..657b88de --- /dev/null +++ b/src/main.d @@ -0,0 +1,30 @@ +import std.file; +import config, onedrive, sync; + +private string configFile = "./onedrive.conf"; +private string refreshTokenFile = "refresh_token"; + +void main() +{ + auto cfg = new Config(configFile); + + auto onedrive = new OneDriveApi(cfg.get("client_id"), cfg.get("client_secret")); + try { + string refreshToken = readText(refreshTokenFile); + onedrive.setRefreshToken(refreshToken); + } catch (FileException e) { + onedrive.authorize(); + } + onedrive.onRefreshToken = (string refreshToken) { std.file.write(refreshTokenFile, refreshToken); }; + + auto sync = new SyncEngine(cfg, onedrive); + sync.applyDifferences(); + + /*import std.stdio; + import std.net.curl; + try { + onedrive.simpleUpload("a.txt", "a.txt", "error").toPrettyString.writeln; + } catch (CurlException e) { + writeln("exc ", e.msg); + }*/ +} diff --git a/src/onedrive.d b/src/onedrive.d new file mode 100644 index 00000000..dd87c80a --- /dev/null +++ b/src/onedrive.d @@ -0,0 +1,219 @@ +module onedrive; + +import std.json, std.net.curl, std.path, std.string, std.uni, std.uri; + +extern(C) void signal(int sig, void function(int)); + +private immutable { + string authUrl = "https://login.live.com/oauth20_authorize.srf"; + string redirectUrl = "https://login.live.com/oauth20_desktop.srf"; + string tokenUrl = "https://login.live.com/oauth20_token.srf"; + string itemByIdUrl = "https://api.onedrive.com/v1.0/drive/items/"; + string itemByPathUrl = "https://api.onedrive.com/v1.0/drive/root:/"; +} + +class OneDriveException: Exception +{ + @nogc @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) + { + super(msg, file, line, next); + } + + @nogc @safe pure nothrow this(string msg, Throwable next, string file = __FILE__, size_t line = __LINE__) + { + super(msg, file, line, next); + } +} + +final class OneDriveApi +{ + private string clientId, clientSecret; + private string refreshToken, accessToken; + private HTTP http; + + void function(string) onRefreshToken; // called when a new refresh_token is received + + this(string clientId, string clientSecret) + { + this.clientId = clientId; + this.clientSecret = clientSecret; + http = HTTP(); + //debug http.verbose = true; + // HACK: prevent SIGPIPE + //import etc.c.curl; + //http.handle.set(CurlOption.nosignal, 0); + //signal(/*SIGPIPE*/ 13, /*SIG_IGN*/ cast(void function(int)) 1); + } + + ~this() + { + http.shutdown(); + } + + void authorize() + { + import std.stdio, std.regex; + string url = authUrl ~ "?client_id=" ~ clientId ~ "&scope=wl.offline_access onedrive.readwrite&response_type=code&redirect_url=" ~ redirectUrl; + writeln("Authorize this app visiting:"); + writeln(url); + + while (true) { + char[] response; + write("Enter the response url: "); + readln(response); + auto c = matchFirst(response, r"(?:code=)(([\w\d]+-){4}[\w\d]+)"); + if (!c.empty) { + c.popFront(); // skip whole match + redeemToken(c.front); + break; + } + } + } + + void setRefreshToken(string refreshToken) + { + this.refreshToken = refreshToken; + newToken(); + } + + string getItemPath(const(char)[] id) + { + JSONValue response = get(itemByIdUrl ~ id ~ "/?select=name,parentReference"); + string path; + try { + path = response["parentReference"].object["path"].str; + } catch (JSONException e) { + // root does not have parentReference + return ""; + } + path = decodeComponent(path[path.indexOf(':') + 1 .. $]); + return buildNormalizedPath("." ~ path ~ "/" ~ response["name"].str); + } + + string getItemId(const(char)[] path) + { + JSONValue response = get(itemByPathUrl ~ encodeComponent(path) ~ ":/?select=id"); + return response["id"].str; + } + + // https://dev.onedrive.com/items/view_changes.htm + JSONValue viewChangesById(const(char)[] id, const(char)[] statusToken) + { + char[] url = itemByIdUrl ~ id ~ "/view.changes"; + if (statusToken) url ~= "?token=" ~ statusToken; + return get(url); + } + + // https://dev.onedrive.com/items/view_changes.htm + JSONValue viewChangesByPath(const(char)[] path, const(char)[] statusToken) + { + char[] url = itemByPathUrl ~ encodeComponent(path).dup ~ ":/view.changes"; + url ~= "?select=id,name,eTag,cTag,deleted,file,folder,fileSystemInfo,parentReference"; + if (statusToken) url ~= "&token=" ~ statusToken; + return get(url); + } + + // https://dev.onedrive.com/items/download.htm + void downloadById(const(char)[] id, string saveToPath) + { + /*string downloadUrl; + // obtain the download url + http.url = itemByIdUrl ~ id ~ "/content"; + http.method = HTTP.Method.get; + http.maxRedirects = 0; + http.onReceive = (ubyte[] data) { return data.length; }; + http.onReceiveHeader = (in char[] key, in char[] value) { + if (sicmp(key, "location") == 0) { + http.onReceiveHeader = null; + downloadUrl = value.dup; + } + }; + writeln("Obtaining the url ..."); + http.perform(); + check(); + http.maxRedirects = 10; + if (downloadUrl) { + // try to download the file + try { + download(downloadUrl, saveToPath); + } catch (CurlException e) { + import std.file; + if (exists(saveToPath)) remove(saveToPath); + throw new OneDriveException("Download error", e); + } + } else { + throw new OneDriveException("Can't obtain the download url"); + }*/ + char[] url = itemByIdUrl ~ id ~ "/content"; + try { + download(url, saveToPath, http); + } catch (CurlException e) { + import std.file; + if (exists(saveToPath)) remove(saveToPath); + throw new OneDriveException("Download error", e); + } + } + + // https://dev.onedrive.com/items/upload_put.htm + auto simpleUpload(string localPath, const(char)[] remotePath, const(char)[] eTag = null) + { + char[] url = itemByPathUrl ~ remotePath ~ ":/content"; + ubyte[] content; + http.onReceive = (ubyte[] data) { + content ~= data; + return data.length; + }; + if (eTag) http.addRequestHeader("If-Match", eTag); + upload(localPath, url, http); + // remove the if-match header + if (eTag) setAccessToken(accessToken); + check(); + return parseJSON(content); + } + + private void redeemToken(const(char)[] authCode) + { + string postData = "client_id=" ~ clientId ~ "&redirect_url=" ~ redirectUrl ~ "&client_secret=" ~ clientSecret; + postData ~= "&code=" ~ authCode ~ "&grant_type=authorization_code"; + acquireToken(postData); + } + + private void newToken() + { + string postData = "client_id=" ~ clientId ~ "&redirect_url=" ~ redirectUrl ~ "&client_secret=" ~ clientSecret; + postData ~= "&refresh_token=" ~ refreshToken ~ "&grant_type=refresh_token"; + acquireToken(postData); + } + + private void acquireToken(const(char)[] postData) + { + JSONValue response = post(tokenUrl, postData); + setAccessToken(response["access_token"].str()); + refreshToken = response["refresh_token"].str().dup; + if (onRefreshToken) onRefreshToken(refreshToken); + } + + private void setAccessToken(string accessToken) + { + http.clearRequestHeaders(); + this.accessToken = accessToken; + http.addRequestHeader("Authorization", "bearer " ~ accessToken); + } + + private auto get(const(char)[] url) + { + return parseJSON(.get(url, http)); + } + + private auto post(T)(const(char)[] url, const(T)[] postData) + { + return parseJSON(.post(url, postData, http)); + } + + private void check() + { + if (http.statusLine.code / 100 != 2) { + throw new OneDriveException(format("HTTP request returned status code %d (%s)", http.statusLine.code, http.statusLine.reason)); + } + } +} diff --git a/src/sqlite.d b/src/sqlite.d new file mode 100644 index 00000000..b4a8e7d3 --- /dev/null +++ b/src/sqlite.d @@ -0,0 +1,181 @@ +module sqlite; + +import etc.c.sqlite3; +import std.string: fromStringz, toStringz; + +extern (C) immutable(char)* sqlite3_errstr(int); // missing from the std library + +private string ifromStringz(const(char)* cstr) +{ + return fromStringz(cstr).dup; +} + +class SqliteException: Exception +{ + @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) + { + super(msg, file, line, next); + } + + @safe pure nothrow this(string msg, Throwable next, string file = __FILE__, size_t line = __LINE__) + { + super(msg, file, line, next); + } +} + +struct Database +{ + private sqlite3* pDb; + + this(const(char)[] filename) + { + open(filename); + } + + ~this() + { + close(); + } + + void open(const(char)[] filename) + { + // https://www.sqlite.org/c3ref/open.html + int rc = sqlite3_open(toStringz(filename), &pDb); + if (rc != SQLITE_OK) { + close(); + throw new SqliteException(ifromStringz(sqlite3_errstr(rc))); + } + sqlite3_extended_result_codes(pDb, 1); // always use extended result codes + } + + void exec(const(char)[] sql) + { + // https://www.sqlite.org/c3ref/exec.html + int rc = sqlite3_exec(pDb, toStringz(sql), null, null, null); + if (rc != SQLITE_OK) { + throw new SqliteException(ifromStringz(sqlite3_errmsg(pDb))); + } + } + + Statement prepare(const(char)[] zSql) + { + Statement s; + // https://www.sqlite.org/c3ref/prepare.html + int rc = sqlite3_prepare_v2(pDb, zSql.ptr, cast(int) zSql.length, &s.pStmt, null); + if (rc != SQLITE_OK) { + throw new SqliteException(ifromStringz(sqlite3_errmsg(pDb))); + } + return s; + } + + void close() + { + // https://www.sqlite.org/c3ref/close.html + sqlite3_close_v2(pDb); + pDb = null; + } +} + +struct Statement +{ + struct Result + { + private sqlite3_stmt* pStmt; + private const(char)[][] row; + + private this(sqlite3_stmt* pStmt) + { + this.pStmt = pStmt; + step(); // initialize the range + } + + @property bool empty() + { + return row.length == 0; + } + + @property auto front() + { + return row; + } + + alias step popFront; + + void step() + { + // https://www.sqlite.org/c3ref/step.html + int rc = sqlite3_step(pStmt); + if (rc == SQLITE_DONE) { + row.length = 0; + } else if (rc == SQLITE_ROW) { + // https://www.sqlite.org/c3ref/data_count.html + int count = sqlite3_data_count(pStmt); + row = new const(char)[][count]; + foreach (int i, ref column; row) { + // https://www.sqlite.org/c3ref/column_blob.html + column = fromStringz(sqlite3_column_text(pStmt, i)); + } + } else { + throw new SqliteException(ifromStringz(sqlite3_errmsg(sqlite3_db_handle(pStmt)))); + } + } + } + + private sqlite3_stmt* pStmt; + + ~this() + { + // https://www.sqlite.org/c3ref/finalize.html + sqlite3_finalize(pStmt); + } + + void bind(int index, const(char)[] value) + { + reset(); + // https://www.sqlite.org/c3ref/bind_blob.html + int rc = sqlite3_bind_text(pStmt, index, value.ptr, cast(int) value.length, SQLITE_STATIC); + if (rc != SQLITE_OK) { + throw new SqliteException(ifromStringz(sqlite3_errmsg(sqlite3_db_handle(pStmt)))); + } + } + + Result exec() + { + reset(); + return Result(pStmt); + } + + private void reset() + { + // https://www.sqlite.org/c3ref/reset.html + int rc = sqlite3_reset(pStmt); + if (rc != SQLITE_OK) { + throw new SqliteException(ifromStringz(sqlite3_errmsg(sqlite3_db_handle(pStmt)))); + } + } +} + +unittest +{ + auto db = Database(":memory:"); + db.exec("CREATE TABLE test( + id TEXT PRIMARY KEY, + value TEXT + )"); + + auto s = db.prepare("INSERT INTO test VALUES (?, ?)"); + s.bind(1, "key1"); + s.bind(2, "value1"); + s.exec(); + s.bind(1, "key2"); + s.bind(2, "value2"); + s.exec(); + + s = db.prepare("SELECT * FROM test ORDER BY id ASC"); + auto r = s.exec(); + assert(r.front[0] == "key1"); + r.popFront(); + assert(r.front[1] == "value2"); + r.popFront(); + assert(r.empty); +} diff --git a/src/sync.d b/src/sync.d new file mode 100644 index 00000000..e2ef6a10 --- /dev/null +++ b/src/sync.d @@ -0,0 +1,277 @@ +import std.stdio, std.file, std.json; +import cache, config, onedrive, util; + +private string statusTokenFile = "status_token"; + +private bool isItemFolder(const ref JSONValue item) +{ + scope (failure) return false; + JSONValue folder = item["folder"]; + return true; +} + +private bool isItemFile(const ref JSONValue item) +{ + scope (failure) return false; + JSONValue folder = item["file"]; + return true; +} + +private bool isItemDeleted(const ref JSONValue item) +{ + scope (failure) return false; + return !item["deleted"].isNull(); +} + +class SyncException: Exception +{ + @nogc @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) + { + super(msg, file, line, next); + } + + @nogc @safe pure nothrow this(string msg, Throwable next, string file = __FILE__, size_t line = __LINE__) + { + super(msg, file, line, next); + } +} + +final class SyncEngine +{ + Config cfg; + OneDriveApi onedrive; + ItemCache itemCache; + string[] itemToDelete; // array of items to be deleted + + this(Config cfg, OneDriveApi onedrive) + { + assert(onedrive); + this.cfg = cfg; + this.onedrive = onedrive; + itemCache.init(); + } + + void applyDifferences() + { + string statusToken; + try { + statusToken = readText(statusTokenFile); + } catch (FileException e) { + writeln("Welcome !"); + } + writeln("Checking for changes..."); + + string currDir = getcwd(); + string syncDir = cfg.get("sync_dir"); + + JSONValue changes; + do { + chdir(syncDir); + changes = onedrive.viewChangesByPath("Politecnico", statusToken); + foreach (item; changes["value"].array) { + applyDifference(item); + } + statusToken = changes["@changes.token"].str; + chdir(currDir); + std.file.write(statusTokenFile, statusToken); + } while (changes["@changes.hasMoreChanges"].type == JSON_TYPE.TRUE); + chdir(syncDir); + deleteFiles(); + chdir(currDir); + } + + private void applyDifference(JSONValue item) + { + string id = item["id"].str; + string name = item["name"].str; + string eTag = item["eTag"].str; + + Item cachedItem; + bool cached = itemCache.selectById(id, cachedItem); + + // skip items already downloaded + //if (cached && cachedItem.eTag == eTag) return; + + writeln("Item ", id, " ", name); + + ItemType type; + if (isItemDeleted(item)) { + writeln("The item is marked for deletion"); + if (cached) { + applyDelete(cachedItem); + } + return; + } else if (isItemFile(item)) { + type = ItemType.file; + writeln("The item is a file"); + } else if (isItemFolder(item)) { + type = ItemType.dir; + writeln("The item is a directory"); + } else { + writeln("The item is neither a file nor a directory, skipping"); + //skippedFolders ~= id; + return; + } + + string cTag = item["cTag"].str; + string mtime = item["fileSystemInfo"].object["lastModifiedDateTime"].str; + string parentId = item["parentReference"].object["id"].str; + + string crc32; + if (type == ItemType.file) { + try { + crc32 = item["file"].object["hashes"].object["crc32Hash"].str; + } catch (JSONException e) { + writeln("The hash is not available"); + } + } + + Item newItem; + itemCache.insert(id, name, type, eTag, cTag, mtime, parentId, crc32); + itemCache.selectById(id, newItem); + + writeln("Path: ", newItem.path); + + try { + if (!cached) { + applyNewItem(newItem); + } else { + applyChangedItem(cachedItem, newItem); + } + } catch (SyncException e) { + itemCache.deleteById(id); + throw e; + } + } + + private void applyDelete(Item item) + { + if (exists(item.path)) { + if (isItemSynced(item, true)) { + addFileToDelete(item.path); + } else { + writeln("The local item is not synced, renaming ..."); + safeRename(item.path); + } + } else { + writeln("The local item is already deleted"); + } + } + private void applyNewItem(Item item) + { + assert(item.id); + if (exists(item.path)) { + if (isItemSynced(item, true)) { + writeln("The item is already present"); + // ensure the modified time is synced + setTimes(item.path, item.mtime, item.mtime); + return; + } else { + writeln("The item is not synced, renaming ..."); + safeRename(item.path); + } + } + final switch (item.type) { + case ItemType.file: + writeln("Downloading ..."); + try { + onedrive.downloadById(item.id, item.path); + } catch (OneDriveException e) { + throw new SyncException("Sync error", e); + } + break; + case ItemType.dir: + writeln("Creating local directory..."); + mkdir(item.path); + break; + } + setTimes(item.path, item.mtime, item.mtime); + } + + private void applyChangedItem(Item oldItem, Item newItem) + { + assert(oldItem.id == newItem.id); + if (exists(oldItem.path)) { + if (isItemSynced(oldItem)) { + if (oldItem.eTag != newItem.eTag) { + assert(oldItem.type == newItem.type); + if (oldItem.path != newItem.path) { + writeln("Moved item ", oldItem.path, " to ", newItem.path); + if (exists(newItem.path)) { + writeln("The destination is occupied, renaming ..."); + safeRename(newItem.path); + } + rename(oldItem.path, newItem.path); + } + if (oldItem.type == ItemType.file && oldItem.cTag != newItem.cTag) { + writeln("Downloading ..."); + onedrive.downloadById(oldItem.id, oldItem.path); + } + setTimes(newItem.path, newItem.mtime, newItem.mtime); + writeln("Updated last modified time"); + } else { + writeln("The item is not changed"); + } + } else { + writeln("The item is not synced, renaming ..."); + safeRename(oldItem.path); + applyNewItem(newItem); + } + } else { + applyNewItem(newItem); + } + } + + // returns true if the given item corresponds to the local one + private bool isItemSynced(Item item, bool checkHash = false) + { + final switch (item.type) { + case ItemType.file: + if (isFile(item.path)) { + SysTime localModifiedTime = timeLastModified(item.path); + import core.time: Duration; + item.mtime.fracSecs = Duration.zero; // HACK + if (localModifiedTime == item.mtime) return true; + else { + writeln("The local item has a different modified time ", localModifiedTime, " remote is ", item.mtime); + } + if (checkHash && item.crc32) { + string localCrc32 = computeCrc32(item.path); + if (localCrc32 == item.crc32) return true; + else { + writeln("The local item has a different hash"); + } + } + } else { + writeln("The local item is a directory but should be a file"); + } + break; + case ItemType.dir: + if (isDir(item.path)) return true; + else { + writeln("The local item is a file but should be a directory"); + } + break; + } + return false; + } + + private void addFileToDelete(string path) + { + itemToDelete ~= path; + } + + private void deleteFiles() + { + foreach_reverse (ref path; itemToDelete) { + if (isFile(path)) { + remove(path); + } else { + // TODO: test not empty folder + rmdir(path); + } + } + assumeSafeAppend(itemToDelete); + itemToDelete.length = 0; + } +} diff --git a/src/util.d b/src/util.d new file mode 100644 index 00000000..19bb2f24 --- /dev/null +++ b/src/util.d @@ -0,0 +1,44 @@ +import std.conv: to; +import std.digest.crc; +import std.digest.digest; +import std.stdio; +import std.string: chomp; +import std.file: exists, rename; +import std.path: extension; + +private string deviceName; + +static this() +{ + import std.socket; + deviceName = Socket.hostName; +} + +// give a new name to the specified file or directory +void safeRename(const(char)[] path) +{ + auto ext = extension(path); + auto newPath = path.chomp(ext) ~ "-" ~ deviceName; + if (exists(newPath ~ ext)) { + int n = 2; + char[] newPath2; + do { + newPath2 = newPath ~ "-" ~ n.to!string; + n++; + } while (exists(newPath2 ~ ext)); + newPath = newPath2; + } + newPath ~= ext; + rename(path, newPath); +} + +// return the crc32 hex string of a file +string computeCrc32(string path) +{ + CRC32 crc; + auto file = File(path, "rb"); + foreach (ubyte[] data; chunks(file, 4096)) { + crc.put(data); + } + return crc.finish().toHexString().dup; +}