rmApiClient.mjs

import fs from 'fs'
import path from 'path'
import get from 'lodash-es/get.js'
import isstr from 'wsemi/src/isstr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import cint from 'wsemi/src/cint.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import { toErrText } from './utils.mjs'


//hostDef, portDef: 服務位置預設值(與 rmApiServer 預設埠一致)
let hostDef = '127.0.0.1'
let portDef = 22000


//getUrl: 取服務位置
//優先序: opt.url(完整位址, 含協定與可能之路徑前綴) > opt.host + opt.port(可各自獨立給, 缺者依序取環境變數 WMD2DOCX_HOST / WMD2DOCX_PORT 再取預設值)
function getUrl(opt) {

    //url
    let url = get(opt, 'url', '')
    if (isestr(url)) {
        return url.trim().replace(/\/+$/, '')
    }

    //host
    let host = get(opt, 'host', '')
    if (!isestr(host)) {
        host = get(process, 'env.WMD2DOCX_HOST', '')
    }
    if (!isestr(host)) {
        host = hostDef
    }
    host = host.trim()

    //port
    let port = get(opt, 'port', null)
    if (!ispint(port)) {
        port = get(process, 'env.WMD2DOCX_PORT', null)
    }
    if (!ispint(port)) {
        port = portDef
    }
    port = cint(port)

    return `http://${host}:${port}`
}


//getToken: 取權杖(參數 > 環境變數 WMD2DOCX_TOKEN > 空字串)
function getToken(token) {
    if (!isestr(token)) {
        token = get(process, 'env.WMD2DOCX_TOKEN', '')
    }
    if (!isestr(token)) {
        token = ''
    }
    return token
}


/**
 * 掃描Markdown內引用之本機相對路徑資產(圖片等)
 *
 * 涵蓋html之<img src="…">與markdown之![](…);排除網路位址、data協定、mailto、錨點與絕對路徑,並去除錨點與查詢字串。
 *
 * @param {String} md 輸入Markdown內容字串
 * @returns {Array} 回傳相對路徑字串陣列(已去重)
 * @example
 *
 * console.log(scanAssetPaths('![a](pics/a.png)\n<img src="pics/b.png" />\n![c](https://x.com/c.png)'))
 * // => [ 'pics/a.png', 'pics/b.png' ]
 *
 */
function scanAssetPaths(md) {
    let ps = new Set()

    if (!isstr(md)) {
        return []
    }

    let add = (v) => {
        if (!isstr(v)) {
            return
        }
        let p = v.trim().replace(/^<|>$/g, '')
        if (p === '') {
            return
        }
        if (/^(https?:)?\/\//i.test(p) || /^data:/i.test(p) || /^mailto:/i.test(p) || /^#/.test(p)) {
            return //網路位址、內嵌資料與錨點不需夾帶
        }
        if (/^[a-zA-Z]:/.test(p) || p.startsWith('/')) {
            return //絕對路徑不夾帶(服務端一律拒收)
        }
        p = p.split('#')[0].split('?')[0] //去除錨點與查詢字串
        if (p === '') {
            return
        }
        ps.add(p)
    }

    for (let m of md.matchAll(/<img[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi)) {
        add(m[1])
    }
    for (let m of md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) {
        add(m[1])
    }

    return [...ps]
}


/**
 * 讀取Markdown引用之資產檔並轉base64
 *
 * path一律回傳md內之原字串(服務端據以還原相對路徑);實體檔則原字串與URL解碼後字串皆嘗試。
 *
 * @param {String} md 輸入Markdown內容字串
 * @param {String} dirMd 輸入Markdown所在資料夾位置字串,相對路徑以此為基準
 * @returns {Object} 回傳{assets,missing},assets為[{path,base64}],missing為找不到之相對路徑陣列
 * @example
 *
 * let { assets, missing } = readAssets('![a](pics/a.png)', './report')
 * // => { assets: [ { path: 'pics/a.png', base64: '…' } ], missing: [] }
 *
 */
function readAssets(md, dirMd) {
    let assets = []
    let missing = []

    if (!isestr(dirMd)) {
        dirMd = '.'
    }

    for (let p of scanAssetPaths(md)) {

        //候選實體路徑(md 內可能為 URL 編碼之中文檔名)
        let cands = [p]
        try {
            let dec = decodeURIComponent(p)
            if (dec !== p) {
                cands.push(dec)
            }
        }
        catch (err) {
            //非法 percent 序列, 略過解碼候選
        }

        let fpHit = ''
        for (let c of cands) {
            let fp = path.resolve(dirMd, c)
            if (fsIsFile(fp)) {
                fpHit = fp
                break
            }
        }

        if (fpHit === '') {
            missing.push(p)
            continue
        }

        assets.push({
            path: p,
            base64: fs.readFileSync(fpHit).toString('base64'),
        })

    }
    return { assets, missing }
}


/**
 * 將本機Markdown檔送至轉檔服務,取回並寫出Html與(或)Docx
 *
 * 供無Microsoft Word之機器產製docx:讀取本機md與其引用之圖片,送至rmApiServer轉檔後寫回本機。連線層失敗會自動重試;逾時與HTTP錯誤不重試。
 *
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.host='127.0.0.1'] 輸入服務主機位址(ip或網域)字串,未給則取環境變數WMD2DOCX_HOST,再無則用'127.0.0.1',預設'127.0.0.1'
 * @param {Integer} [opt.port=22000] 輸入服務埠號整數,未給則取環境變數WMD2DOCX_PORT,再無則用22000,預設22000
 * @param {String} [opt.url=''] 輸入服務完整位址字串(如'https://host/prefix'),給予時覆寫host與port,預設''
 * @param {String} opt.fpInMd 輸入來源Markdown檔位置字串
 * @param {String} [opt.fpOutDocx=''] 輸入轉出Docx檔位置字串,與fpOutHtml皆未給時預設於md旁產出同名docx,預設''
 * @param {String} [opt.fpOutHtml=''] 輸入轉出Html檔位置字串,預設''
 * @param {String} [opt.fpInTemp=''] 輸入本機Docx模板檔位置字串,給予時以base64夾帶至服務端,預設''
 * @param {String} [opt.templateName=''] 輸入服務端模板檔名字串,預設''
 * @param {String} [opt.name=''] 輸入輸出檔名主體字串,未給則取md檔名,預設''
 * @param {String} [opt.token=''] 輸入x-api-token字串,未給則取環境變數WMD2DOCX_TOKEN,預設''
 * @param {Object} [opt.optMd2html] 輸入傳予w-md2html之設定物件
 * @param {Object} [opt.optHtml2docx] 輸入傳予w-html2docx之設定物件,其imgHeightReserveLines未給時由服務端預設4(行格線模板之圖片下方預留格數,給null停用,見cvMdToDocx)
 * @param {Boolean} [opt.allowMissingAssets=false] 輸入是否容許md引用之資產檔不存在布林值,預設false
 * @param {Boolean|Object} [opt.keepCaption=false] 輸入是否於服務端使圖名、表名與其圖、表同頁(true或{labels});有產出docx而服務端回應未帶其結果時(服務端版本不支援)reject且不寫檔,預設false
 * @param {Boolean|Object} [opt.toc=false] 輸入是否於服務端添加章節目錄、圖目錄、表目錄並重編頁碼(true或addDocxToc之設定物件,其timeoutMs由服務端決定而不採用);有產出docx而服務端回應未帶目錄結果時(服務端版本不支援)reject且不寫檔,預設false
 * @param {Number} [opt.timeoutMs=600000] 輸入逾時毫秒數,轉檔含Word啟動故預設10分鐘,預設600000
 * @param {Integer} [opt.retries=3] 輸入連線層失敗之重試次數整數,預設3
 * @returns {Promise} 回傳Promise,resolve回傳結果物件{ms,nAssets,template,[html],[docx],[keepCaption],[toc]},其中html與docx為{fp,size},keepCaption與toc為服務端之同名結果,reject回傳錯誤訊息
 * @example
 *
 * import { cvMdTo } from 'w-md2docx/src/rmApiClient.mjs'
 *
 * let r = await cvMdTo({
 *     host: '127.0.0.1',
 *     port: 22000,
 *     fpInMd: './test/report.md',
 *     fpOutDocx: './test/report.docx',
 * })
 * console.log(r)
 * // => { ms: 8342, nAssets: 1, template: 'default', docx: { fp: '…', size: 39856 } }
 *
 */
async function cvMdTo(opt = {}) {

    //url
    let url = getUrl(opt)

    //fpInMd
    let fpInMd = get(opt, 'fpInMd', '')
    if (!isestr(fpInMd)) {
        return Promise.reject('fpInMd must be a non-empty string')
    }
    fpInMd = path.resolve(fpInMd)
    if (!fsIsFile(fpInMd)) {
        return Promise.reject(`fpInMd[${fpInMd}] does not exist`)
    }

    //fpOutDocx, fpOutHtml
    let fpOutDocx = get(opt, 'fpOutDocx', '')
    fpOutDocx = isestr(fpOutDocx) ? path.resolve(fpOutDocx) : ''
    let fpOutHtml = get(opt, 'fpOutHtml', '')
    fpOutHtml = isestr(fpOutHtml) ? path.resolve(fpOutHtml) : ''
    if (fpOutDocx === '' && fpOutHtml === '') {
        //未指定輸出時, 預設於 md 旁產出同名 docx
        fpOutDocx = path.resolve(path.dirname(fpInMd), `${path.basename(fpInMd).replace(/\.md$/i, '')}.docx`)
    }

    //out (依欲寫出之檔案決定)
    let out = 'docx'
    if (fpOutDocx !== '' && fpOutHtml !== '') {
        out = 'both'
    }
    else if (fpOutHtml !== '') {
        out = 'html'
    }

    //allowMissingAssets
    let allowMissingAssets = get(opt, 'allowMissingAssets', false)
    if (!isbol(allowMissingAssets)) {
        allowMissingAssets = false
    }

    //md 與資產
    let md = fs.readFileSync(fpInMd, 'utf8')
    let dirMd = path.dirname(fpInMd)
    let { assets, missing } = readAssets(md, dirMd)
    if (missing.length > 0 && !allowMissingAssets) {
        //why: 缺圖不擋下會靜默產出破圖之 docx, 交付後才被發現
        return Promise.reject(`${missing.length} asset file(s) referenced in md do not exist: ${missing.join(', ')}. Set allowMissingAssets:true to skip this check`)
    }

    //name (輸出檔名主體, 未指定則取 md 檔名)
    let name = get(opt, 'name', '')
    if (!isestr(name)) {
        name = path.basename(fpInMd).replace(/\.md$/i, '')
    }

    //body
    let body = {
        md,
        name,
        out,
        assets,
    }
    let optMd2html = get(opt, 'optMd2html', null)
    if (isobj(optMd2html)) {
        body.optMd2html = optMd2html
    }
    let optHtml2docx = get(opt, 'optHtml2docx', null)
    if (isobj(optHtml2docx)) {
        body.optHtml2docx = optHtml2docx
    }
    let templateName = get(opt, 'templateName', '')
    if (isestr(templateName)) {
        body.templateName = templateName.trim()
    }
    let keepCaption = get(opt, 'keepCaption', false)
    let needKeep = (keepCaption === true || isobj(keepCaption))
    if (needKeep) {
        body.keepCaption = keepCaption
    }
    let toc = get(opt, 'toc', false)
    let needToc = (toc === true || isobj(toc))
    if (needToc) {
        body.toc = toc
    }
    let fpInTemp = get(opt, 'fpInTemp', '')
    if (isestr(fpInTemp)) {
        fpInTemp = path.resolve(fpInTemp)
        if (!fsIsFile(fpInTemp)) {
            return Promise.reject(`fpInTemp[${fpInTemp}] does not exist`)
        }
        body.templateBase64 = fs.readFileSync(fpInTemp).toString('base64')
    }

    //headers
    let headers = { 'content-type': 'application/json' }
    let token = getToken(get(opt, 'token', ''))
    if (token !== '') {
        headers['x-api-token'] = token
    }

    //timeoutMs (轉檔含 Word 啟動, 預設 10 分鐘)
    let timeoutMs = get(opt, 'timeoutMs', null)
    if (!ispnum(timeoutMs)) {
        timeoutMs = 600000
    }
    timeoutMs = cdbl(timeoutMs)

    //retries (連線層失敗之重試次數)
    let retries = get(opt, 'retries', null)
    if (!isp0int(retries)) {
        retries = 3
    }
    retries = cint(retries)

    //post
    //why: 連線層失敗(網路瞬斷、服務端重啟、TCP 佇列滿)屬暫時性, 須重試;
    //     逾時與業務錯誤(HTTP 4xx/5xx)則不重試——前者重試只會更久, 後者重試結果相同
    let cBody = JSON.stringify(body)
    let res = null
    let errLast = ''
    for (let i = 0; i <= retries; i++) {

        if (i > 0) {
            let msWait = [0, 3000, 8000, 15000][i] || 15000
            console.log(`connection failed, retrying in ${msWait / 1000}s (${i}/${retries}): ${errLast}`)
            await new Promise((resolve) => setTimeout(resolve, msWait))
        }

        try {
            res = await fetch(`${url}/api/convert`, {
                method: 'POST',
                headers,
                body: cBody,
                signal: AbortSignal.timeout(timeoutMs),
            })
            errLast = ''
            break
        }
        catch (err) {
            let nameErr = get(err, 'name', '')
            errLast = toErrText(err) || 'unknown error'
            if (nameErr === 'TimeoutError' || nameErr === 'AbortError') {
                return Promise.reject(`Conversion timed out (no response within ${timeoutMs} ms): ${url}`)
            }
        }

    }
    if (errLast !== '') {
        return Promise.reject(`Unable to connect to the conversion service ${url} (retried ${retries} times): ${errLast}`)
    }

    //rt
    let rt = await res.json().catch(() => null)
    if (!res.ok || !isobj(rt) || rt.success !== true) {
        let msg = get(rt, 'error', '')
        if (!isestr(msg)) {
            msg = `HTTP ${res.status}`
        }
        return Promise.reject(`Conversion failed: ${msg}`)
    }

    //圖名表名同頁與目錄之結果: 有要求且有產出 docx 時回應須帶該結果, 未帶代表服務端版本不支援而略過, 不寫出缺此處理之 docx
    if (needKeep && fpOutDocx !== '' && !isobj(get(rt, 'keepCaption'))) {
        return Promise.reject(`The conversion service did not report the keepCaption result, it may not support keepCaption, please update w-md2docx on the service: ${url}`)
    }
    if (needToc && fpOutDocx !== '' && !isobj(get(rt, 'toc'))) {
        return Promise.reject(`The conversion service did not report the TOC result, it may not support toc, please update w-md2docx on the service: ${url}`)
    }

    //寫出並驗證產物(以實體檔大小為準)
    let rw = { ms: rt.ms, nAssets: rt.nAssets, template: rt.template }
    if (isobj(get(rt, 'keepCaption'))) {
        rw.keepCaption = rt.keepCaption
    }
    if (isobj(get(rt, 'toc'))) {
        rw.toc = rt.toc
    }

    let writeOne = (fpOut, one, tag) => {
        if (fpOut === '') {
            return ''
        }
        let b64 = get(one, 'base64', '')
        if (!isestr(b64)) {
            return `the service did not return ${tag}`
        }
        fs.mkdirSync(path.dirname(fpOut), { recursive: true })
        fs.writeFileSync(fpOut, Buffer.from(b64, 'base64'))
        let size = fs.statSync(fpOut).size
        if (size === 0) {
            return `${tag} is empty after being written: ${fpOut}`
        }
        rw[tag] = { fp: fpOut, size }
        return ''
    }

    let errWrite = writeOne(fpOutHtml, rt.html, 'html')
    if (errWrite !== '') {
        return Promise.reject(errWrite)
    }
    errWrite = writeOne(fpOutDocx, rt.docx, 'docx')
    if (errWrite !== '') {
        return Promise.reject(errWrite)
    }

    return rw
}


/**
 * 查詢轉檔服務之健康狀態
 *
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.host='127.0.0.1'] 輸入服務主機位址(ip或網域)字串,未給則取環境變數WMD2DOCX_HOST,再無則用'127.0.0.1',預設'127.0.0.1'
 * @param {Integer} [opt.port=22000] 輸入服務埠號整數,未給則取環境變數WMD2DOCX_PORT,再無則用22000,預設22000
 * @param {String} [opt.url=''] 輸入服務完整位址字串,給予時覆寫host與port,預設''
 * @param {String} [opt.token=''] 輸入x-api-token字串,未給則取環境變數WMD2DOCX_TOKEN,預設''
 * @param {Number} [opt.timeoutMs=20000] 輸入逾時毫秒數,預設20000
 * @returns {Promise} 回傳Promise,resolve回傳服務端回傳之狀態物件,reject回傳錯誤訊息
 * @example
 *
 * import { health } from 'w-md2docx/src/rmApiClient.mjs'
 *
 * let r = await health({ host: '127.0.0.1', port: 22000 })
 * console.log(r)
 * // => { success: true, platform: 'win32', docxReady: true, … }
 *
 */
async function health(opt = {}) {

    //url
    let url = getUrl(opt)

    //headers
    let headers = {}
    let token = getToken(get(opt, 'token', ''))
    if (token !== '') {
        headers['x-api-token'] = token
    }

    //timeoutMs
    let timeoutMs = get(opt, 'timeoutMs', null)
    if (!ispnum(timeoutMs)) {
        timeoutMs = 20000
    }
    timeoutMs = cdbl(timeoutMs)

    //fetch
    let res = null
    try {
        res = await fetch(`${url}/api/health`, { headers, signal: AbortSignal.timeout(timeoutMs) })
    }
    catch (err) {
        return Promise.reject(`Unable to connect to the conversion service ${url}: ${toErrText(err)}`)
    }

    //rt
    let rt = await res.json().catch(() => null)
    if (!isobj(rt)) {
        return Promise.reject(`Invalid response from the conversion service ${url}: HTTP ${res.status}`)
    }

    return rt
}


let rmApiClient = {
    cvMdTo,
    health,
    scanAssetPaths,
    readAssets,
}


export { cvMdTo, health, scanAssetPaths, readAssets }
export default rmApiClient