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 isarr from 'wsemi/src/isarr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import cint from 'wsemi/src/cint.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
//mime
let mimeHtml = 'text/html; charset=utf-8'
let mimeDocx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
/**
* 取錯誤訊息文字
*
* 底層轉檔器之reject可能為字串或Error物件,統一轉為文字供組合訊息使用。
*
* @param {*} err 輸入錯誤資料,可為字串、Error物件或任意資料
* @returns {String} 回傳錯誤訊息字串
* @example
*
* console.log(toErrText('abc'))
* // => 'abc'
*
* console.log(toErrText(new Error('abc')))
* // => 'abc'
*
*/
function toErrText(err) {
if (isstr(err)) {
return err
}
if (isestr(get(err, 'message'))) {
return err.message
}
let s = ''
try {
s = JSON.stringify(err) //undefined/function/symbol 會回 undefined 而非字串
}
catch (e) {
s = ''
}
if (!isstr(s)) {
s = String(err)
}
return s
}
/**
* 重試短暫被鎖定之檔案操作
*
* Windows下新建檔案常被防毒或索引器即時掃描而短暫鎖定(EBUSY/EPERM),遇此類錯誤時延遲後重試以提高穩定性,其餘錯誤直接拋出。
*
* @param {Function} fun 輸入欲執行之函數,可回傳Promise
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Integer} [opt.tries=6] 輸入最多嘗試次數整數,預設6
* @param {Number} [opt.delayMs=400] 輸入每次重試前延遲毫秒數,預設400
* @returns {Promise} 回傳Promise,resolve回傳fun之結果,reject回傳錯誤
* @example
*
* let r = await retryBusy(() => fs.promises.readFile('./a.txt', 'utf8'), { tries: 3 })
*
*/
async function retryBusy(fun, opt = {}) {
//check
if (!isfun(fun)) {
return Promise.reject('fun must be a function')
}
//tries
let tries = get(opt, 'tries', null)
if (!ispint(tries)) {
tries = 6
}
tries = cint(tries)
//delayMs
let delayMs = get(opt, 'delayMs', null)
if (!ispnum(delayMs)) {
delayMs = 400
}
delayMs = cdbl(delayMs)
let errLast = null
for (let i = 0; i < tries; i++) {
try {
return await fun()
}
catch (err) {
errLast = err
let code = get(err, 'code', '')
let busy = (code === 'EBUSY' || code === 'EPERM')
if (busy && i < tries - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
continue
}
throw err
}
}
throw errLast
}
//pmChain: docx 轉檔須逐一序列化
//why: 底層係調用本機 Word(win32com)進行轉檔, 同時執行多份會相互干擾(共用 Word 實例、模板檔遭鎖定),
// 故不論同時進來幾個請求, docx 階段一律排隊逐一執行; md 轉 html 為純 JS 不受此限
let pmChain = Promise.resolve()
let nQueue = 0
/**
* 將函數排入Word轉檔佇列並逐一執行
*
* 底層係調用本機Word(win32com)轉檔,同時執行多份會相互干擾,故docx階段一律排隊逐一執行;前一件不論成敗均續行下一件。
*
* @param {Function} fun 輸入欲執行之函數,可回傳Promise
* @returns {Promise} 回傳Promise,resolve回傳fun之結果,reject回傳錯誤
* @example
*
* let r = await runExclusive(() => WHtml2docx('./a.html', './a.docx'))
*
*/
function runExclusive(fun) {
//check
if (!isfun(fun)) {
return Promise.reject('fun must be a function')
}
nQueue++
let pm = pmChain.then(fun, fun) //前一件不論成敗均續行
pmChain = pm.then(() => {
nQueue--
}, () => {
nQueue--
})
return pm
}
/**
* 取當前排隊中(含執行中)之docx轉檔數
*
* @returns {Integer} 回傳排隊數整數
* @example
*
* console.log(getQueueSize())
* // => 0
*
*/
function getQueueSize() {
return nQueue
}
/**
* 取w-html2docx之htmlToDocx.exe目前所在位置(僅供診斷顯示)
*
* 依w-html2docx之查找位置(cwd之src/、cwd之node_modules/w-html2docx/src/)查詢檔案「目前」是否存在,不下載。
*
* 注意:不得作為轉檔前之守門。查無不代表無法轉檔——w-html2docx於轉檔當下會自動下載缺少之轉檔器,上層以此攔截會使該機制永遠不被執行。
*
* @returns {String} 回傳exe位置字串,目前不存在則回傳空字串
* @example
*
* console.log(getFpExe())
* // => 'C:\\proj\\node_modules\\w-html2docx\\src\\htmlToDocx.exe'
*
*/
function getFpExe() {
let fdSrv = path.resolve() //= process.cwd(), 與 w-html2docx 內部同規則
let fps = [
path.resolve(fdSrv, 'src/htmlToDocx.exe'),
path.resolve(fdSrv, 'node_modules/w-html2docx/src/htmlToDocx.exe'),
]
for (let fp of fps) {
if (fsIsFile(fp)) {
return fp
}
}
return ''
}
/**
* 檢查本機是否具備docx轉檔條件
*
* ready僅以作業系統為Windows判定,不以轉檔器是否存在為條件:exeFound為false代表轉檔器目前尚未取得,將於首次轉檔時由w-html2docx自動下載,並非不可用。
*
* 注意:僅代表環境條件成立,不代表轉檔器可下載成功或Word確實可被調用;後二者須實際轉一份小檔驗證。
*
* @returns {Object} 回傳狀態物件{platform,isWindows,cwd,exePath,exeFound,ready},exePath與exeFound為轉檔器目前所在位置與是否已存在(診斷用)
* @example
*
* console.log(checkDocxReady())
* // => { platform: 'win32', isWindows: true, cwd: '...', exePath: '', exeFound: false, ready: true }
*
*/
function checkDocxReady() {
let fpExe = getFpExe()
let isWindows = process.platform === 'win32'
return {
platform: process.platform,
isWindows,
cwd: path.resolve(),
exePath: fpExe,
exeFound: fpExe !== '',
ready: isWindows,
}
}
/**
* 取安全檔名主體
*
* 移除路徑成分、常見副檔名與跨平台非法字元,避免路徑穿越與寫檔失敗;無效輸入回傳'output'。
*
* @param {String} name 輸入檔名字串
* @returns {String} 回傳安全檔名主體字串
* @example
*
* console.log(toSafeName('../report/報告R00.01.md'))
* // => '報告R00.01'
*
* console.log(toSafeName(''))
* // => 'output'
*
*/
function toSafeName(name) {
let s = ''
if (isstr(name)) {
s = name.trim()
}
if (s === '') {
return 'output'
}
s = path.basename(s) //去除任何路徑成分
s = s.replace(/\.(md|markdown|html|htm|docx)$/i, '') //去除常見副檔名
s = s.replace(/[\\/:*?"<>|\r\n\t]/g, '_') //Windows 非法字元
s = s.replace(/^\.+/, '') //去除開頭點號(避免隱藏檔與相對路徑)
s = s.trim()
if (s === '') {
return 'output'
}
return s.slice(0, 120)
}
//checkAssetPath: 單一相對路徑之落點檢查
//why: 路徑可能來自外部輸入, 須以絕對路徑比對且尾端補分隔字元, 避免同前綴之兄弟資料夾被放行
function checkAssetPath(dirJob, rp, pOri) {
//拒絕絕對路徑(含磁碟機代號與 UNC)
if (/^[a-zA-Z]:/.test(rp) || rp.startsWith('/') || rp.startsWith('//')) {
throw new Error(`asset.path must be a relative path: ${pOri}`)
}
//落點檢查
let fp = path.resolve(dirJob, rp)
let base = path.resolve(dirJob) + path.sep
if (!fp.startsWith(base)) {
throw new Error(`asset.path is outside the working folder: ${pOri}`)
}
return fp
}
/**
* 解析資產之相對路徑並防護路徑穿越
*
* md內之圖片路徑可能為URL編碼(中文或空白),而轉檔器係以md內之原字串組路徑取圖;故原字串與解碼後字串不同時回傳兩者,供呼叫端寫入同一內容確保圖片必能被解析。
*
* @param {String} dirJob 輸入工作資料夾位置字串
* @param {String} p 輸入資產相對路徑字串
* @returns {Array} 回傳實際寫入之絕對路徑陣列
* @example
*
* console.log(resolveAssetPaths('C:\\job', 'pics/%E5%9C%96.png'))
* // => [ 'C:\\job\\pics\\%E5%9C%96.png', 'C:\\job\\pics\\圖.png' ]
*
*/
function resolveAssetPaths(dirJob, p) {
//raw
let raw = ''
if (isstr(p)) {
raw = p.trim().replace(/\\/g, '/')
}
if (raw === '') {
throw new Error('asset.path is empty')
}
//解碼; 非法 percent 序列則視為未編碼
let dec = raw
try {
dec = decodeURIComponent(raw)
}
catch (err) {
dec = raw
}
let rps = (raw === dec) ? [raw] : [raw, dec]
return rps.map((rp) => checkAssetPath(dirJob, rp, p))
}
/**
* 將夾帶之資產(圖片等)還原至工作資料夾
*
* 使md內之相對路徑得以解析;base64容許data URI形式。
*
* @param {String} dirJob 輸入工作資料夾位置字串
* @param {Array} assets 輸入資產陣列,格式[{path,base64}],亦接受contentBase64欄位
* @returns {Integer} 回傳寫入之資產數整數
* @example
*
* let n = writeAssets('C:\\job', [{ path: 'pics/圖.png', base64: '…' }])
* // => 1
*
*/
function writeAssets(dirJob, assets) {
let n = 0
if (!isarr(assets)) {
return n
}
for (let v of assets) {
let p = get(v, 'path', '')
let fps = resolveAssetPaths(dirJob, p)
let b64 = get(v, 'base64', '')
if (!isestr(b64)) {
b64 = get(v, 'contentBase64', '')
}
if (!isestr(b64)) {
throw new Error(`asset.base64 is empty: ${p}`)
}
b64 = b64.replace(/^data:[^;]+;base64,/, '') //容許 data URI 形式
let buf = Buffer.from(b64, 'base64')
for (let fp of fps) {
fs.mkdirSync(path.dirname(fp), { recursive: true })
fs.writeFileSync(fp, buf)
}
n++
}
return n
}
/**
* 清除工作資料夾內逾時未刪之作業夾(前綴job_)
*
* 工作資料夾不存在時自動建立。
*
* @param {String} dirWork 輸入工作資料夾位置字串
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Number} [opt.msAge=21600000] 輸入視為逾時之毫秒數,預設6小時(21600000)
* @returns {Integer} 回傳刪除之作業夾數整數
* @example
*
* let n = cleanWorkDir('./tmp', { msAge: 3600 * 1000 })
* // => 2
*
*/
function cleanWorkDir(dirWork, opt = {}) {
//check
if (!isestr(dirWork)) {
return 0
}
//msAge
let msAge = get(opt, 'msAge', null)
if (!ispnum(msAge)) {
msAge = 6 * 3600 * 1000
}
msAge = cdbl(msAge)
if (!fsIsFolder(dirWork)) {
fs.mkdirSync(dirWork, { recursive: true })
return 0
}
let n = 0
let msNow = Date.now()
for (let fn of fs.readdirSync(dirWork)) {
if (!fn.startsWith('job_')) {
continue
}
let fp = path.resolve(dirWork, fn)
try {
let st = fs.statSync(fp)
if (msNow - st.mtimeMs > msAge) {
fs.rmSync(fp, { recursive: true, force: true })
n++
}
}
catch (err) {
//忽略無法處理者
}
}
return n
}
/**
* 轉譯資產缺漏之錯誤訊息
*
* 底層僅回原始路徑訊息(且含內部工作夾路徑),此處改為可行動之提示並隱去工作路徑;非資產缺漏訊息原樣回傳。
*
* @param {String} msg 輸入原始錯誤訊息字串
* @returns {String} 回傳轉譯後錯誤訊息字串
* @example
*
* console.log(transErrAsset('fp[C:/tmp/job_1/pics/a.png] does not exist'))
* // => 'Failed to convert md to html: the referenced file (pics/a.png) does not exist, please make sure it is included in assets'
*
*/
function transErrAsset(msg) {
if (!isstr(msg)) {
return toErrText(msg)
}
let mt = msg.match(/job_[^\\/]+[\\/](.+?)\]/)
if (/does not exist/i.test(msg) && mt) {
return `Failed to convert md to html: the referenced file (${mt[1].replace(/\\/g, '/')}) does not exist, please make sure it is included in assets`
}
return msg
}
/**
* 整理服務端請求之開關或設定物件
*
* 值為true或字串'true'時回傳true;為物件時回傳移除omit所列鍵後之複本;其餘回傳false。供toc、keepCaption等「true或設定物件」型之請求參數共用。
*
* @param {*} v 輸入請求之值
* @param {Array} [omit=[]] 輸入不由請求端決定而須移除之鍵陣列,預設[]
* @returns {Boolean|Object} 回傳false、true或設定物件
* @example
*
* console.log(normReqOpt('true'))
* // => true
*
* console.log(normReqOpt({ a: 1, b: 2 }, ['b']))
* // => { a: 1 }
*
*/
function normReqOpt(v, omit = []) {
if (v === true || v === 'true') {
return true
}
if (isobj(v)) {
let o = { ...v }
for (let k of isarr(omit) ? omit : []) {
delete o[k]
}
return o
}
return false
}
/**
* 整理服務端請求之目錄設定
*
* 同normReqOpt,另移除timeoutMs:Word更新之逾時屬服務端資源控管,不由請求端決定,否則請求端可用極短逾時反覆中斷Word更新,使經COM啟動之WINWORD行程殘留。
*
* @param {*} toc 輸入請求之toc
* @returns {Boolean|Object} 回傳false、true或設定物件
* @example
*
* console.log(normTocReq({ maxLevels: 2, timeoutMs: 1 }))
* // => { maxLevels: 2 }
*
*/
function normTocReq(toc) {
return normReqOpt(toc, ['timeoutMs'])
}
/**
* 整理服務端請求之圖名表名同頁設定
*
* 同normReqOpt,回傳false、true或設定物件之複本。
*
* @param {*} keepCaption 輸入請求之keepCaption
* @returns {Boolean|Object} 回傳false、true或設定物件
* @example
*
* console.log(normKeepReq({ labels: { fig: 'Figure', tab: 'Table' } }))
* // => { labels: { fig: 'Figure', tab: 'Table' } }
*
*/
function normKeepReq(keepCaption) {
return normReqOpt(keepCaption)
}
export {
mimeHtml,
mimeDocx,
toErrText,
retryBusy,
runExclusive,
getQueueSize,
getFpExe,
checkDocxReady,
toSafeName,
resolveAssetPaths,
writeAssets,
cleanWorkDir,
transErrAsset,
normReqOpt,
normTocReq,
normKeepReq
}