For Dropbox handling I chose a pretty small library, node-dropbox
. To use it, I went in Dropbox's developer dashboard and created an access token (instead of using secrets and keys) and saved that in my .env
. Then onto the helper:
const dropbox = require('node-dropbox').api(process.env.ACCESS_TOKEN)
function Dropbox() {}
Dropbox.getFolder = function(path) {
return new Promise(function(resolve) {
// This will get all the metadata for each file from the folder.
dropbox.getMetadata(path, function(error, result, folder) {
if (error) {
console.log('Getting ' + path + ' failed.')
}
else {
resolve(folder)
}
})
})
}
Dropbox.getFile = function(path) {
return new Promise(function(resolve) {
// This will return the contents of a file.
dropbox.getFile(path, function(error, result, file) {
if (error) {
console.log('Getting ' + path + ' failed.')
}
else {
resolve(file)
}
})
})
}
Lastly, we will need three new db
methods that we'll use when syncing:
Db.updatePost = function(post) {
const query = 'UPDATE posts SET' +
' ' + fields() +
' = ' + values(post) + ' WHERE ' +
' link = \'' + post.link + '\' AND ' +
' datetime = \'' + post.datetime + '\''
// Connect and run the query
})
Db.insertPost = function(post) {
const query = 'INSERT INTO posts ' +
fields() +
' VALUES ' + values(post)
// Connect and run the query
})
Db.delete = function(post) {
const query = 'DELETE FROM posts WHERE' +
' link = \'' + post.link + '\' AND' +
' datetime = \'' + post.datetime + '\''
// Connect and run the query
})
The fields and values methods are just helpers for building the query, one for returning the fields, one for creating the values:
function fields() {
return '(title, body, datetime, modified, link, readingtime)'
}
function values(post) {
// Escape all ' in the title and body by adding another one
const title = post.title.replace(new RegExp('\'', 'g'), '\'\'')
const body = post.body.replace(new RegExp('\'', 'g'), '\'\'')
return '(\'' + title + '\', ' +
'\'' + body + '\', ' +
'\'' + post.datetime + '\', ' +
'\'' + post.modified + '\', ' +
'\'' + post.link + '\', ' +
'\'' + post.readingTime + '\')'
// This will return a string similar to fields:
('A title', 'A body', '2016-07-22-2148', 'a-title', '2 min')
}
And these two come together in the sync
route:
// Force means update even if the date of the file is the same as the database post's
// shouldDelete means files have been deleted and the respective posts need to be deleted
// This is because for deleting there's an extra couple of iterations over all posts, and takes a bit longer
router.get('/' + process.env.MY_SYNC_KEY + '/:key1?/:key2?', function(req, res) {
const shouldDelete = req.params.key1 == 'delete' || req.params.key2 == 'delete'
const forced = req.params.key1 == 'force' || req.params.key2 == 'force'
This will wait for both promises to finish, then return them in an array:
Promise.all([Db.fetchPostsForUpdating(), Dropbox.getFolder('/posts')]).then(function(data) {
let posts = data[0].posts
const folder = data[1]
Since Dropbox.getFile
is async, and wrapped in a promise, we need to wait for all of them to finish. Luckily, bluebird
(Promise) has a nice little helper, map
, which will do just that; we just have to return a Promise
:
Promise.map(folder.contents, function(item) {
return Promise.join(item, Dropbox.getFile(item.path), function(item, file) {
return { item: item, file: file }
})
This will return an array of promises containing the item and the file contents, so we map
once more, to eventually create an array of Posts
:
}).map(function(dropboxData) {
const item = dropboxData.item
const file = dropboxData.file
First, we create a safe-ish link
out of the file name:
const matches = item.path.match(/\/(posts)\/(\d{4})-(\d{2})-(\d{2})-(\d{4})-([\w\s\.\/\}\{\[\]_#&@$:"';,!=\?\+\*\-\)\(]+)\.md$/)
const datetime = matches[2] + '-' + matches[3] + '-' + matches[4] + '-' + matches[5]
let link = matches[6].replace(/([#,;!:"\'\?\[\]\{\}\(\$\/)]+)/g, '')
.replace(/&/g, 'and')
.replace(/\s|\./g, '-')
.toLowerCase()
Then we create the title
from the first line, delete the second line and create the body
out of the rest by transforming the markdown syntax in html with marked
, save the last modified time in modified
and return the Post
object:
let lines = file.toString().split('\n')
const title = lines[0]
lines.splice(0, 2)
const body = lines.join('\n')
const modified = item.client_mtime
return new Post(title, marked(body), Post.readingTime(body),
datetime, modified, link)
We now have an array of Post
s that represent the data in Dropbox (newPosts
), and an array of Post
s that represent the data in the database (posts
):
}).then(function(newPosts) {
if (shouldDelete) {
// Iterate through existing posts, and if no corresponding
// file is found, delete the post, and remove it from the data.
data[0].posts.forEach(function(post, index) {
let matchingNewPosts = newPosts.filter(function(newPost) {
return Post.linksMatch(newPost, post) &&
newPost.datetime == post.datetime
})
if (matchingNewPosts.length) { return }
// We don't need to wait for this to finish
Db.deletePost(post)
// After the iteration is finished, posts will represent the data in the database
// with the extra posts removed.
posts.splice(index, 1)
})
}
Quick explanation: if a Dropbox post has the same link
and the same datetime
as a database post , it means it's the same entity, since these fields are created out of filenames, which are unique; if they have the same link
, but different datetime
s, then they are different entities.
We will now iterate through the Dropbox posts, find the ones with matching links, and if none is found, we create it, but since we want to wait for all posts to be created, we will use bluebird
's map
again, and return an object that contains the matching posts (or empty array), and the newPost
:
Promise.map(newPosts, function(newPost) {
// Just the one(s) with the same link
let matchingPosts = posts.filter(function(p) {
return Post.linksMatch(newPost, p)
})
let returnObject = {
newPost: newPost,
matchingPosts: matchingPosts
}
// Create
if (matchingPosts.length == 0) {
Db.createPost(newPost)
}
return returnObject
Quick reminder that when map
finishes, we will have an array of objects:
}).each(function(data) {
const newPost = data.newPost
const matchingPosts = data.matchingPosts
Inside map
we created new Post
s if no matches were found. Here, if we have one or more matches, we will update the ones with matching datetime
:
matchingPosts.forEach(function(matchingPost) {
// Update
if (newPost.datetime == matchingPost.datetime) {
// Only if these differ, no reason to query the db for nothing
if (newPost.modified != matchingPost.modified || forced) {
Db.updatePost(newPost)
}
return
}
If any of the matches have non-matching datetime
s, that means it's just another post, created by mistake with the same name, so we will append --1
to it, or if that exists --2
, and so on:
let variant
// Create a new one, with same link, but duplicated.
// If it has --1 already, make it --2, and so on.
if (matchingPost.link.slice(-3, -1) == '--') {
variant = parseInt(matchingPost.link.slice(-1)[0])
}
else if (matchingPost.link.slice(-4, -2) == '--') {
variant = parseInt(matchingPost.link.slice(-2))
}
else {
variant = 0
}
variant += 1
newPost.link += '--' + variant
Db.createPost(newPost)
})
When everything is finished, we just redirect back to the homepage:
}).then(function() {
res.redirect('/')
})
})
}).catch(function(error) {
console.log(error)
})
})
Hopefully I managed to split and explain the code properly. If I didn't, don't hesitate to drop by and ask questions, or give any kind of feedback @rolandleth.