Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync data with Google Drive #930

Open
wants to merge 31 commits into
base: sync
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b237802
Add menu buttons to sync with google drive
Dec 8, 2022
4505718
Add google-api to dependencies
Dec 8, 2022
75c8cbd
use example code to connect to google drive api
Dec 8, 2022
cf52e41
test code to upload data to drive
Dec 8, 2022
a018ac0
export db as JSON to google drive
Dec 9, 2022
09d84da
download json file from google drive
Dec 9, 2022
844b8e3
first draft of importing data from google drive
Dec 12, 2022
1b0bf2d
Refactor export, add confirmation & success window
Dec 12, 2022
3b5e324
confirmation window for db import
Dec 12, 2022
49ca41e
fix formating
Jan 3, 2023
8277abb
Merge branch 'thamara:main' into sync-data-with-google-drive
SarahRemus Jan 3, 2023
84388a8
reload calendar after database import
Jan 3, 2023
ea53ce6
err handling for data export
Jan 4, 2023
a4ec25f
err handling for data import
Jan 4, 2023
9ec0781
simplify export function
Jan 5, 2023
2409a89
cleanup and formatting
Jan 5, 2023
a70d0ab
Added test for import from buffer
Jan 5, 2023
f283351
rename function
Jan 6, 2023
9edb738
Added test for get database as json
Jan 6, 2023
84b8624
refactor code structure
Jan 7, 2023
848f341
refactor code structure
Jan 7, 2023
373e66b
refactor code structure
Jan 7, 2023
e8e6d23
Add tests for import from drive
Jan 7, 2023
80c33d8
Fix format
Jan 8, 2023
f1b53eb
update package lock
Jan 8, 2023
da1ba39
fix requested formatting
Jan 21, 2023
7d3ea75
fix requested formatting
Jan 21, 2023
413d538
log errors to console
Jan 22, 2023
efea2b4
various small requested changes
Jan 22, 2023
2191298
remove regular entry from import fun & test case
Jan 22, 2023
5bc08ea
revert unrelated changes
Jan 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion __tests__/__main__/import-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
const {
exportDatabaseToFile,
importDatabaseFromFile,
importDatabaseFromBuffer,
migrateFixedDbToFlexible,
validEntry
validEntry,
getDatabaseAsJSON
} = require('../../js/import-export');

const fs = require('fs');
Expand Down Expand Up @@ -156,6 +158,41 @@ describe('Import export', function()
});
});

const regularEntriesJSON =
`[{"type": "flexible","date": "2020-4-1","values": ["08:00","12:00","13:00","17:00"]},
{"type": "flexible","date": "2020-4-2","values": ["07:00","11:00","14:00","18:00"]},
{"type": "waived","date": "2019-12-31","data": "New Year's eve","hours": "08:00"},
{"type": "waived","date": "2020-01-01","data": "New Year's Day","hours": "08:00"},
{"type": "waived","date": "2020-04-10","data": "Good Friday","hours": "08:00"}]`;

const notValidJSON =
'[{"type": "flexible","date": "2022-11-6","values": "08:44","08:45"]}]';

describe('importDatabaseFromBuffer', function()
{
test('Check that import fom buffer works', () =>
{
expect(importDatabaseFromBuffer(regularEntriesJSON)['result']).toBeTruthy();
expect(importDatabaseFromBuffer(notValidJSON)['result']).not.toBeTruthy();
expect(importDatabaseFromBuffer(notValidJSON)['failed']).toBe(0);
expect(importDatabaseFromBuffer(invalidEntriesContent)['result']).not.toBeTruthy();
expect(importDatabaseFromBuffer(invalidEntriesContent)['failed']).toBe(5);
});
});

store.clear();
store.set(regularEntries);

describe('getDatabaseAsJSON', function()
{
const databaseJSONObject = JSON.parse(getDatabaseAsJSON());
const regularEntriesJSONObject = JSON.parse(regularEntriesJSON);
test('Check that returning database as JSON works', () =>
{
expect(databaseJSONObject).toMatchObject(regularEntriesJSONObject);
});
});

afterAll(() =>
{
fs.rmdirSync(folder, {recursive: true});
Expand Down
51 changes: 51 additions & 0 deletions __tests__/__main__/invalid-entries-import-drive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';
const Store = require('electron-store');
const { importDatabaseFromGoogleDrive } = require('../../js/import-export-online');

// first five entries are invalid, second five are valid
const invalidEntriesContent =
`[{"type": "flexible", "date": "not-a-date", "data": "day-begin", "hours": "08:00"},
{"type": "waived", "date": "2020-01-01", "data": "example waiver 2", "hours": "not-an-hour"},
{"type": "flexible", "date": "not-a-date", "data": "day-end", "hours": "17:00"},
{"type": "flexible", "date": "not-a-date", "values": "not-an-array"},
{"type": "not-a-type", "date": "not-a-date", "data": "day-end", "hours": "17:00"},
{"type": "flexible","date": "2020-4-1","values": ["08:00","12:00","13:00","17:00"]},
{"type": "flexible","date": "2020-4-2","values": ["07:00","11:00","14:00","18:00"]},
{"type": "waived","date": "2019-12-31","data": "New Year's eve","hours": "08:00"},
{"type": "waived","date": "2020-01-01","data": "New Year's Day","hours": "08:00"},
{"type": "waived","date": "2020-04-10","data": "Good Friday","hours": "08:00"}
]`;

jest.mock('../../js/google-drive', () => ({
authorize: jest.fn(),
searchFile: jest.fn(),
downloadFile: jest.fn().mockResolvedValue(invalidEntriesContent),
}));

describe('Faulty import from google drive', function()
{
process.env.NODE_ENV = 'test';
const store = new Store();
const flexibleStore = new Store({ name: 'flexible-store' });
const waivedWorkdays = new Store({ name: 'waived-workdays' });
store.clear();
flexibleStore.clear();
waivedWorkdays.clear();

test('Check that invalid json is not imported', async() =>
{
expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(0);
expect(waivedWorkdays.size).toBe(0);

const data = await importDatabaseFromGoogleDrive();

expect(data['result']).not.toBeTruthy();
expect(data['failed']).toBe(5);
expect(data['total']).toBe(10);

expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(2);
expect(waivedWorkdays.size).toBe(3);
});
});
41 changes: 41 additions & 0 deletions __tests__/__main__/invalid-json-import-drive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable no-undef */
'use strict';
const Store = require('electron-store');
const { importDatabaseFromGoogleDrive } = require('../../js/import-export-online');

const notValidJSON =
'[{"type": "flexible","date": "2022-11-6","values": "08:44","08:45"]}]';

jest.mock('../../js/google-drive', () => ({
authorize: jest.fn(),
searchFile: jest.fn(),
downloadFile: jest.fn().mockResolvedValue(notValidJSON),
}));

describe('Invalid import from google drive', function()
{
process.env.NODE_ENV = 'test';
const store = new Store();
const flexibleStore = new Store({ name: 'flexible-store' });
const waivedWorkdays = new Store({ name: 'waived-workdays' });
store.clear();
flexibleStore.clear();
waivedWorkdays.clear();

test('Check that invalid json is not imported', async() =>
{
expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(0);
expect(waivedWorkdays.size).toBe(0);

const data = await importDatabaseFromGoogleDrive();

expect(data['result']).not.toBeTruthy();
expect(data['failed']).toBe(0);
expect(data['total']).toBe(0);

expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(0);
expect(waivedWorkdays.size).toBe(0);
});
});
45 changes: 45 additions & 0 deletions __tests__/__main__/regular-import-drive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* eslint-disable no-undef */
'use strict';

const { importDatabaseFromGoogleDrive } = require('../../js/import-export-online');
const Store = require('electron-store');

const regularEntriesJSON =
`[{"type": "flexible","date": "2020-4-1","values": ["08:00","12:00","13:00","17:00"]},
{"type": "flexible","date": "2020-4-2","values": ["07:00","11:00","14:00","18:00"]},
{"type": "waived","date": "2019-12-31","data": "New Year's eve","hours": "08:00"},
{"type": "waived","date": "2020-01-01","data": "New Year's Day","hours": "08:00"},
{"type": "waived","date": "2020-04-10","data": "Good Friday","hours": "08:00"}]`;

jest.mock('../../js/google-drive', () => ({
authorize: jest.fn(),
searchFile: jest.fn(),
downloadFile: jest.fn().mockResolvedValue(regularEntriesJSON),
}));

describe('Regular import from google drive', function()
{
process.env.NODE_ENV = 'test';

const store = new Store();
const flexibleStore = new Store({ name: 'flexible-store' });
const waivedWorkdays = new Store({ name: 'waived-workdays' });
store.clear();
flexibleStore.clear();
waivedWorkdays.clear();

test('Check valid json is imported', async() =>
{
expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(0);
expect(waivedWorkdays.size).toBe(0);

const data = await importDatabaseFromGoogleDrive();

expect(data['result']).toBeTruthy();

expect(store.size).toBe(0);
expect(flexibleStore.size).toBe(2);
expect(waivedWorkdays.size).toBe(3);
});
});
165 changes: 165 additions & 0 deletions js/google-drive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
const fs = require('fs').promises;
const path = require('path');
const process = require('process');
const { authenticate } = require('@google-cloud/local-auth');
const { google } = require('googleapis');
const { getDatabaseAsJSON } = require('./import-export.js');

const SCOPES = ['https://www.googleapis.com/auth/drive'];
araujoarthur0 marked this conversation as resolved.
Show resolved Hide resolved
const TOKEN_PATH = path.join(process.cwd(), 'token.json');
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
const VERSION = 'v3';

/**
* Reads previously authorized credentials from the save file.
*
* @return {Promise<OAuth2Client|null>}
*/
async function maybeLoadSavedCredentials()
{
try
{
const content = await fs.readFile(TOKEN_PATH);
const credentials = JSON.parse(content);
return google.auth.fromJSON(credentials);
}
catch (err)
{
return null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you log the error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To the console?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Then when it happens to a user, we can usually ask them to open dev tools to report the bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I won't log this specific error, as it's supposed to happen. The function will return null if there is no token file present, and then the user has to authenticate himself. No need to log that. But I added a log for the other errors!

}
}

/**
* Serializes credentials to a file compatible with GoogleAuth.fromJSON.
*
* @param {OAuth2Client} client
* @return {Promise<void>}
*/
async function saveCredentials(client)
{
const content = await fs.readFile(CREDENTIALS_PATH);
const keys = JSON.parse(content);
const key = keys.installed || keys.web;
const payload = JSON.stringify({
type: 'authorized_user',
client_id: key.client_id,
client_secret: key.client_secret,
refresh_token: client.credentials.refresh_token,
});
await fs.writeFile(TOKEN_PATH, payload);
}

/**
* Load or request authorization to call APIs.
*/
async function authorize()
{
let client = await maybeLoadSavedCredentials();
if (client)
{
return client;
}
client = await authenticate({
scopes: SCOPES,
keyfilePath: CREDENTIALS_PATH,
});
if (client.credentials)
{
await saveCredentials(client);
}
return client;
}

/**
* Upload database content as JSON to Google Drive.
* @param {OAuth2Client} authClient An authorized OAuth2 client.
* @param {String} path Path and name of the uploaded file.
*/
async function uploadData(authClient, path)
{
const service = google.drive({ version: VERSION, auth: authClient });
const jsonData = getDatabaseAsJSON();
const fileMetadata = {
name: path,
mimeType: 'application/json',
};
const media = {
mimeType: 'application/json',
body: jsonData,
};
try
{
await service.files.create({
resource: fileMetadata,
media: media,
});
}
catch (err)
{
araujoarthur0 marked this conversation as resolved.
Show resolved Hide resolved
console.log(err);
throw new Error('Failed to upload data.');
}
}

/**
* Search file in drive location by filename.
* @param {OAuth2Client} authClient An authorized OAuth2 client.
* @param {String} fileName Name of the searched file.
*
* @return {String} fileId
* */
async function searchFile(authClient, fileName)
{
const service = google.drive({ version: VERSION, auth: authClient });
const files = [];
try
{
const res = await service.files.list({
q: `name='${fileName}'` ,
fields: 'nextPageToken, files(id, name)',
spaces: 'drive',
});
// use first file that matches the given file name
Array.prototype.push.apply(files, res.files);
const fileId = res.data.files[0].id;
return fileId;
}
catch (err)
{
console.log(err);
throw new Error(`Failed to find file ${fileName} in Drive.`);
}
}

/**
* Download TTL-data file from Google Drive.
* @param {OAuth2Client} authClient An authorized OAuth2 client.
* @param {String} fileId of the file that should be downloaded.
*/
async function downloadFile(authClient, fileId)
{
const service = google.drive({ version: VERSION, auth: authClient });
try
{
const file = await service.files.get({
fileId: fileId,
alt: 'media',
});
const jsonData = JSON.stringify(file.data, null, '\t');
return jsonData;
}
catch (err)
{
araujoarthur0 marked this conversation as resolved.
Show resolved Hide resolved
console.log(err);
throw new Error('Failed to download file.');
}
}

araujoarthur0 marked this conversation as resolved.
Show resolved Hide resolved
module.exports = {
maybeLoadSavedCredentials,
saveCredentials,
authorize,
searchFile,
downloadFile,
uploadData
};
33 changes: 33 additions & 0 deletions js/import-export-online.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const { authorize, searchFile, downloadFile, uploadData } = require('./google-drive.js');
const { importDatabaseFromBuffer } = require('./import-export.js');

/**
* Authorize and upload database content as JSON to Google Drive.
* @param {String} path Path and name of the uploaded file.
*/
async function exportDatabaseToGoogleDrive(path)
{
const client = await authorize();
await uploadData(client, path);
}

/**
* Import the database from Google Drive by downloading a JSON file that contains TTL data.
*
* @return {Promise<object>} result of the import in form {'result': false, 'total': 0, 'failed': 0} if failed
* and {'result': true} if import succeeded.
*/
async function importDatabaseFromGoogleDrive()
{
// TODO: file name hardcoded at the moment, add user input
const client = await authorize();
const fileId = await searchFile(client, 'time_to_leave_export');
const jsonData = await downloadFile(client, fileId);
const importResult = importDatabaseFromBuffer(jsonData);
return importResult;
}

module.exports = {
importDatabaseFromGoogleDrive,
exportDatabaseToGoogleDrive
};
Loading