mirror of
https://github.com/abraunegg/onedrive
synced 2024-05-15 20:26:53 +02:00
first commit
This commit is contained in:
commit
00d53f648e
14
Makefile
Normal file
14
Makefile
Normal file
|
@ -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)
|
14
Makefile.win
Normal file
14
Makefile.win
Normal file
|
@ -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)
|
BIN
lib/sqlite3.lib
Normal file
BIN
lib/sqlite3.lib
Normal file
Binary file not shown.
3
onedrive.conf
Normal file
3
onedrive.conf
Normal file
|
@ -0,0 +1,3 @@
|
|||
client_id = "000000004C15842F"
|
||||
client_secret = "5vWj5xi6rYZM61X81Z9OyXAmjGhVS6Py"
|
||||
sync_dir = "~/OneDrive/"
|
163
src/cache.d
Normal file
163
src/cache.d
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
64
src/config.d
Normal file
64
src/config.d
Normal file
|
@ -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");
|
||||
}
|
30
src/main.d
Normal file
30
src/main.d
Normal file
|
@ -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);
|
||||
}*/
|
||||
}
|
219
src/onedrive.d
Normal file
219
src/onedrive.d
Normal file
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
181
src/sqlite.d
Normal file
181
src/sqlite.d
Normal file
|
@ -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);
|
||||
}
|
277
src/sync.d
Normal file
277
src/sync.d
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
44
src/util.d
Normal file
44
src/util.d
Normal file
|
@ -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;
|
||||
}
|
Loading…
Reference in a new issue