2019-10-09 06:47:37 -04:00
const r2 = require ( 'r2' )
2019-12-10 07:45:09 -05:00
const path = require ( 'path' )
const fs = require ( 'fs' )
2020-05-04 09:32:55 -04:00
const { once , EventEmitter } = require ( 'events' )
const { inherits } = require ( 'util' )
2019-10-11 06:55:52 -04:00
const getPost = require ( 'mediumexporter' ) . getPost
2019-12-19 07:20:24 -05:00
const { createClient } = require ( 'webdav' )
2019-12-24 04:20:48 -05:00
const readline = require ( 'readline' )
2019-12-24 12:08:38 -05:00
const { markdown } = require ( 'markdown' )
2019-12-24 11:52:58 -05:00
const GhostAdminAPI = require ( '@tryghost/admin-api' )
2020-01-01 11:01:13 -05:00
const Rembrandt = require ( 'rembrandt' )
2019-10-09 06:47:37 -04:00
2020-04-24 11:50:41 -04:00
const config = require ( './config' )
2020-05-04 08:25:41 -04:00
class Seance {
constructor ( ... args ) {
this . MEDIUM _IMG _CDN = 'https://miro.medium.com/fit/c/'
try {
this . ghostAdmin = new GhostAdminAPI ( {
url : config . ghost . url ,
version : config . ghost . version ,
key : config . ghost . admin _key ,
} )
} catch ( err ) {
console . error ( 'Your Ghost isn\'t configured. Please run `seance setup` to fix this!' )
}
/ * *
* function [ fetchFromMedium ]
* @ returns [ string ] status
* /
}
2020-05-04 08:36:01 -04:00
async fetchFromMedium ( mediumUrl , options = {
json : null ,
} ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'starting' ,
message : ` Fetching: ${ mediumUrl } ` ,
loglevel : 'info'
} )
console . info ( ) ;
2020-05-04 08:25:41 -04:00
var output = path . join ( process . env . PWD , 'content' )
var json
2020-05-04 08:36:01 -04:00
if ( ! options . json ) {
json = await this . fetchMediumJSON ( mediumUrl )
} else {
json = options . json
}
2020-05-04 08:25:41 -04:00
// use mediumexporter's getPost function to fetch a Medium post
const post = await getPost ( mediumUrl , {
returnObject : true ,
output : output ,
postJSON : json
} ) . catch ( ( err ) => {
return {
error : err ,
}
} )
// set output folder path
// this is based on what mediumexporter chooses as the output folder
var outputFolder = path . join ( output , post . slug )
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Saving to: ${ outputFolder } ` ,
loglevel : 'info'
} )
2020-05-04 08:25:41 -04:00
if ( ! fs . existsSync ( path . join ( outputFolder , post . slug ) ) ) {
fs . mkdirSync ( outputFolder , { recursive : true } )
2019-12-10 07:45:09 -05:00
}
2020-05-04 08:25:41 -04:00
// mediumexporter writes a plain .md file if the post has no media
// if that is the case, we should create the subfolder manually
// and copy the data there.
if ( fs . existsSync ( path . join ( output , post . slug + '.md' ) ) ) {
fs . renameSync (
path . join ( output , post . slug + '.md' ) ,
path . join ( outputFolder , 'index.md' )
)
}
2019-12-10 07:45:09 -05:00
2020-05-04 08:25:41 -04:00
// generate metadata
const metadata = JSON . stringify ( {
title : post . title ,
subtitle : post . subtitle ,
author : post . author || "" ,
authors : post . authors || [ ] ,
date : new Date ( post . date ) ,
tags : post . tags ,
url : post . url ,
slug : post . slug ,
images : post . images ,
featuredImage : post . featuredImage ,
} )
2019-12-10 07:45:09 -05:00
2020-05-04 08:25:41 -04:00
// write metadata to output folder
2020-05-04 08:48:31 -04:00
await fs . promises . writeFile ( path . join ( outputFolder , 'metadata.json' ) , metadata )
2020-05-04 08:25:41 -04:00
return post
} ;
2019-12-10 07:45:09 -05:00
2020-05-04 08:25:41 -04:00
/ * *
* function [ pushToGhost ]
* @ returns [ string ] status
* /
async pushToGhost ( postSlug ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'starting' ,
message : 'Starting upload: ' + postSlug ,
loglevel : 'info'
} )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Decide working path
var postFolder = path . resolve ( 'content/' + postSlug )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Verify file exists
if ( ! fs . existsSync ( postFolder ) ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'error' , {
message : 'Could not find post folder! Is it fetched?' ,
} )
2020-05-04 08:25:41 -04:00
return false
}
// Decide file
const postContent = path . join ( postFolder , 'index.md' )
const postOutput = path . join ( postFolder , 'ghost.md' )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Verify post exists
if ( ! fs . existsSync ( postContent ) ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'error' , {
message : "Could not find 'index.md' in " + postSlug + "! Is it fetched?" ,
} )
2020-05-04 08:25:41 -04:00
return false
}
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Decide WebDAV upload path
var current _date = new Date ( )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
const uploadPath = path . join (
current _date . getUTCFullYear ( ) . toString ( ) ,
( current _date . getUTCMonth ( ) + 1 ) . toString ( ) ,
postSlug
)
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Path where WebDAV files will be placed (eg. https://example.com:2078)
const davPath = path . join ( config . webdav . path _prefix , uploadPath )
2020-01-01 11:05:23 -05:00
2020-05-04 08:25:41 -04:00
// Public path to upload those files (eg. https://media.example.com/uploads)
// We'll do it directly since path.join mangles the protocol
const uploadedPath = config . webdav . uploaded _path _prefix + '/' + uploadPath
2020-01-01 11:05:23 -05:00
2020-05-04 08:25:41 -04:00
// load metadata file
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'starting' ,
message : 'Loading metadata' ,
loglevel : 'debug'
} )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
var postMetaFile = path . join ( postFolder , 'metadata.json' )
2020-05-04 08:48:31 -04:00
let postMeta = await JSON . parse ( await fs . promises . readFile ( postMetaFile ) )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// Process lines
const readInterface = readline . createInterface ( {
input : fs . createReadStream ( postContent ) ,
output : process . stdout ,
terminal : false
} )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
const outStream = fs . createWriteStream ( postOutput , { encoding : 'utf-8' } )
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
var titleSkipped = false ;
let reImage = new RegExp ( '^!\\[(.*)\\]\\((\\S+?)\\)(.*)' )
let reTitle = new RegExp ( '^#\ .*' )
// Note down uploaded images
var uploadedImages = [ ]
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : null , // we don't know the percentage
message : 'Parsing post' ,
loglevel : 'info'
} )
2020-05-04 08:25:41 -04:00
for await ( const line of readInterface ) {
// Line to output
// Default is to make it same as input
var newLine = line
// Skip the header
if ( ! titleSkipped && await reTitle . exec ( line ) ) {
titleSkipped = true
}
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
// check for images
var m = await reImage . exec ( line )
if ( m ) {
// Get image name
var imageAlt = m [ 1 ]
var imageName = m [ 2 ] . replace ( '*' , '' )
var imagePath = path . join ( postFolder , 'images' , imageName )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
if ( ! fs . existsSync ( imagePath ) ) {
console . warn ( 'Skipping missing image: ' + imageName )
} else {
// check for separator image
var isScissors = await this . checkScissors ( imagePath )
if ( isScissors ) {
newLine = '\n---\n'
} else {
// upload pic to server
console . debug ( ` Adding to upload queue: ${ imageName } ` )
uploadedImages . push ( imageName )
2020-05-04 08:51:59 -04:00
// Let's wait for the upload, just to avoid conflicts
await this . uploadDav ( davPath , imagePath )
2020-05-04 08:25:41 -04:00
newLine = '![' + imageAlt + '](' + uploadedPath + '/' + imageName + ')'
}
}
}
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
outStream . write ( newLine + '\n' )
2020-01-01 11:06:29 -05:00
}
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
// Upload feature_image, if required
var featuredImagePath
if ( ! ! postMeta . featuredImage ) {
var imageName = postMeta . featuredImage . replace ( '*' , '' )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// if the image is listed in postMeta.images, it would have
// already been uploaded
if ( uploadedImages . indexOf ( imageName ) != - 1 ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : 95 , // we don't know the percentage
message : ` Skipping feature image ${ imageName } : already listed for upload ` ,
loglevel : 'info'
} )
2019-12-24 04:20:48 -05:00
} else {
2020-05-04 08:25:41 -04:00
var imagePath = path . join ( postFolder , 'images' , imageName )
2019-12-24 04:20:48 -05:00
2020-05-04 08:25:41 -04:00
// We can only upload if the file exists!
if ( ! fs . existsSync ( imagePath ) ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : 95 , // we don't know the percentage
message : ` Skipping feature image " ${ imageName } ": file not found ` ,
loglevel : 'warning'
} )
2020-05-04 08:25:41 -04:00
} else {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : 95 , // we don't know the percentage
message : ` Uploading feature image: ${ imageName } ` ,
loglevel : 'info'
} )
2020-05-04 08:25:41 -04:00
this . uploadDav ( davPath , imagePath )
featuredImagePath = uploadedPath + '/' + imageName
2020-01-01 11:01:13 -05:00
}
2019-12-24 04:20:48 -05:00
}
}
2020-05-04 08:25:41 -04:00
// calculate users
let users = [ ]
postMeta . authors . forEach ( ( user ) => {
users . push ( { slug : user . username } )
} )
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
// This will happen once all the line reading is finished
// Uploads will continue in paralell though
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : 100 , // we don't know the percentage
message : 'Uploading to Ghost' ,
loglevel : 'info'
} )
2020-05-04 08:25:41 -04:00
this . ghostAdmin . posts . add ( {
title : postMeta . title ,
custom _excerpt : postMeta . subtitle || null ,
tags : postMeta . tags ,
authors : users ,
2020-05-04 08:48:31 -04:00
html : markdown . toHTML ( await fs . promises . readFile ( postOutput , 'utf-8' ) ) ,
2020-05-04 08:25:41 -04:00
feature _image : featuredImagePath
} , { source : 'html' } )
. then ( ( res ) => {
// Check if user was added
if ( res . primary _author . id == 1 ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'notification' , {
message : ` WARNING: The admin editor, " ${ res . primary _author . name } ", is set as author for this post. If this is incorrect, there was some problem matching usernames. Please check and set it manually. ` ,
} )
2020-05-04 08:25:41 -04:00
}
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'progress' ,
progress : 100 , // we don't know the percentage
message : 'Post conveyed successfully' ,
loglevel : 'info'
} )
2020-05-04 08:25:41 -04:00
} )
} ;
2019-12-24 04:20:48 -05:00
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
/ * *
* function [ mediumToGhost ]
* @ returns [ string ] status
* /
mediumToGhost ( mediumUrl ) {
console . info ( 'Copying: ' + mediumUrl ) ;
2020-01-01 11:01:13 -05:00
}
2019-12-24 12:08:38 -05:00
2020-05-04 08:25:41 -04:00
async fetchMediumJSON ( mediumUrl ) {
var json
var text
if ( mediumUrl . match ( /^http/i ) ) {
// add ?json attribute
mediumUrl = mediumUrl . replace ( /#.*$/ , '' )
mediumUrl = ` ${ mediumUrl } ?format=json `
const response = await fetch ( mediumUrl )
text = await response . text ( )
2020-05-04 08:36:01 -04:00
} else if ( fs . existsSync ( mediumUrl ) ) {
2020-05-04 08:48:31 -04:00
text = ( await fs . promises . readFile ( mediumUrl ) ) . toString ( )
2020-05-04 08:25:41 -04:00
} else {
2020-05-04 08:36:01 -04:00
throw { error : 'URL must be a Medium URL or existing JSON file' }
2020-01-01 11:01:13 -05:00
}
2019-10-09 06:47:37 -04:00
2020-05-04 08:48:31 -04:00
try {
json = await JSON . parse ( text . substr ( text . indexOf ( '{' ) ) )
} catch ( err ) {
throw { error : 'You JSON seems to be malformed' }
}
2020-05-04 08:25:41 -04:00
return json ;
}
/ * *
* function [ checkScissors ]
* @ returns [ boolean ] matchStatus
* /
async checkScissors ( imagePath ) {
// Decide "separator" image
// If set, images matching this will be ignored and replaced
// with a horizontal-rule ("---" in markdown) instead.
let scissors = config . scissors
// if scissors not set, return false
// (it's never a scissors since it never matches)
if ( ! scissors ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : '[scissors] No scissors set, so rejecting all images' ,
loglevel : 'warning'
} )
2020-01-01 11:01:13 -05:00
return false
2020-05-04 08:25:41 -04:00
} else {
// Check if given image matches the scissors
try {
let isScissors = new Rembrandt ( {
imageA : scissors ,
imageB : imagePath ,
thresholdType : Rembrandt . THRESHOLD _PERCENT ,
maxThreshold : 0.1
} )
let result = await isScissors . compare ( )
return result . passed
} catch ( err ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` [scissors] Skipping scissors check: ${ err . message } ` ,
loglevel : 'warning'
} )
2020-05-04 08:25:41 -04:00
return false
}
2020-01-01 11:01:13 -05:00
}
}
2020-05-04 08:25:41 -04:00
/ * *
* function [ createUser ]
* @ returns [ object ] ghost data json
* /
async generateUserData ( mediumUsername , email ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'starting' ,
message : ` Creating: @ ${ mediumUsername } (email: ${ email } ) ` ,
loglevel : 'debug'
} )
2020-05-04 08:25:41 -04:00
const mediumUrl = ` https://medium.com/@ ${ mediumUsername } /?format=json ` ;
const json = await fetchMediumJSON ( mediumUrl ) ;
if ( ! json . success ) {
2020-05-04 09:32:55 -04:00
this . emit ( 'error' , {
message : ` Error: ${ json . error } ` ,
} )
2020-05-04 08:25:41 -04:00
return false
}
2019-10-09 06:47:37 -04:00
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Name: ${ json . payload . user . name } ` ,
loglevel : 'debug'
} )
this . emit ( 'update' , {
status : 'normal' ,
message : ` Bio: ${ json . payload . user . bio } ` ,
loglevel : 'debug'
} )
2019-10-09 06:47:37 -04:00
2020-05-04 08:25:41 -04:00
// Download and upload image
2019-12-24 10:00:01 -05:00
2020-05-04 08:25:41 -04:00
let imageId = json . payload . user . imageId
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Profile pic: ${ imageId } ` ,
loglevel : 'debug'
} )
2019-12-24 10:00:01 -05:00
2020-05-04 08:25:41 -04:00
let imagePath = MEDIUM _IMG _CDN + '256/256/' + imageId
let filetype = imageId . split ( '.' ) [ imageId . split ( '.' ) . length - 1 ]
let fileName = ` ${ mediumUsername } . ${ filetype } `
let filePath = path . join ( process . env . PWD , fileName )
2019-12-24 10:00:01 -05:00
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Fetching profile pic: ${ imagePath } ` ,
loglevel : 'info'
} )
2019-12-24 10:00:01 -05:00
2020-05-04 08:25:41 -04:00
const response = await ( await r2 . get ( imagePath ) . response ) . buffer ( )
2020-05-04 08:48:31 -04:00
await await fs . promises . writeFile ( filePath , response , 'base64' )
2019-12-24 10:00:01 -05:00
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Uploading profile pic: ${ imagePath } ` ,
loglevel : 'info'
} )
2019-12-24 10:00:01 -05:00
2020-05-04 08:25:41 -04:00
await this . uploadDav ( path . join ( config . webdav . path _prefix , 'avatars' ) ,
filePath )
2019-12-24 10:00:01 -05:00
2020-05-04 08:25:41 -04:00
// Generate Ghost JSON
2019-10-09 06:47:37 -04:00
2020-05-04 08:25:41 -04:00
const ghostData = {
data : {
users : [
{
id : 1 ,
slug : json . payload . user . username ,
bio : json . payload . user . bio ,
email : email ,
name : json . payload . user . name ,
profile _image : config . webdav . uploaded _path _prefix + '/avatars/' + fileName
}
]
} ,
meta : {
exported _on : new Date ,
version : '2.14.0'
}
2019-10-09 06:47:37 -04:00
}
2020-05-04 08:25:41 -04:00
return ( JSON . stringify ( ghostData ) )
} ;
2019-10-06 09:57:08 -04:00
2020-05-04 08:25:41 -04:00
async createDirIfNotExist ( client , folder ) {
// recursively create subfolders if they don't exist.
2019-12-19 07:20:24 -05:00
2020-05-04 08:25:41 -04:00
//safety: don't touch directories outside WEBDAV_PATH_PREFIX
if ( ! folder . startsWith ( config . webdav . path _prefix ) ) {
throw new Error ( ` Cannot create directories outside ${ config . webdav . path _prefix } ` )
}
2019-12-19 07:20:24 -05:00
2020-05-04 08:25:41 -04:00
// check the folder
await client . stat ( folder )
. catch ( async ( err ) => {
if ( ! err . response ) {
// no response! Maybe a network error or something :P
console . error ( ` [dav-upload:folder] Error creating folder " ${ folder } " ` )
console . error ( ` [dav-upload:folder] ${ err . toJSON ( ) . message } ` )
console . error ( '[dav-upload:folder] Please check your Internet connection and try again' )
return false
} else if ( err . response . status == 404 ) {
2019-12-19 07:20:24 -05:00
2020-05-04 08:25:41 -04:00
// it's a 404, so we'll create the directory
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Noting missing subdirectory: ${ folder } ` ,
loglevel : 'debug'
} )
2019-12-19 07:20:24 -05:00
2020-05-04 08:25:41 -04:00
// first, create the parent directory (if required)
if ( ! await this . createDirIfNotExist ( client , path . dirname ( folder ) ) ) {
// if not created, we fail too :-/
return false
}
2019-12-19 07:20:24 -05:00
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'normal' ,
message : ` Creating missing subdirectory: ${ folder } ` ,
loglevel : 'debug'
} )
2020-05-04 08:25:41 -04:00
// then, create the current directory
await client . createDirectory ( folder )
. catch ( async ( err ) => {
if ( err . response . status == 405 ) { // Method Not Allowed
// Maybe the directory's already been created in the meantime?
await client . stat ( folder )
. catch ( ( err2 ) => {
// Bad guess. Panic (and raise the original error)
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'error' ,
message : ` Error: ${ err . toJSON ( ) . message } \n We're not sure what went wrong. Help! ` ,
loglevel : 'error'
} )
2020-05-04 08:25:41 -04:00
throw err
} )
} else {
// what's this? Panic!
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'error' ,
message : ` Error: ${ err . toJSON ( ) . message } \n We're not sure what went wrong. Help! ` ,
loglevel : 'error'
} )
2019-12-19 07:20:24 -05:00
throw err
2020-05-04 08:25:41 -04:00
}
} )
} else {
// it's not a 404; we don't know how to handle this. Panic!
2020-05-04 09:32:55 -04:00
this . emit ( 'update' , {
status : 'error' ,
message : 'An unknown error occured. Help!' ,
loglevel : 'error'
} )
2020-05-04 08:25:41 -04:00
console . error ( err . toJSON ( ) )
throw err
}
} )
return true
}
/ * *
* function [ uploadDav ]
* @ returns [ string ] status
* /
async uploadDav ( dirPath , filePath ) {
// connect to webdav
const client = createClient (
config . webdav . server _url ,
{
username : config . webdav . username ,
password : config . webdav . password ,
digest : config . webdav . use _digest
2019-12-19 07:20:24 -05:00
} )
2020-05-04 08:25:41 -04:00
// create directory if not exists
console . debug ( ` [dav-upload] Loading ${ dirPath } ` )
if ( ! await this . createDirIfNotExist ( client , dirPath ) ) {
console . error ( ` [dav-upload] Could not upload ${ path . basename ( filePath ) } :( ` )
return false
2019-12-19 07:20:24 -05:00
}
2020-05-04 08:25:41 -04:00
// upload a file
console . debug ( 'Uploading file' )
const outStream = client . createWriteStream (
path . join ( dirPath , path . basename ( filePath ) )
)
outStream . on ( 'finish' , ( ) => console . debug ( 'Uploaded successfully.' ) )
2019-12-19 07:20:24 -05:00
2020-05-04 08:25:41 -04:00
const inStream = fs . createReadStream ( filePath )
. pipe ( outStream )
2020-01-01 11:08:34 -05:00
2020-05-04 08:25:41 -04:00
return true
}
2019-12-19 07:20:24 -05:00
}
2020-05-04 09:32:55 -04:00
// Make Seance an EventEmitter
inherits ( Seance , EventEmitter )
2019-10-06 09:57:08 -04:00
module . exports = {
2020-05-04 08:25:41 -04:00
Seance
2019-10-06 09:57:08 -04:00
}