isbn-bisac-tools
Version:
A toolkit for working with BISAC subject headings and ISBN lookups
936 lines ⢠41.1 kB
JavaScript
/**
* Utility functions for the BISAC scraper
*/
import { promises as fs } from 'fs';
import * as fsSync from 'fs';
import * as path from 'path';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import { glob } from 'glob';
const execPromise = promisify(exec);
/**
* Initialize the necessary directories
* @param outputDir - The directory to store output files
* @param screenshotsDir - The directory to store screenshots
* @param takeScreenshots - Whether to initialize the screenshots directory
*/
export async function initialize(outputDir, screenshotsDir, takeScreenshots = false) {
await fs.mkdir(outputDir, { recursive: true });
if (takeScreenshots) {
await fs.mkdir(screenshotsDir, { recursive: true });
console.log('š Output and screenshots directories initialized.');
}
else {
console.log('š Output directory initialized.');
}
}
/**
* Take a screenshot
* @param page - Puppeteer page object
* @param name - Base name for the screenshot
* @param screenshotsDir - Directory to save screenshots
*/
export async function takeScreenshot(page, name, screenshotsDir) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${name}-${timestamp}.png`;
const filepath = path.join(screenshotsDir, filename);
await page.screenshot({ path: filepath, fullPage: true });
console.log(`šø Screenshot saved: ${filename}`);
}
/**
* Save data to JSON file
* @param filePath - Path to save the JSON file
* @param data - Data to save
*/
export async function saveToJSON(filePath, data) {
// If this is BISAC category data, format it with metadata and use fixed filename
if (Array.isArray(data) && data.length > 0 && 'subjects' in data[0]) {
// Get the current date in YYYY-MM-DD format
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
// Create the fixed output path
const outputDir = path.dirname(filePath);
const fixedFilePath = path.join(outputDir, 'bisac-data.json');
// Create the data structure with metadata
const bisacData = {
timestamp: Date.now(),
date: dateStr,
categories: data,
};
await fs.writeFile(fixedFilePath, JSON.stringify(bisacData, null, 2));
console.log(`š¾ BISAC data saved to: ${fixedFilePath}`);
return;
}
// For other types of data, maintain the original behavior
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
console.log(`š¾ Data saved to: ${filePath}`);
}
/**
* Generate a random delay between min and max with visual countdown
* @param min - Minimum delay in ms
* @param max - Maximum delay in ms
* @returns A Promise that resolves after the delay
*/
export function randomDelay(min, max) {
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
return new Promise(resolve => {
// Show a visual countdown
const intervalTime = 250; // Update every 250ms
let remainingTime = delay;
const timerEmojis = ['ā°', 'ā', 'ā±ļø', 'ā³'];
let emojiIndex = 0;
console.log(`\n${timerEmojis[emojiIndex]} Starting countdown for ${delay}ms delay...`);
const interval = setInterval(() => {
remainingTime -= intervalTime;
emojiIndex = (emojiIndex + 1) % timerEmojis.length;
// Only log every second to avoid flooding the console
if (remainingTime % 1000 === 0 || remainingTime <= 0) {
const secondsLeft = Math.ceil(remainingTime / 1000);
process.stdout.write(`\r${timerEmojis[emojiIndex]} ${secondsLeft} seconds remaining...${' '.repeat(20)}`);
}
if (remainingTime <= 0) {
clearInterval(interval);
process.stdout.write('\n');
resolve(delay);
}
}, intervalTime);
});
}
/**
* Get the path to the latest JSON file in the output directory
* @param outputDir - The directory containing BISAC JSON files (default: ./output)
* @returns The full path to the latest JSON file, or undefined if none found
*/
/**
* Runs the BISAC scraper to generate a new JSON file
* @returns A promise that resolves when the scraper completes
*/
export async function runBisacScraper() {
console.log('š No existing BISAC data files found. Running the scraper...');
return new Promise(resolve => {
console.log('š Attempting to run the BISAC scraper...');
// Use npm to run the scraper script
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
try {
// Use spawn to allow stdio inheritance
const scraperProcess = spawn(npmExecutable, ['run', 'start'], {
cwd: process.cwd(),
stdio: 'inherit',
shell: true, // Use shell to properly handle npm commands
});
scraperProcess.on('close', code => {
if (code === 0) {
console.log('ā
Scraper completed successfully');
resolve(true);
}
else {
console.error(`ā Scraper failed with code ${code}`);
resolve(false);
}
});
scraperProcess.on('error', error => {
console.error(`ā Scraper process error: ${error.message}`);
resolve(false);
});
}
catch (error) {
console.error(`ā Failed to run the scraper: ${error.message}`);
resolve(false);
}
});
}
/**
* Check if a JSON file with today's date already exists
* @param outputDir - The directory containing BISAC JSON files (default: ./output)
* @returns The full path to today's JSON file if it exists, or undefined if not found
*/
export async function checkExistingJsonFileForToday(outputDir = path.join(process.cwd(), 'output')) {
try {
// Use fixed filename instead of date-based naming
const filename = 'bisac-data.json';
const filePath = path.join(outputDir, filename);
// Check if the file exists
try {
await fs.access(filePath);
return filePath; // File exists
}
catch {
return undefined; // File doesn't exist
}
}
catch (error) {
console.error(`ā Error checking for today's JSON file: ${error.message}`);
return undefined;
}
}
export async function getLatestJsonFilePath(outputDir = path.join(process.cwd(), 'output')) {
try {
// Create the output directory if it doesn't exist
await fs.mkdir(outputDir, { recursive: true });
// Check for the fixed filename
const filePath = path.join(outputDir, 'bisac-data.json');
try {
await fs.access(filePath);
console.log(`š Found BISAC data file: bisac-data.json`);
return filePath;
}
catch (err) {
// Handle the case where no BISAC data file exists
console.warn('ā ļø No BISAC data file found in the output directory');
}
// If no files found, run the scraper
console.log('š Running the BISAC scraper to generate data...');
const scraperSuccess = await runBisacScraper();
if (scraperSuccess) {
// Check if the file now exists
try {
await fs.access(filePath);
console.log(`š BISAC data file generated successfully`);
return filePath;
}
catch (err) {
console.error('ā Failed to find BISAC data file after running the scraper');
return undefined;
}
}
else {
console.error('ā Failed to run the BISAC scraper');
return undefined;
}
}
catch (error) {
console.error(`ā Error finding latest JSON file: ${error.message}`);
return undefined;
}
}
/**
* Load BISAC data from JSON file
* @param filePath - Path to the JSON file (if undefined, uses latest file)
* @returns Array of Category objects
*/
export async function loadBisacData(filePath) {
try {
// If no file path provided, get the latest one
let resolvedPath = filePath;
if (!resolvedPath) {
try {
resolvedPath = await getLatestJsonFilePath();
}
catch (pathError) {
// Try to find the data file in the module directory
try {
const moduleDir = new URL('.', import.meta.url).pathname;
const dataPath = path.resolve(moduleDir, '..', '..', 'data', 'bisac-data.json');
if (fsSync.existsSync(dataPath)) {
resolvedPath = dataPath;
console.log(`š Using bundled BISAC data file: ${dataPath}`);
}
}
catch (modulePathError) {
console.error(`ā ļø Could not locate module path: ${modulePathError.message}`);
}
// If still not found, check current working directory
if (!resolvedPath) {
const cwdDataPath = path.join(process.cwd(), 'data', 'bisac-data.json');
if (fsSync.existsSync(cwdDataPath)) {
resolvedPath = cwdDataPath;
console.log(`š Using BISAC data file from current directory: ${cwdDataPath}`);
}
}
}
}
if (!resolvedPath) {
throw new Error('No BISAC data file found. Try running with --scrape to generate the data first.');
}
const data = await fs.readFile(resolvedPath, 'utf-8');
const jsonData = JSON.parse(data);
// Check if this is the new format (with timestamp and categories)
if (jsonData.categories && Array.isArray(jsonData.categories)) {
console.log(`š
Loaded BISAC data from ${jsonData.date} (timestamp: ${jsonData.timestamp})`);
return jsonData.categories;
}
// Legacy format (array of categories directly)
return jsonData;
}
catch (error) {
console.error(`ā Error loading BISAC data: ${error.message}`);
return [];
}
}
/**
* Get full label for a subject code
* @param code - BISAC subject code (e.g., ANT007000)
* @param dataFilePath - Path to the BISAC data JSON file (if undefined, uses latest file)
* @returns The full label or undefined if not found
*/
export async function getFullLabelFromCode(code, dataFilePath) {
const categories = await loadBisacData(dataFilePath);
for (const category of categories) {
const subject = category.subjects.find(s => s.code === code);
if (subject) {
// If the subject label already includes the category heading, return it directly
if (subject.label.startsWith(category.heading + ' / ')) {
return subject.label;
}
return `${category.heading} / ${subject.label}`;
}
}
console.log(`š No label found for code: ${code}`);
console.log(`ā¹ļø Note: This code may exist in the complete BISAC dataset but is not available in the current data.`);
console.log(`ā¹ļø If you used --url to fetch a specific category, try using a different category URL or fetch the full dataset.`);
return undefined;
}
/**
* Get all codes and full labels for a category heading
* @param heading - BISAC category heading (e.g., "ANTIQUES & COLLECTIBLES")
* @param dataFilePath - Path to the BISAC data JSON file (if undefined, uses latest file)
* @returns Array of code and full label pairs
*/
export async function getCodesForHeading(heading, dataFilePath) {
const categories = await loadBisacData(dataFilePath);
// Normalize the input heading for comparison
const normalizedHeading = heading.toUpperCase().trim();
const category = categories.find(c => {
const categoryHeading = c.heading.toUpperCase().trim();
return (categoryHeading === normalizedHeading ||
categoryHeading.replace('&', 'AND') === normalizedHeading.replace('&', 'AND'));
});
if (!category) {
console.log(`š No category found with heading: ${heading}`);
return [];
}
return category.subjects.map(subject => ({
code: subject.code,
fullLabel: subject.label.startsWith(category.heading + ' / ')
? subject.label
: `${category.heading} / ${subject.label}`,
}));
}
/**
* Get code from a full label
* @param fullLabel - Full BISAC label (e.g., "ANTIQUES & COLLECTIBLES / Buttons & Pins")
* @param dataFilePath - Path to the BISAC data JSON file (if undefined, uses latest file)
* @returns The code or undefined if not found
*/
export async function getCodeFromFullLabel(fullLabel, dataFilePath) {
const categories = await loadBisacData(dataFilePath);
// Extract the heading - it's the first part before " / "
const firstSeparatorIndex = fullLabel.indexOf(' / ');
if (firstSeparatorIndex === -1) {
console.log(`ā Invalid full label format: ${fullLabel}`);
console.log('Full label must be in format "HEADING / SUBJECT"');
return undefined;
}
const heading = fullLabel.substring(0, firstSeparatorIndex);
// The subject label is everything after the heading and the first separator
const subjectLabel = fullLabel.substring(firstSeparatorIndex + 3);
if (!heading || !subjectLabel) {
console.log(`ā Invalid full label format: ${fullLabel}`);
console.log('Full label must be in format "HEADING / SUBJECT"');
return undefined;
}
// Normalize the input heading for comparison
const normalizedHeading = heading.toUpperCase().trim();
const category = categories.find(c => {
const categoryHeading = c.heading.toUpperCase().trim();
return (categoryHeading === normalizedHeading ||
categoryHeading.replace('&', 'AND') === normalizedHeading.replace('&', 'AND'));
});
if (!category) {
console.log(`š No category found with heading: ${heading}`);
return undefined;
}
const subject = category.subjects.find(s => {
// For exact match with the full label
if (s.label.toUpperCase().trim() === fullLabel.toUpperCase().trim()) {
return true;
}
// The stored label might be in format "CATEGORY / SUBJECT"
const labelParts = s.label.split(' / ');
const subjectPart = labelParts.length > 1 ? labelParts[1].trim() : s.label.trim();
// Check if the subject portion matches
return subjectPart.toUpperCase() === subjectLabel.toUpperCase().trim();
});
if (!subject) {
console.log(`š No subject found with label: ${subjectLabel} in category: ${heading}`);
return undefined;
}
return subject.code;
}
function getBestBisacCategory(categories, book) {
if (categories.length === 0)
return undefined;
if (categories.length === 1)
return categories[0];
// Initialize category scores
const categoryScores = categories.map(category => ({
category,
score: 0,
}));
// Get book description and Google's category
const description = book.volumeInfo?.description || '';
const googleCategories = book.volumeInfo?.categories || [];
// Weight 1: Check if category appears in Google's categories
for (const { category, score: _score } of categoryScores) {
const fullLabelLower = category.fullLabel.toLowerCase();
for (const googleCategory of googleCategories) {
if (googleCategory.toLowerCase().includes(fullLabelLower)) {
const scoreItem = categoryScores.find(c => c.category === category);
if (scoreItem)
scoreItem.score += 5;
}
}
}
// Weight 2: Check for category mentions in book description
for (const { category, score: _score } of categoryScores) {
const fullLabelParts = category.fullLabel.toLowerCase().split(' / ');
const mainCategory = fullLabelParts[0];
const subCategory = fullLabelParts[1] || '';
// Main category is in description
if (description.toLowerCase().includes(mainCategory.toLowerCase())) {
const scoreItem = categoryScores.find(c => c.category === category);
if (scoreItem)
scoreItem.score += 2;
}
// Subcategory is in description
if (subCategory && description.toLowerCase().includes(subCategory.toLowerCase())) {
const scoreItem = categoryScores.find(c => c.category === category);
if (scoreItem)
scoreItem.score += 3;
}
}
// Weight 3: Special category recognition for comics, graphic novels, etc.
if (description.toLowerCase().includes('comic') ||
description.toLowerCase().includes('marvel') ||
description.toLowerCase().includes('superhero') ||
description.toLowerCase().includes('graphic novel')) {
for (const { category } of categoryScores) {
if (category.fullLabel.toLowerCase().includes('comics') ||
category.fullLabel.toLowerCase().includes('graphic novel')) {
const scoreItem = categoryScores.find(c => c.category === category);
if (scoreItem)
scoreItem.score += 8;
}
}
}
// Find category with highest score
categoryScores.sort((a, b) => b.score - a.score);
// Return the highest scoring category
return categoryScores[0].category;
}
export async function getCodeFromISBN(isbn, dataFilePath) {
// Clean the ISBN (remove hyphens and spaces)
const cleanIsbn = isbn.replace(/[-\s]/g, '');
if (!/^(\d{10}|\d{13})$/.test(cleanIsbn)) {
console.log(`ā Invalid ISBN format: ${isbn}`);
console.log('ISBN must be 10 or 13 digits (hyphens optional)');
return { title: 'Invalid ISBN', categories: [] };
}
try {
// Use Google Books API to get book information from ISBN
const response = await fetch(`https://www.googleapis.com/books/v1/volumes?q=isbn:${cleanIsbn}`);
if (!response.ok) {
throw new Error(`Google Books API returned status ${response.status}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
console.log(`š No book found with ISBN: ${cleanIsbn}`);
return { title: 'Book Not Found', categories: [] };
}
const book = data.items[0];
// Get book title
const title = book.volumeInfo?.title || 'Unknown Title';
// Extract BISAC categories from industry identifiers
const categories = [];
// Check if the book has BISAC categories in the industryIdentifiers
if (book.volumeInfo && book.volumeInfo.industryIdentifiers) {
const bisacIdentifiers = book.volumeInfo.industryIdentifiers.filter((id) => id.type === 'BISAC');
for (const id of bisacIdentifiers) {
const code = id.identifier;
const fullLabel = (await getFullLabelFromCode(code, dataFilePath)) || 'Unknown BISAC category';
categories.push({ code, fullLabel });
}
}
// If no BISAC identifiers found, try to match categories from book categories
if (categories.length === 0 && book.volumeInfo && book.volumeInfo.categories) {
const bisacData = await loadBisacData(dataFilePath);
for (const category of book.volumeInfo.categories) {
// Try to match the category to BISAC categories
for (const bisacCategory of bisacData) {
// Check if the category matches a BISAC heading
if (category.toUpperCase().includes(bisacCategory.heading.toUpperCase())) {
// Return all subjects under this category
for (const subject of bisacCategory.subjects) {
categories.push({
code: subject.code,
fullLabel: `${bisacCategory.heading} / ${subject.label}`,
});
}
break;
}
// Check if it matches any subject
for (const subject of bisacCategory.subjects) {
if (category.toUpperCase().includes(subject.label.toUpperCase())) {
categories.push({
code: subject.code,
fullLabel: `${bisacCategory.heading} / ${subject.label}`,
});
break;
}
}
}
}
}
// Find the best category if multiple were found
const bestCategory = getBestBisacCategory(categories, book);
// We don't need to log here as the caller will handle it
return { title, categories, bestCategory };
}
catch (error) {
console.error(`ā Error looking up ISBN: ${error instanceof Error ? error.message : String(error)}`);
return { title: 'Error', categories: [] };
}
}
/**
* Print formatted JSON to console
* Uses jq if available, falls back to JSON.stringify
* @param data - The data to print
* @param title - Optional title to print before the data
*/
export async function printFormattedJSON(data, title) {
if (title) {
console.log(`\n${title}`);
}
console.log('⨠Formatted output:');
try {
// Check if jq is available
await execPromise('which jq');
// Use jq for pretty formatting (with colors)
const jsonString = JSON.stringify(data);
const { stdout } = await execPromise(`echo '${jsonString.replace(/'/g, "'\\''")}' | jq .`);
console.log(stdout);
}
catch (error) {
// jq not available, fall back to built-in formatting
console.log(JSON.stringify(data, null, 2));
}
}
/**
* Compare two BISAC JSON files and identify differences
* @param olderFilePath - Path to the older BISAC JSON file
* @param newerFilePath - Path to the newer BISAC JSON file
* @returns Comparison results showing differences between the files
*/
export async function compareBisacJsonFiles(olderFilePath, newerFilePath) {
try {
// Load both JSON files
const oldData = await loadBisacData(olderFilePath);
const newData = await loadBisacData(newerFilePath);
if (!oldData.length || !newData.length) {
throw new Error('One or both of the JSON files could not be loaded or are empty');
}
// Get file metadata instead of extracting dates from filenames
const getFileDate = async (filePath) => {
try {
const stats = await fs.stat(filePath);
return stats.mtime.toISOString().split('T')[0]; // YYYY-MM-DD format
}
catch (err) {
return 'unknown date';
}
};
const oldDate = await getFileDate(olderFilePath);
const newDate = await getFileDate(newerFilePath);
// Initialize comparison result
const result = {
oldFilePath: olderFilePath,
newFilePath: newerFilePath,
oldDate,
newDate,
summary: {
totalCategoriesOld: oldData.length,
totalCategoriesNew: newData.length,
totalSubjectsOld: oldData.reduce((sum, category) => sum + category.subjects.length, 0),
totalSubjectsNew: newData.reduce((sum, category) => sum + category.subjects.length, 0),
newCategories: 0,
removedCategories: 0,
modifiedCategories: 0,
newSubjects: 0,
removedSubjects: 0,
modifiedSubjects: 0,
},
newCategories: [],
removedCategories: [],
modifiedCategories: [],
};
// Create maps for easier comparisons
const oldCategoriesMap = new Map(oldData.map(category => [category.heading, category]));
const newCategoriesMap = new Map(newData.map(category => [category.heading, category]));
// Find new categories
for (const [heading, category] of newCategoriesMap) {
if (!oldCategoriesMap.has(heading)) {
result.newCategories.push({
heading,
subjectCount: category.subjects.length,
});
result.summary.newCategories++;
result.summary.newSubjects += category.subjects.length;
}
}
// Find removed categories
for (const [heading, category] of oldCategoriesMap) {
if (!newCategoriesMap.has(heading)) {
result.removedCategories.push({
heading,
subjectCount: category.subjects.length,
});
result.summary.removedCategories++;
result.summary.removedSubjects += category.subjects.length;
}
}
// Analyze categories that exist in both files
for (const [heading, oldCategory] of oldCategoriesMap) {
if (newCategoriesMap.has(heading)) {
const newCategory = newCategoriesMap.get(heading);
// Create maps of subjects by code for comparison
const oldSubjectsMap = new Map(oldCategory.subjects.map(subject => [subject.code, subject]));
const newSubjectsMap = new Map(newCategory.subjects.map(subject => [subject.code, subject]));
const categoryChanges = {
heading,
newSubjects: [],
removedSubjects: [],
modifiedSubjects: [],
};
let hasChanges = false;
// Find new subjects
for (const [code, subject] of newSubjectsMap) {
if (!oldSubjectsMap.has(code)) {
categoryChanges.newSubjects.push({
code,
label: subject.label,
});
result.summary.newSubjects++;
hasChanges = true;
}
}
// Find removed subjects
for (const [code, subject] of oldSubjectsMap) {
if (!newSubjectsMap.has(code)) {
categoryChanges.removedSubjects.push({
code,
label: subject.label,
});
result.summary.removedSubjects++;
hasChanges = true;
}
}
// Find modified subjects (same code but different label)
for (const [code, oldSubject] of oldSubjectsMap) {
if (newSubjectsMap.has(code)) {
const newSubject = newSubjectsMap.get(code);
if (oldSubject.label !== newSubject.label) {
categoryChanges.modifiedSubjects.push({
code,
oldLabel: oldSubject.label,
newLabel: newSubject.label,
});
result.summary.modifiedSubjects++;
hasChanges = true;
}
}
}
// Add category to modified list if it has any changes
if (hasChanges) {
result.modifiedCategories.push(categoryChanges);
result.summary.modifiedCategories++;
}
}
}
return result;
}
catch (error) {
console.error(`ā Error comparing BISAC JSON files: ${error.message}`);
throw new Error(`Failed to compare BISAC data files: ${error.message}`);
}
}
/**
* Print a comparison report between two BISAC JSON files
* @param comparison - Comparison result object
*/
export async function printComparisonReport(comparison) {
console.log('\nš BISAC Subject Headings Comparison Report š');
console.log('==============================================');
console.log(`\nš Comparing data from ${comparison.oldDate} to ${comparison.newDate}`);
console.log(`Old file: ${path.basename(comparison.oldFilePath)}`);
console.log(`New file: ${path.basename(comparison.newFilePath)}`);
console.log('\nš Summary:');
console.log(`- Categories: ${comparison.summary.totalCategoriesOld} ā ${comparison.summary.totalCategoriesNew} (${comparison.summary.totalCategoriesNew > comparison.summary.totalCategoriesOld ? '+' : ''}${comparison.summary.totalCategoriesNew - comparison.summary.totalCategoriesOld})`);
console.log(`- Subjects: ${comparison.summary.totalSubjectsOld} ā ${comparison.summary.totalSubjectsNew} (${comparison.summary.totalSubjectsNew > comparison.summary.totalSubjectsOld ? '+' : ''}${comparison.summary.totalSubjectsNew - comparison.summary.totalSubjectsOld})`);
console.log(`- New categories: ${comparison.summary.newCategories}`);
console.log(`- Removed categories: ${comparison.summary.removedCategories}`);
console.log(`- Modified categories: ${comparison.summary.modifiedCategories}`);
console.log(`- New subjects: ${comparison.summary.newSubjects}`);
console.log(`- Removed subjects: ${comparison.summary.removedSubjects}`);
console.log(`- Modified subjects: ${comparison.summary.modifiedSubjects}`);
// Display new categories
if (comparison.newCategories.length > 0) {
console.log('\nš New Categories:');
comparison.newCategories.forEach(category => {
console.log(`- ${category.heading} (${category.subjectCount} subjects)`);
});
}
// Display removed categories
if (comparison.removedCategories.length > 0) {
console.log('\nšļø Removed Categories:');
comparison.removedCategories.forEach(category => {
console.log(`- ${category.heading} (${category.subjectCount} subjects)`);
});
}
// Display modified categories
if (comparison.modifiedCategories.length > 0) {
console.log('\nš Modified Categories:');
comparison.modifiedCategories.forEach(category => {
console.log(`\nš ${category.heading}:`);
if (category.newSubjects.length > 0) {
console.log(' ā New subjects:');
category.newSubjects.forEach(subject => {
console.log(` - ${subject.code}: ${subject.label}`);
});
}
if (category.removedSubjects.length > 0) {
console.log(' ā Removed subjects:');
category.removedSubjects.forEach(subject => {
console.log(` - ${subject.code}: ${subject.label}`);
});
}
if (category.modifiedSubjects.length > 0) {
console.log(' š Modified subjects:');
category.modifiedSubjects.forEach(subject => {
console.log(` - ${subject.code}:`);
console.log(` FROM: ${subject.oldLabel}`);
console.log(` TO: ${subject.newLabel}`);
});
}
});
}
console.log('\nā
End of comparison report');
}
/**
* Select two BISAC JSON files for comparison using an interactive prompt
* @param outputDir - The directory containing BISAC JSON files (default: ./output)
* @returns Object containing paths to the selected files, or undefined if canceled
*/
/**
* Creates a backup of the bisac-data.json file with a timestamp-based filename
* @param outputDir Directory where the bisac-data.json file is located
* @returns Path to the created backup file, or undefined if backup failed
*/
export async function createBackupOfBisacData(outputDir = path.join(process.cwd(), 'output')) {
try {
// Ensure the output directory exists
await fs.mkdir(outputDir, { recursive: true });
// Path to the main data file
const dataFilePath = path.join(outputDir, 'bisac-data.json');
// Check if the file exists
try {
await fs.access(dataFilePath);
}
catch (err) {
console.warn('ā ļø No bisac-data.json file found to back up');
return undefined;
}
// Get current date for the backup filename
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD format
// Create backup filename
const backupFileName = `bisac-data-backup-${dateStr}.json`;
const backupFilePath = path.join(outputDir, backupFileName);
// Check if a backup with this name already exists
try {
await fs.access(backupFilePath);
// If we get here, the file exists, so let's add a timestamp to make it unique
const timestamp = now.toISOString().replace(/[:.]/g, '-');
const uniqueBackupFileName = `bisac-data-backup-${dateStr}-${timestamp}.json`;
const uniqueBackupFilePath = path.join(outputDir, uniqueBackupFileName);
// Copy the file to the unique backup path
await fs.copyFile(dataFilePath, uniqueBackupFilePath);
console.log(`š Created unique backup at: ${uniqueBackupFilePath}`);
return uniqueBackupFilePath;
}
catch (err) {
// File doesn't exist, proceed with normal backup
await fs.copyFile(dataFilePath, backupFilePath);
console.log(`š Created backup at: ${backupFilePath}`);
return backupFilePath;
}
}
catch (error) {
console.error(`ā Error creating backup: ${error.message}`);
return undefined;
}
}
export async function selectFilesForComparison(outputDir = path.join(process.cwd(), 'output')) {
try {
// Ensure the output directory exists
await fs.mkdir(outputDir, { recursive: true });
// Find JSON backup files in the output directory
const files = await glob(`${outputDir}/*.json`);
// Filter out non-BISAC data files if needed
const validFiles = files.filter(file => {
const basename = path.basename(file);
return basename === 'bisac-data.json' || basename.includes('bisac-data-backup');
});
if (validFiles.length < 2) {
console.error('ā Need at least two BISAC JSON files for comparison. Please create backups of your bisac-data.json file before updating.');
return undefined;
}
// Sort files by modification time (newest first) with error handling for tests
let sortedFiles = [];
try {
const fileStats = await Promise.all(validFiles.map(async (file) => {
try {
const stats = await fs.stat(file);
return {
path: file,
mtime: stats.mtime,
};
}
catch (err) {
// Fallback for tests where fs.stat might be mocked incompletely
return {
path: file,
mtime: new Date(), // Use current date as fallback
};
}
}));
sortedFiles = fileStats
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.map(item => item.path);
}
catch (err) {
// Fallback if Promise.all fails - just use the file list unsorted
console.warn('ā ļø Could not sort files by modification time:', err);
sortedFiles = validFiles;
}
// Format choices for display with modification dates
const fileChoices = await Promise.all(sortedFiles.map(async (file) => {
try {
const stats = await fs.stat(file);
const dateStr = stats.mtime.toISOString().split('T')[0];
return {
name: `${path.basename(file)} (${dateStr})`,
value: file,
};
}
catch (err) {
// Fallback for tests
return {
name: path.basename(file),
value: file,
};
}
}));
// Attempt to import inquirer dynamically
const { default: inquirer } = await import('inquirer');
// Prompt for newer file
const { newerFile } = await inquirer.prompt([
{
type: 'list',
name: 'newerFile',
message: 'Select the NEWER file:',
choices: fileChoices,
},
]);
// Filter out the selected file for the second prompt
const olderFileChoices = fileChoices.filter(choice => choice.value !== newerFile);
// Prompt for older file
const { olderFile } = await inquirer.prompt([
{
type: 'list',
name: 'olderFile',
message: 'Select the OLDER file to compare against:',
choices: olderFileChoices,
},
]);
return { olderFile, newerFile };
}
catch (error) {
console.error(`ā Error selecting files for comparison: ${error.message}`);
return undefined;
}
}
/**
* Browse a JSON file using the fx tool
* Allows interactive selection of JSON files from the output directory
*/
export async function browseJsonFile(outputDir = path.join(process.cwd(), 'output')) {
try {
// Ensure the output directory exists
await fs.mkdir(outputDir, { recursive: true });
// Find all JSON files
const files = await glob(`${outputDir}/*.json`);
if (files.length === 0) {
console.error('ā No JSON files found in the output directory');
return false;
}
// Get file stats for modification time sorting
const fileStats = await Promise.all(files.map(async (filePath) => {
const stats = await fs.stat(filePath);
return {
path: filePath,
mtime: stats.mtime,
};
}));
// Sort files by modification time (newest first)
const sortedFiles = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// Format choices for display
const fileChoices = sortedFiles.map(file => {
return {
name: `${path.basename(file.path)} (${file.mtime.toLocaleDateString()} ${file.mtime.toLocaleTimeString()})`,
value: file.path,
};
});
// Attempt to import inquirer dynamically
const { default: inquirer } = await import('inquirer');
// Prompt for file selection
const { selectedFile } = await inquirer.prompt([
{
type: 'list',
name: 'selectedFile',
message: 'Select a JSON file to browse:',
choices: fileChoices,
pageSize: 15,
},
]);
console.log(`š Opening ${path.basename(selectedFile)} with fx...`);
// Use child_process to open fx with the selected file
const { spawn } = await import('child_process');
const fxProcess = spawn('npx', ['fx'], {
stdio: ['pipe', 'inherit', 'inherit'],
cwd: process.cwd(),
});
// Read the file and pipe to fx
const fileContent = await fs.readFile(selectedFile, 'utf8');
fxProcess.stdin?.write(fileContent);
fxProcess.stdin?.end();
return new Promise(resolve => {
fxProcess.on('exit', code => {
if (code === 0) {
resolve(true);
}
else {
console.error(`ā fx exited with code ${code}`);
resolve(false);
}
});
});
}
catch (error) {
console.error(`ā Error browsing JSON file: ${error.message}`);
return false;
}
}
//# sourceMappingURL=utils.js.map