markmv
Version:
TypeScript CLI for markdown file operations with intelligent link refactoring
251 lines • 9.13 kB
JavaScript
import { constants } from 'node:fs';
import { access, copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { PathUtils } from './path-utils.js';
/**
* Utility class for common file system operations.
*
* Provides a comprehensive set of static methods for file and directory manipulation, with proper
* error handling and cross-platform compatibility. All methods are async and use Node.js
* promises-based file system APIs.
*
* @category Utilities
*
* @example
* Basic file operations
* ```typescript
* // Check if file exists
* const exists = await FileUtils.exists('document.md');
*
* // Read file content
* const content = await FileUtils.readTextFile('document.md');
*
* // Write new content
* await FileUtils.writeTextFile('output.md', content, {
* createDirectories: true
* });
*
* // Find markdown files
* const files = await FileUtils.findMarkdownFiles('./docs', true);
* ```
*/
export class FileUtils {
/** Check if a file or directory exists */
static async exists(path) {
try {
await access(path, constants.F_OK);
return true;
}
catch {
return false;
}
}
/** Check if a path is readable */
static async isReadable(path) {
try {
await access(path, constants.R_OK);
return true;
}
catch {
return false;
}
}
/** Check if a path is writable */
static async isWritable(path) {
try {
await access(path, constants.W_OK);
return true;
}
catch {
return false;
}
}
/** Get file statistics */
static async getStats(path) {
const stats = await stat(path);
return {
path,
size: stats.size,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
modified: stats.mtime,
created: stats.birthtime,
};
}
/** Ensure directory exists, creating it if necessary */
static async ensureDirectory(dirPath) {
try {
await mkdir(dirPath, { recursive: true });
}
catch (error) {
// Ignore error if directory already exists
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) {
throw error;
}
}
}
/** Safely read a file with encoding detection */
static async readTextFile(filePath) {
const buffer = await readFile(filePath);
// Simple encoding detection - assume UTF-8 for now
// Could be enhanced with proper encoding detection library
return buffer.toString('utf-8');
}
/** Safely write a file with directory creation */
static async writeTextFile(filePath, content, options = {}) {
if (options.createDirectories) {
await FileUtils.ensureDirectory(dirname(filePath));
}
await writeFile(filePath, content, 'utf-8');
}
/** Copy a file with options */
static async copyFile(sourcePath, destinationPath, options = {}) {
const { overwrite = false, createDirectories = true } = options;
// Check if destination exists
if (!overwrite && (await FileUtils.exists(destinationPath))) {
throw new Error(`Destination file already exists: ${destinationPath}`);
}
// Create destination directory if needed
if (createDirectories) {
await FileUtils.ensureDirectory(dirname(destinationPath));
}
// Copy the file
await copyFile(sourcePath, destinationPath);
// TODO: Preserve timestamps if requested
if (options.preserveTimestamps) {
const sourceStats = await stat(sourcePath);
const { utimes } = await import('node:fs/promises');
await utimes(destinationPath, sourceStats.atime, sourceStats.mtime);
}
}
/** Move a file with options */
static async moveFile(sourcePath, destinationPath, options = {}) {
const { overwrite = false, createDirectories = true, backup = false } = options;
// Validate paths
const sourceValidation = PathUtils.validatePath(sourcePath);
if (!sourceValidation.valid) {
throw new Error(`Invalid source path: ${sourceValidation.reason}`);
}
const destValidation = PathUtils.validatePath(destinationPath);
if (!destValidation.valid) {
throw new Error(`Invalid destination path: ${destValidation.reason}`);
}
// Check if source exists
if (!(await FileUtils.exists(sourcePath))) {
throw new Error(`Source file does not exist: ${sourcePath}`);
}
// Handle destination conflicts
if (await FileUtils.exists(destinationPath)) {
if (!overwrite) {
throw new Error(`Destination file already exists: ${destinationPath}`);
}
if (backup) {
const backupPath = `${destinationPath}.backup`;
await FileUtils.copyFile(destinationPath, backupPath);
}
}
// Create destination directory if needed
if (createDirectories) {
await FileUtils.ensureDirectory(dirname(destinationPath));
}
// Try atomic rename first (works if on same filesystem)
try {
await rename(sourcePath, destinationPath);
}
catch (error) {
// If rename fails, fall back to copy + delete
if (error && typeof error === 'object' && 'code' in error && error.code === 'EXDEV') {
await FileUtils.copyFile(sourcePath, destinationPath, { overwrite: true });
await unlink(sourcePath);
}
else {
throw error;
}
}
}
/** Delete a file safely */
static async deleteFile(filePath) {
if (await FileUtils.exists(filePath)) {
await unlink(filePath);
}
}
/** List files in a directory with filtering */
static async listFiles(dirPath, options = {}) {
const { recursive = false, extensions, includeDirectories = false } = options;
const files = [];
const processDirectory = async (currentDir) => {
const entries = await readdir(currentDir);
for (const entry of entries) {
const fullPath = join(currentDir, entry);
const stats = await FileUtils.getStats(fullPath);
if (stats.isDirectory) {
if (includeDirectories) {
files.push(fullPath);
}
if (recursive) {
await processDirectory(fullPath);
}
}
else if (stats.isFile) {
// Filter by extensions if specified
if (extensions) {
const ext = PathUtils.getExtension(fullPath).toLowerCase();
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
else {
files.push(fullPath);
}
}
}
};
await processDirectory(dirPath);
return files;
}
/** Find markdown files in a directory */
static async findMarkdownFiles(dirPath, recursive = true) {
return FileUtils.listFiles(dirPath, {
recursive,
extensions: ['.md', '.markdown', '.mdown', '.mkd', '.mdx'],
});
}
/** Create a backup of a file */
static async createBackup(filePath, suffix = '.backup') {
const backupPath = `${filePath}${suffix}`;
await FileUtils.copyFile(filePath, backupPath);
return backupPath;
}
/** Get file size in bytes */
static async getFileSize(filePath) {
const stats = await FileUtils.getStats(filePath);
return stats.size;
}
/** Check if two files have the same content */
static async filesEqual(path1, path2) {
try {
const [content1, content2] = await Promise.all([
FileUtils.readTextFile(path1),
FileUtils.readTextFile(path2),
]);
return content1 === content2;
}
catch {
return false;
}
}
/** Generate a safe filename by removing invalid characters */
static sanitizeFilename(filename) {
// Remove or replace invalid characters
return filename
.replace(/[<>:"/\\|?*]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** Get relative path between two files */
static getRelativePath(fromFile, toFile) {
return PathUtils.makeRelative(toFile, dirname(fromFile));
}
}
//# sourceMappingURL=file-utils.js.map