bibcli
Version:
CLI tool for scavaging patterns in different translations of God's Holy Word.
193 lines (154 loc) • 8.77 kB
JavaScript
import { Command } from 'commander'
import {promises as fs_promises} from 'fs'
import chalk from 'chalk'
import ora from 'ora'
import path from 'path'
import {get_filename_from_translation} from './helpers.js'
import package_info from './package.json' with { type: 'json' };
const program = new Command()
program
.name('bibcli')
.description("CLI tool for scavaging patterns in different translations of God's Holy Word.")
.version(package_info.version)
.option('-d, --debug', 'output extra debugging')
.option('-l, --lang <string>', 'language', 'en')
.option('-p, --phrase <string>', 'phrase to search for')
.option('-t, --translation <string>', 'translation filter', '')
program.parse(process.argv)
const options = program.opts()
const caseSensitive = false
const phrase_value = options.phrase
const lang_value = 'en' // TODO: add support for options.lang
const translation_filter = options.translation
const translation_file_name = get_filename_from_translation(translation_filter)
const dir_name = './data/' + lang_value
const spinner = ora(chalk.cyan(`searching '${dir_name}' for '${phrase_value}'...`)).start()
try {
// Check if the directory exists
const stats = await fs_promises.stat(dir_name);
if (!stats.isDirectory()) {
spinner.fail(chalk.red(`Error: '${dir_name}' is not a directory.`));
process.exit(1);
}
let totalMatchesFound = 0;
const filesWithMatches = new Set(); // To store unique file paths that contain matches
// Use a Map to group matches by file path
const groupedMatches = new Map(); // Map<filePath, Array<MatchDetails>>
const files = await fs_promises.readdir(dir_name);
spinner.text = chalk.cyan(`Scanning ${files.length} items in '${dir_name}'...`);
for (const file of files) {
if (translation_file_name !== '' && translation_file_name !== file) continue;
const filePath = path.join(dir_name, file);
let fileStats;
try {
fileStats = await fs_promises.stat(filePath);
} catch (err) {
// Skip if we can't stat the file (e.g., permission error, broken symlink)
spinner.warn(chalk.yellow(`Skipping '${filePath}': Could not access. (${err.message})`));
spinner.start(chalk.cyan(`Scanning items in '${dir_name}'...`)); // Restart spinner
continue;
}
if (fileStats.isFile()) {
let content;
try {
content = await fs_promises.readFile(filePath, 'utf8');
} catch (err) {
spinner.warn(chalk.yellow(`Skipping '${filePath}': Could not read file content. (${err.message})`));
spinner.start(chalk.cyan(`Scanning items in '${dir_name}'...`)); // Restart spinner
continue;
}
const searchTarget = caseSensitive ? phrase_value : phrase_value.toLowerCase();
const contentLines = content.split(/\r?\n/);
contentLines.forEach((line, index) => {
const lineToSearch = caseSensitive ? line : line.toLowerCase();
let currentMatchIndexInLowerCase = -1;
let offset = 0;
// Find all occurrences of the search target within the current line
while ((currentMatchIndexInLowerCase = lineToSearch.indexOf(searchTarget, offset)) !== -1) {
const matchDetail = {
lineNumber: index + 1,
lineContent: line, // Store original line content
matchStartIndex: currentMatchIndexInLowerCase, // Start index of match in the original line (assuming only case changes)
searchText: phrase_value // The actual text searched for (original case)
};
// Add to groupedMatches
if (!groupedMatches.has(filePath)) {
groupedMatches.set(filePath, []);
}
groupedMatches.get(filePath).push(matchDetail);
totalMatchesFound++; // Increment total matches found globally
filesWithMatches.add(filePath); // Add file path to the set of files with matches
offset = currentMatchIndexInLowerCase + searchTarget.length;
}
});
} else if (fileStats.isDirectory()) {
spinner.info(chalk.blue(`Skipping directory: '${filePath}' (non-recursive search).`));
spinner.start(chalk.cyan(`Scanning items in '${dir_name}'...`)); // Restart spinner
}
}
spinner.stop(); // Stop the scanning spinner
if (totalMatchesFound > 0) {
console.log(chalk.green.bold(`\n--- Search Results for '${phrase_value}' in '${dir_name}' ---`));
// Get sorted file paths
const sortedFilePaths = Array.from(groupedMatches.keys()).sort();
sortedFilePaths.forEach(filePath => {
console.log(chalk.cyan.bold(`\nFile: ${filePath}`));
const matchesInFile = groupedMatches.get(filePath);
// Sort matches within the file by line number
matchesInFile.sort((a, b) => a.lineNumber - b.lineNumber);
matchesInFile.forEach(match => {
const originalLine = match.lineContent;
const matchStartCharIndex = match.matchStartIndex;
const searchText = match.searchText;
// Part 1: First 6 characters of the line
const first6Chars = originalLine.length > 6 ? originalLine.substring(0, 6)?.toUpperCase() : originalLine;
// Part 2: Context snippet with highlighting (approx. 3 words around the match)
const contextWindowChars = 50; // Characters before and after the match for context
let contextStart = Math.max(0, matchStartCharIndex - contextWindowChars);
let contextEnd = Math.min(originalLine.length, matchStartCharIndex + searchText.length + contextWindowChars);
// Attempt to adjust contextStart to the beginning of a word
let tempStart = contextStart;
while (tempStart > 0 && !/\s/.test(originalLine[tempStart - 1]) && (matchStartCharIndex - tempStart) < contextWindowChars) {
tempStart--;
}
contextStart = tempStart;
// Attempt to adjust contextEnd to the end of a word
let tempEnd = contextEnd;
while (tempEnd < originalLine.length && !/\s/.test(originalLine[tempEnd]) && (tempEnd - (matchStartCharIndex + searchText.length)) < contextWindowChars) {
tempEnd++;
}
contextEnd = tempEnd;
let contextString = originalLine.substring(contextStart, contextEnd);
// Highlight the search text within the context string
// Find the index of the search text within the *contextString* (case-insensitive search for index)
const highlightStartIndex = contextString.toLowerCase().indexOf(searchText.toLowerCase());
if (highlightStartIndex !== -1) {
const beforeMatch = contextString.substring(0, highlightStartIndex);
const matchedText = contextString.substring(highlightStartIndex, highlightStartIndex + searchText.length);
const afterMatch = contextString.substring(highlightStartIndex + searchText.length);
contextString = `${beforeMatch}${chalk.red.bold(matchedText)}${afterMatch}`;
}
// Add ellipses if the context string doesn't start/end at the original line's boundaries
if (contextStart > 0) {
contextString = "..." + contextString;
}
if (contextEnd < originalLine.length) {
contextString = contextString + "...";
}
const snippet = `[${first6Chars}] Context: "${contextString}"`;
console.log(chalk.green(` ${snippet}`));
});
});
console.log(chalk.green.bold(`\nSearch complete! Found ${totalMatchesFound} total match(es) in ${filesWithMatches.size} file(s).`));
} else {
console.log(chalk.yellow.bold(`\nSearch complete. No matches found for '${phrase_value}' in ${filesWithMatches.size} file(s) within '${dir_name}'.`));
}
} catch (error) {
if (error.code === 'ENOENT') {
spinner.fail(chalk.red(`Error: Directory not found at '${dir_name}'.`));
} else {
spinner.fail(chalk.red(`An unexpected error occurred: ${error.message}`));
}
process.exit(1);
}