marktext/src/renderer/util/fileSystem.js
Lee Holmes 5ffc026a28 This change adds several features to make it easier to write Markdown articles that desire server-based paths (such as /images/my_image.png).
1) There is now a new preference page under images for "Maintaining server paths". You specify a server path (such as /images/) and a local path (such as c:\hugo\static\images), and
Marktext will do the right mapping internally. Image previews will come from the images on your computer, while the content stored in the document will represent the corresponding server paths.
Full documentation has been added to IMAGES.md.
2) Path variables (like ${filename}) have been expanded to support year, month, and day. This helps prevent filename collisions and is common in many blogging platforms.
3) Pasting images and dragging them into Marktext supports this path mapping as well, moving them to the local path as appropriate
4) Image imports (pasting and or dragging) now retain the source image file name if possible, only using the image hash if there is a conflict. Images with names are easier to manage and show up
appropriately in search engines
4) The image edit dialog now has a 'Rename' tab so that you can rename the images to something more specific when they are just pasted from the clipboard. This simplifies the previous workflow that
used to require editing the markdown and renaming the file in the filesystem manually.

There is also now a 'User Notification Dialog' component to let Marktext communicate with the user about error conditions.
2024-01-19 18:42:50 -08:00

258 lines
7.4 KiB
JavaScript

import path from 'path'
import crypto from 'crypto'
import fs from 'fs-extra'
import { statSync, constants } from 'fs'
import cp from 'child_process'
import { tmpdir } from 'os'
import dayjs from 'dayjs'
import { Octokit } from '@octokit/rest'
import { isImageFile } from 'common/filesystem/paths'
import { isWindows } from './index'
export const create = async (pathname, type) => {
return type === 'directory'
? fs.ensureDir(pathname)
: fs.outputFile(pathname, '')
}
export const paste = async ({ src, dest, type }) => {
return type === 'cut'
? fs.move(src, dest)
: fs.copy(src, dest)
}
export const rename = async (src, dest) => {
return fs.move(src, dest)
}
export const getHash = (content, encoding, type) => {
return crypto.createHash(type).update(content, encoding).digest('hex')
}
export const getContentHash = content => {
return getHash(content, 'utf8', 'sha1')
}
/**
* Moves an image to a relative position.
*
* @param {String} cwd The relative base path (project root or full folder path of opened file).
* @param {String} relativeName The relative directory name.
* @param {String} filePath The full path to the opened file in editor.
* @param {String} imagePath The image to move.
* @returns {String} The relative path the the image from given `filePath`.
*/
export const moveToRelativeFolder = async (cwd, relativeName, filePath, imagePath) => {
if (!relativeName) {
// Use fallback name according settings description
relativeName = 'assets'
} else if (path.isAbsolute(relativeName)) {
throw new Error('Invalid relative directory name.')
}
// Path combination:
// - markdown file directory + relative directory name or
// - root directory + relative directory name
const absPath = path.resolve(cwd, relativeName)
const dstPath = path.resolve(absPath, path.basename(imagePath))
await fs.ensureDir(absPath)
await fs.move(imagePath, dstPath, { overwrite: true })
// Find relative path between given file and saved image.
const dstRelPath = path.relative(path.dirname(filePath), dstPath)
if (isWindows) {
// Use forward slashes for better compatibility with websites.
return dstRelPath.replace(/\\/g, '/')
}
return dstRelPath
}
export const moveImageToFolder = async (pathname, image, outputDir) => {
await fs.ensureDir(outputDir)
const isPath = typeof image === 'string'
if (isPath) {
const dirname = path.dirname(pathname)
const imagePath = path.resolve(dirname, image)
const isImage = isImageFile(imagePath)
if (isImage) {
const filename = path.basename(imagePath)
const extname = path.extname(imagePath)
const noHashPath = path.join(outputDir, filename)
// If the file doesn't need to be moved, return it
if (noHashPath === imagePath) {
return imagePath
}
let targetPath = noHashPath
if (fs.existsSync(targetPath)) {
// If a file already exists in the target location, use the image's content hash to avoid
// name conflict.
const hash = getContentHash(imagePath)
targetPath = path.join(outputDir, `${hash}${extname}`)
}
await fs.copy(imagePath, targetPath)
return targetPath
} else {
return Promise.resolve(image)
}
} else {
const imagePath = path.join(outputDir, `${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-${image.name}`)
const binaryString = await new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = () => {
resolve(fileReader.result)
}
fileReader.readAsBinaryString(image)
})
await fs.writeFile(imagePath, binaryString, 'binary')
return imagePath
}
}
/**
* @jocs todo, rewrite it use class
*/
export const uploadImage = async (pathname, image, preferences) => {
const { currentUploader, imageBed, githubToken: auth, cliScript } = preferences
const { owner, repo, branch } = imageBed.github
const isPath = typeof image === 'string'
const MAX_SIZE = 5 * 1024 * 1024
let re
let rj
const promise = new Promise((resolve, reject) => {
re = resolve
rj = reject
})
if (currentUploader === 'none') {
rj('No image uploader provided.')
}
const uploadByGithub = (content, filename) => {
const octokit = new Octokit({
auth
})
const path = dayjs().format('YYYY/MM') + `/${dayjs().format('DD-HH-mm-ss')}-${filename}`
const message = `Upload by MarkText at ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`
const payload = {
owner,
repo,
path,
branch,
message,
content
}
if (!branch) {
delete payload.branch
}
octokit.repos.createOrUpdateFileContents(payload)
.then(result => {
re(result.data.content.download_url)
})
.catch(_ => {
rj('Upload failed, the image will be copied to the image folder')
})
}
const uploadByCommand = async (uploader, filepath) => {
let isPath = true
if (typeof filepath !== 'string') {
isPath = false
const data = new Uint8Array(filepath)
filepath = path.join(tmpdir(), +new Date())
await fs.writeFile(filepath, data)
}
if (uploader === 'picgo') {
cp.exec(`picgo u "${filepath}"`, async (err, data) => {
if (!isPath) {
await fs.unlink(filepath)
}
if (err) {
return rj(err)
}
const parts = data.split('[PicGo SUCCESS]:')
if (parts.length === 2) {
re(parts[1].trim())
} else {
rj('PicGo upload error')
}
})
} else {
cp.execFile(cliScript, [filepath], async (err, data) => {
if (!isPath) {
await fs.unlink(filepath)
}
if (err) {
return rj(err)
}
re(data.trim())
})
}
}
const notification = () => {
rj('Cannot upload more than 5M image, the image will be copied to the image folder')
}
if (isPath) {
const dirname = path.dirname(pathname)
const imagePath = path.resolve(dirname, image)
const isImage = isImageFile(imagePath)
if (isImage) {
const { size } = await fs.stat(imagePath)
if (size > MAX_SIZE) {
notification()
} else {
switch (currentUploader) {
case 'cliScript':
case 'picgo':
uploadByCommand(currentUploader, imagePath)
break
case 'github': {
const imageFile = await fs.readFile(imagePath)
const base64 = Buffer.from(imageFile).toString('base64')
uploadByGithub(base64, path.basename(imagePath))
break
}
}
}
} else {
re(image)
}
} else {
const { size } = image
if (size > MAX_SIZE) {
notification()
} else {
const reader = new FileReader()
reader.onload = async () => {
switch (currentUploader) {
case 'picgo':
case 'cliScript':
uploadByCommand(currentUploader, reader.result)
break
default:
uploadByGithub(reader.result, image.name)
}
}
const readerFunction = currentUploader !== 'github' ? 'readAsArrayBuffer' : 'readAsDataURL'
reader[readerFunction](image)
}
}
return promise
}
export const isFileExecutableSync = (filepath) => {
try {
const stat = statSync(filepath)
return stat.isFile() && (stat.mode & (constants.S_IXUSR | constants.S_IXGRP | constants.S_IXOTH)) !== 0
} catch (err) {
// err ignored
return false
}
}