const util = require('util'); const DirectusSDK = require('@directus/sdk-js'); const yaml = require('js-yaml'); const urlslug = require('url-slug'); const fs = require('fs'); const mime = require('mime-types'); const express = require('express') const bodyParser = require('body-parser'); const { exec } = require("child_process"); const uuidregex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; class Driver { constructor(config = {}, directusConfig = {}){ this.url = config.url || 'http://localhost:8055'; this.email = config.email || ''; this.password = config.password || ''; this.frontMatter = (config.frontMatter && config.frontMatter.toUpperCase()) || 'YAML'; // TODO: Add TOML and JSON frontmatter support this.collection = config.collection this.collections = config.collections this.content = {} this.content.path = (config.content && config.content.path) ? config.content.path : 'content'; this.content.home = (config.content && config.content.home) ? config.content.home.toLowerCase() : 'home'; this.content.index = (config.content && config.content.index) ? config.content.index.toLowerCase() : 'index'; this.content.map = (config.content && config.content.map) ? config.content.map : []; this.directus = new DirectusSDK(this.url, directusConfig); this.pathMethod = config.pathBuilder || this._pathBuilder; this._auth = null; this.buildDrafts = config.buildDrafts || false; // Auto-rebuild server related this.buildPort = config.buildPort || 8060; this.buildHost = config.buildHost || 'http://localhost'; this.autoWebhook = config.autoWebhook || true; } _checkAuth(){ return new Promise(resolve => { if (this.email && this.password && this._auth === null){ this._auth = this.directus.auth.login({ email: this.email, password: this.password }) .then(() => { console.info('Login with user', this.email); resolve() }) } else { resolve() } }) } static emptyPromise(){ return new Promise(resolve => resolve()) } getCollections(){ return this.directus.collections.read() // Let not following .then() confuse you. It is an arrow function running an arrow filter. // The Directus internal system fields have "meta.system == true" and those we discard here. // .then(data => { // console.log(util.inspect(data, false, null, true /* enable colors */)) // return data // }) .then(data => data.data.filter(collection => !collection.meta.system)) } /** * Build path string where the articles will be stored exlcluding individual article slug * @param {*} article * @param {*} collection * @returns {string} */ _pathBuilder(article, collection){ console.log('article', article); // console.log('collection', collection); if (this.collections && this.collections[collection.collection] && this.collections[collection.collection].pathBuilder) { return this.collections[collection.collection].pathBuilder(this.content.path, article, urlslug) } if (this._isHome(article, collection)) { return `${this.content.path}`; } if (this._isBranch(article, collection) || this._isPage(article, collection)){ return `${this.content.path}/${collection.collection}`; } const [year, mouth, day] = article.date.split('-') return `${this.content.path}/actualites/${year}/${mouth}/${article.date.replaceAll('-', '_')}-${urlslug(article.title, { remove: /\./g })}`; } _isHome(article, collection){ return (collection.meta.singleton === true && collection.collection === this.content.home) } _isBranch(article, collection){ return (collection.meta.singleton === false && article.title.toLowerCase() === this.content.index) } // eslint-disable-next-line class-methods-use-this _isPage(article, collection){ return (collection.meta.singleton === true) } _formatFrontMatter(article, collection = {}){ let front = { ...article }; // Manipulate some variable property names if (front.date_created){ if (!front.date) { front.date = front.date_created } delete front.date_created } if (front.date_updated){ front.lastmod = front.date_updated delete front.date_updated } delete front.body // Finally, transform from object to string if (this.frontMatter === 'YAML'){ front = `---\r\n${yaml.safeDump(front).trim()}\r\n---\r\n` } return front } static _writeFileStream(path, readstream, passtrough = {}){ return new Promise((resolve, reject) => { const fileWriter = fs.createWriteStream(path); fileWriter.on('finish', () => { resolve(passtrough) }); fileWriter.on('error', error => { console.log(error) fileWriter.close(); reject(error); }); readstream.pipe(fileWriter); }); } /** * * @param {object} article * @param {object} collection * @returns {promise} */ _importItem(originArticle, collection){ const article = { ...originArticle } const itemPath = this.pathMethod(article, collection); const indexName = this._isHome(article, collection) || this._isBranch(article, collection) || this._isPage(article, collection) ? '_index' : 'index' // Only continue if this is a published article or we have explicitly set to import Drafts // Archived items would be always discarded if ((article.status === 'archived') || (article.draft === 'true' && this.buildDrafts === false)){ return Driver.emptyPromise(); } // if ((article.status !== 'published') && (article.status === 'draft' && this.buildDrafts === false)){ // return Driver.emptyPromise(); // } if (!fs.existsSync(itemPath)){ fs.mkdirSync(itemPath, { recursive: true }); } const writePromises = [] for (const [key, value] of Object.entries(article)){ // TODO: instead of trying to pull asset and hope for the best, // crosscheck things with the /fields and play by the rules if (typeof value === 'string' && value.match(uuidregex)) { // console.log([key, value]); const downloadPromise = this.directus.axios.get(`assets/${value}?download`, { responseType: 'stream' }) .then(response => { const disposition = response.headers['content-disposition'].match(/filename="(.*)"/); const downloadName = disposition ? disposition[1] : `${value}.${mime.extension(response.headers['content-type'])}`; const savePath = `${itemPath}/${downloadName}`; return Driver._writeFileStream(savePath, response.data, { key, downloadName }) }) .then(passtrough => { article[passtrough.key] = passtrough.downloadName }) .catch(error => { if (error.response && error.response.status && error.response.status !== 403) { console.error(error.response.status, error.response.statusText) } }); writePromises.push(downloadPromise); } } return Promise.all(writePromises).then(() => { const frontMatter = this._formatFrontMatter(article, collection); const itemContent = `${frontMatter}${article.body ? article.body.toString() : ''}` fs.writeFileSync(`${itemPath}/${indexName}.md`, itemContent) }) } _importCollection(collection){ const col = this.directus.items(collection.collection).read() // TODO: IMPORTANT handle pagination when a lot of content in CMS return col .then(data => { // normalize to always have an array of content. const content = (collection.meta.singleton === true) ? [data.data] : data.data; return content.map(article => this._importItem(article, collection)) }) } import() { console.info('Let\'s import from', this.url); this._checkAuth() .then(() => this.getCollections()) .then(collections => { if (this.collections) { return collections.filter(collection => Object.keys(this.collections).includes(collection.collection)) } return collections }) .then(collections => { return collections.map(collection => this._importCollection(collection)) }) .then(collectionPromises => { return Promise.all(collectionPromises) }) .catch(error => { console.log(error) }) } } module.exports = Driver