Skip to content

Commit

Permalink
Issue14: Bulk import/export (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidlang42 authored Jan 27, 2024
1 parent b42d54d commit 3d3686f
Show file tree
Hide file tree
Showing 3 changed files with 321 additions and 0 deletions.
234 changes: 234 additions & 0 deletions app/ImportExport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// Module contains all the code for importing and exporting a full board of tasks from/to CSV

//client call
function exportToCsv(boardId) {
var table = [];
table.push(headerRow(false));
var tasks = Tasks.Tasks.list(boardId, { showDeleted: true, showHidden: true });
while (true) {
for (const task of tasks.getItems()) {
table.push(exportRow(task));
}
const token = tasks.getNextPageToken();
if (!token) {
break;
}
tasks = Tasks.Tasks.list(boardId, { showDeleted: true, showHidden: true, pageToken: token });
}
return toCSV(table);
}

//client call
function importFromCsv(boardId, csvFile) {
// load csv
var warnings = [];
if (!csvFile.length) throw Error("CSV file was empty");
var table = fromCSV(csvFile);
if (table.length < 2) throw Error("CSV file only contained a header row");
if (!table[0].length) throw Error("CSV file did not contain any columns");
// parse headers
var invalid = invalidHeaders(table[0]);
if (invalid.length == table[0].length) throw Error("CSV did not contain any matching column headers");
if (invalid.length) {
warnings.push("Invalid columns have been ignored (" + invalid.join(", ") + ")");
}
var map = headerMap(table[0]);
// parse tasks
var tasks = [];
for (var i=1; i < table.length; i++) {
if (table[i].length == map.length) {
var task = importRow(table[i], map);
task.status = task.completed ? "completed" : "needsAction";
tasks.push(task);
}
}
// add/update tasks
var count_updated = 0;
var count_added = 0;
for (const task of tasks) {
if (task.id) {
var result = Tasks.Tasks.patch(task, boardId, task.id);
count_updated += 1;
if (task.parent != result.parent)
Tasks.Tasks.move(boardId, task.id, {parent: task.parent});
} else {
Tasks.Tasks.insert(task, boardId, {parent: task.parent});
count_added += 1;
}
}
// send response
var response = "Successfully ";
if (count_added > 0) {
if (count_updated > 0) {
response += "added " + count_added + " and updated " + count_updated;
} else {
response += "added " + count_added
}
} else {
response += "updated " + count_updated
}
response += " tasks";
if (warnings.length) {
response += ", with " + warnings.length + " warnings:\n- " + warnings.join("\n- ");
} else {
response += ".";
}
return response;
}

const ROW_LENGTH = 11; // irrelevant fields not included: kind, etag, selfLink

function exportRow(task) {
var o = new Array(ROW_LENGTH);
var i=0;
o[i++]=task.parent;
o[i++]=task.position;
o[i++]=task.title;
o[i++]=task.id;
o[i++]=task.notes;
o[i++]=formatCsvDate(task.due);
o[i++]=task.updated;
o[i++]=task.completed;
o[i++]=task.deleted ?? false;
o[i++]=task.hidden ?? false;
var links = task.links;
if (links.length == 0) {
links = "";
} else {
links = JSON.stringify(links);
}
o[i++]=links;
if (i != ROW_LENGTH) {
throw new Error('Export row was the wrong length (' + i + ' != ' + ROW_LENGTH + ')');
}
return o;
}

function importRow(o, map) {
var task = {};
var i=0;
processMappedColumn(o, i++, map, task, 'parent');
processMappedColumn(o, i++, map, task, 'position');
processMappedColumn(o, i++, map, task, 'title');
processMappedColumn(o, i++, map, task, 'id');
processMappedColumn(o, i++, map, task, 'notes');
if (processMappedColumn(o, i++, map, task, 'due')) {
task.due = unformatCsvDate(task.due);
}
processMappedColumn(o, i++, map, task, 'updated');
processMappedColumn(o, i++, map, task, 'completed');
processMappedColumn(o, i++, map, task, 'deleted');
processMappedColumn(o, i++, map, task, 'hidden');
processMappedColumn(o, i++, map, task, 'links');
if (i != ROW_LENGTH) {
throw new Error('Import row was the wrong length (' + i + ' != ' + ROW_LENGTH + ')');
}
return task;
}

function headerRow(exclude_read_only) {
var o = new Array(ROW_LENGTH);
var i=0;
o[i++]='ParentId';
o[i++]=exclude_read_only ? "" : 'Position'; // could support setting this in the future, but it has to be set by calling move()
o[i++]='Title';
o[i++]='Id';
o[i++]='Notes';
o[i++]="DateDue";
o[i++]=exclude_read_only ? "" : "UpdatedTimestamp";
o[i++]="DateCompleted";
o[i++]="IsDeleted";
o[i++]=exclude_read_only ? "" : "IsHidden";
o[i++]=exclude_read_only ? "" : "Links";
if (i != ROW_LENGTH) {
throw new Error('Header row was the wrong length (' + i + ' != ' + ROW_LENGTH + ')');
}
return o;
}

function processMappedColumn(o, expected_index, map, task, field) {
var actual_index = map[expected_index];
if (actual_index != -1) {
var value = o[actual_index];
if (value.length) {
task[field] = value;
} else {
task[field] = null;
}
return true;
}
return false;
}

function headerMap(header) {
var map_from_expected_column_index_to_actual_column_index = [];
var i = 0;
for (const expected of headerRow(true)) {
map_from_expected_column_index_to_actual_column_index[i++] = expected ? header.indexOf(expected) : -1;
}
return map_from_expected_column_index_to_actual_column_index;
}

function invalidHeaders(header) {
var invalid = [];
var expected = headerRow();
for (const actual of header) {
if (!expected.includes(actual)) {
invalid.push(actual);
}
}
return invalid;
}

function formatCsvDate(task_date) {
if (!task_date) return null;
return formatDateForm(task_date);
}

function unformatCsvDate(csv) {
if (!csv) return null;
var timestamp = Date.parse(csv);
if (isNaN(timestamp)) {
throw Error("Invalid date: " + csv);
}
var d = new Date(timestamp);
return formatDateTasks(d);
}

// source: https://stackoverflow.com/questions/46637955/write-a-string-containing-commas-and-double-quotes-to-csv
function toCSV(table) {
return table
.map(row =>
row
.map(cell => {
if (cell == null) return "";
cell = cell.toString();
// We remove blanks and check if the column contains
// other whitespace,`,` or `"`.
// In that case, we need to quote the column.
if (cell.replace(/ /g, '').match(/[\s,"]/)) {
return '"' + cell.replace(/"/g, '""') + '"';
}
return cell;
})
.join(',')
)
.join('\n');
}

// source: https://stackoverflow.com/questions/8493195/how-can-i-parse-a-csv-string-with-javascript-which-contains-comma-in-data
function fromCSV(text) {
let p = '', row = [''], ret = [row], i = 0, r = 0, s = !0, l;
for (l of text) {
if ('"' === l) {
if (s && l === p) row[i] += l;
s = !s;
} else if (',' === l && s) l = row[++i] = '';
else if ('\n' === l && s) {
if ('\r' === p) row[i] = row[i].slice(0, -1);
row = ret[++r] = [l = '']; i = 0;
} else row[i] += l;
p = l;
}
return ret;
}
62 changes: 62 additions & 0 deletions app/JS_board.html
Original file line number Diff line number Diff line change
Expand Up @@ -864,11 +864,73 @@
google.script.run.withSuccessHandler(clearCompletedSuccess).withFailureHandler(clearCompletedFailure).removeDueDatesCompleted("<?= board.id ?>", taskIds);
}

// import/export dialog

function twoDigit(n) {
n = n.toString();
if (n.length == 1) {
return "0" + n;
} else {
return n;
}
}

function configureImportExportDialogEvents() {
$('#importExportDialog').on('show.bs.modal', function (event) {
var d = new Date();
document.getElementById("import_csv_file").value = null;
document.getElementById("export_file_name").value = `<?!= board.title.replace(/[^a-zA-Z0-9]/g, '') ?>_${d.getFullYear()}${twoDigit(d.getMonth() + 1)}${twoDigit(d.getDate())}_${twoDigit(d.getHours())}${twoDigit(d.getMinutes())}.csv`;
});
}

function importFromCsvAsync() {
var file = document.getElementById("import_csv_file").files[0];
if (file) {
var reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = function (evt) {
google.script.run.withSuccessHandler(importSuccess).withFailureHandler(showError).importFromCsv("<?= board.id ?>",evt.target.result);
$('#importExportDialog').modal('hide');
}
reader.onerror = function (evt) {
showError("Error reading file");
}
} else {
alert("Please select a CSV file to import");
}
}

function importSuccess(response) {
alert(response);
loadTasksAsync(); // refresh
}

function exportToCsvAsync() {
var export_file_name = document.getElementById("export_file_name").value;
if (!export_file_name) {
alert("Export file name cannot be blank.");
return;
}
google.script.run.withSuccessHandler(exportSuccess).withFailureHandler(showError).withUserObject(export_file_name).exportToCsv("<?= board.id ?>");
$('#importExportDialog').modal('hide');
}

function exportSuccess(csv_file_data, export_file_name) {
const csv_file = new File([csv_file_data], export_file_name);
const link = document.createElement("a");
link.href = URL.createObjectURL(csv_file);
link.download = csv_file.name;
link.style.display = "none";
document.body.appendChild(link);
link.click();
}

// on load

window.addEventListener("load", loadTasksAsync);
window.addEventListener("load", configureEditDialogEvents);
window.addEventListener("load", configureSettingsDialogEvents);
window.addEventListener("load", configureImportExportDialogEvents);
window.addEventListener("load", configureBoardEvents);
window.addEventListener("load", focusFilter);
</script>
25 changes: 25 additions & 0 deletions app/UI_board.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ <h3 class="font-weight-light text-black" style="display: inline-block;" data-tog
<input class="form-control mr-sm-2" type=textbox id="filter_text" placeholder="Filter text" onKeyUp='updateFilter();'>
<button class="btn btn-primary btn-sm my-2 my-sm-0" id="button_details" onClick="toggleDetails(this);">Hide details</button>&nbsp;
<button class="btn btn-primary btn-sm my-2 my-sm-0" onClick="loadTasksAsync();">🔄</button>&nbsp;
<button class="btn btn-primary btn-sm my-2 my-sm-0" data-toggle="modal" data-target="#importExportDialog">📁</button>&nbsp;
<button class="btn btn-primary btn-sm my-2 my-sm-0" data-toggle="modal" data-target="#settingsDialog">⚙️</button>
</div>
</div>
Expand Down Expand Up @@ -149,5 +150,29 @@ <h6>Links:</h6>
</div>
</div>
</div>
<!-- Import/export dialog -->
<div class="modal fade" id="importExportDialog" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import/export tasks</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><input type=file id="import_csv_file" style="width:75%" accept="text/csv">
<button type="button" class="btn btn-primary float-right" id="import_from_csv" onclick="importFromCsvAsync();">Import CSV</button></p>
</div>
<div class="modal-body">
<p><input type="text" id="export_file_name" style="width:75%">
<button type="button" class="btn btn-success float-right" id="export_to_csv" onclick="exportToCsvAsync();">Export CSV</button></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
</body>
</html>

0 comments on commit 3d3686f

Please sign in to comment.