react-native-beautiful-logs
Version:
A beautiful, feature-rich logging library for React Native applications with colored output and file persistence
853 lines • 47.9 kB
JavaScript
/**
* @fileoverview Manages file system operations for logging. This includes
* initializing the log directory and file for a session, writing log entries,
* cleaning up old log files based on configured rules, and providing an
* interface (`loggerInterface`) to access and manage these files.
* @category File Management
*/
import { Platform } from 'react-native';
import ReactNativeBlobUtil from 'react-native-blob-util';
import moment from 'moment';
import { Buffer } from 'buffer'; // Import Buffer explicitly for base64 fallback logic
import { DEFAULT_LOG_DIR_BASE, FALLBACK_DIRS, LOGS_SUBDIR, LOG_FILE_PREFIX, LOG_FILE_SUFFIX, MAX_LOG_FILES, MAX_LOG_SIZE_MB, MAX_LOG_AGE_DAYS, LOG_FILE_ENCODING, LOG_FILE_ENCODING_FALLBACK, } from './constants';
import { generateLogFilename } from './utils'; // Correct import
// --- Module State ---
/** @internal Tracks if the logging session is currently initialized (directory and file path confirmed). */
let isSessionInitialized = false;
/** @internal The full, absolute path to the directory where log files are stored (e.g., `/path/to/cache/logs`). Null if not initialized. */
let logDirectoryPath = null;
/** @internal The full, absolute path to the current log file being written to (e.g., `/path/to/cache/logs/session_YYYY-MM-DD.txt`). Null if not initialized. */
let currentSessionLogPath = null;
/** @internal A simple lock flag to prevent concurrent execution of `initSessionLog`. */
let initializationInProgress = false;
/** @internal Stores the preferred base directory path. Defaults to DEFAULT_LOG_DIR_BASE but could be overridden by future configuration mechanisms. */
const preferredLogDirBase = DEFAULT_LOG_DIR_BASE;
// --- Helper Functions ---
/**
* Attempts to create the log subdirectory (`<baseDir>/<LOGS_SUBDIR>`) if it doesn't exist
* and verifies that write access is possible within that directory.
* @internal
* @param baseDir The base directory (e.g., `ReactNativeBlobUtil.fs.dirs.DocumentDir`) to attempt creating the logs subdirectory within.
* @returns A promise resolving to the full path to the logs subdirectory (e.g., `/path/to/docs/logs`) if successful, otherwise `null`.
* @async
*/
const tryCreateDirectory = async (baseDir) => {
if (!baseDir) {
// This shouldn't happen if called correctly, but guard anyway.
console.warn('[Logger] tryCreateDirectory called with invalid base directory.');
return null;
}
const logsPath = `${baseDir}/${LOGS_SUBDIR}`;
try {
const exists = await ReactNativeBlobUtil.fs.exists(logsPath);
if (!exists) {
await ReactNativeBlobUtil.fs.mkdir(logsPath);
// console.log(`[Logger] Created log directory: ${logsPath}`); // Less verbose logging
}
// Verify write access by writing and deleting a temporary file. This confirms permissions.
const testFileName = `.write_test_${Date.now()}`;
const testPath = `${logsPath}/${testFileName}`;
await ReactNativeBlobUtil.fs.writeFile(testPath, 'write_test', LOG_FILE_ENCODING);
await ReactNativeBlobUtil.fs.unlink(testPath);
// console.debug(`[Logger] Write access verified for directory: ${logsPath}`);
return logsPath; // Return the path to the logs subdirectory
}
catch (error) {
console.warn(`[Logger] Failed to create or verify write access for directory ${logsPath}:`, error instanceof Error ? error.message : String(error));
return null; // Indicate failure
}
};
/**
* Initializes the file logging session. This function ensures:
* 1. A writable log directory (`<baseDir>/logs/`) exists, attempting default and fallback locations.
* 2. The log file for the current 2-day window (see {@link generateLogFilename}) exists or is created.
* 3. A session start marker is written to the log file.
* 4. Log cleanup (`cleanupOldLogs`) is triggered upon successful initialization.
*
* This is called automatically by the first `log()` call or can be invoked manually
* early in the app lifecycle (recommended for predictability). Handles concurrent calls gracefully.
*
* @example Manually initializing early in your app (e.g., index.js or App.tsx)
* ```typescript
* import { initSessionLog } from 'react-native-beautiful-logs';
*
* async function initializeApp() {
* // ... other setup
* console.log('Initializing logging session...');
* const logSessionReady = await initSessionLog();
* if (logSessionReady) {
* console.log('File logging initialized successfully.');
* } else {
* console.warn("File logging could not be initialized. Logs will only go to console.");
* }
* // ... rest of app startup
* }
*
* initializeApp();
* ```
*
* @returns {Promise<boolean>} A promise resolving to `true` if initialization was successful
* (log directory and file are ready for writing), and `false` otherwise.
* Detailed errors during the process are logged internally via `console.warn` or `console.error`.
* @category Core
* @async
*/
export const initSessionLog = async () => {
// 1. Fast Check: Is the session already marked as initialized and seems valid?
if (isSessionInitialized && currentSessionLogPath && logDirectoryPath) {
try {
// Quick check: Does the cached filename match the expected filename for *now*?
const currentFileName = currentSessionLogPath.split('/').pop();
const expectedFileName = generateLogFilename(); // Calculates filename based on current date
if (currentFileName === expectedFileName) {
// Filename matches the current date window. Now verify the file *actually* still exists.
const fileExists = await ReactNativeBlobUtil.fs.exists(currentSessionLogPath);
if (fileExists) {
// console.debug('[Logger] Session already initialized and valid.');
return true; // Session is active and ready.
}
else {
console.warn(`[Logger] Current log file missing unexpectedly (${currentSessionLogPath}). Reinitializing session.`);
// Proceed to full re-initialization by resetting flags.
}
}
else {
// console.log( // Less verbose logging
// `[Logger] Log file date window changed (expected ${expectedFileName}, was ${currentFileName}). Reinitializing.`,
// );
// Date window changed, proceed to full re-initialization.
}
}
catch (checkError) {
console.warn('[Logger] Error checking existing log file status. Reinitializing session.', checkError);
// Error during check, proceed to full re-initialization.
}
// Reset state if checks failed (file missing, date changed, error)
isSessionInitialized = false;
currentSessionLogPath = null;
// Keep `logDirectoryPath` as the directory itself might still be valid.
}
// 2. Concurrency Lock: Prevent multiple simultaneous initialization attempts.
if (initializationInProgress) {
// console.debug('[Logger] Initialization already in progress, waiting briefly...');
// Wait for a short period, then re-check if another process finished init.
await new Promise(resolve => setTimeout(resolve, 250)); // Reduced wait time
// Re-check the primary condition after waiting to see if another caller finished.
return isSessionInitialized && !!currentSessionLogPath && !!logDirectoryPath;
}
initializationInProgress = true;
// console.log('[Logger] Starting log session initialization...'); // Less verbose
try {
// 3. Determine Directories: Create list of directories to try (preferred first, then fallbacks).
const dirsToTry = [
preferredLogDirBase,
...FALLBACK_DIRS.filter(dir => dir !== preferredLogDirBase), // Add fallbacks not already preferred
].filter((dir) => !!dir); // Ensure all entries are valid, non-empty strings
if (dirsToTry.length === 0) {
console.error('[Logger] CRITICAL: No valid log directories configured (DEFAULT_LOG_DIR_BASE and FALLBACK_DIRS are empty or invalid). File logging disabled.');
throw new Error('No valid log directories available.'); // Throw to ensure failure is propagated if needed.
}
let successfulInit = false;
logDirectoryPath = null; // Reset before trying directories
// 4. Attempt Initialization in each directory
for (const baseDir of dirsToTry) {
// console.debug(`[Logger] Attempting to use base directory: ${baseDir}`);
const potentialLogDirPath = await tryCreateDirectory(baseDir);
if (potentialLogDirPath) {
// Found a working directory! Now set up the log file.
logDirectoryPath = potentialLogDirPath;
const fileName = generateLogFilename();
const filePath = `${logDirectoryPath}/${fileName}`;
let createdNewFile = false;
try {
// Check if the specific log file for the current period exists
const fileExists = await ReactNativeBlobUtil.fs.exists(filePath);
if (!fileExists) {
// Create the file empty if it doesn't exist. Use internal helper for encoding resilience.
// Use appendToFile helper to handle potential encoding issues from the start.
await appendToFile(filePath, ''); // Create file using append (handles encoding)
createdNewFile = true;
// console.log(`[Logger] Created new log file: ${filePath}`); // Less verbose
}
else {
// console.debug(`[Logger] Using existing log file: ${filePath}`);
}
// Final check to ensure file exists after attempt (paranoid check)
const finalCheckExists = await ReactNativeBlobUtil.fs.exists(filePath);
if (!finalCheckExists) {
throw new Error(`Log file verification failed after creation/check: ${filePath}`);
}
// --- Success Point ---
currentSessionLogPath = filePath;
isSessionInitialized = true;
successfulInit = true;
// Write a session marker to the log file
const timestamp = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
// Use a different marker if we created a brand new file vs appending to existing
const sessionMarker = createdNewFile
? `\n=== New Log Session Started at ${timestamp} ===\n` // First entry for this date window
: `\n=== App Session Resumed at ${timestamp} ===\n`; // App restart within same date window
await appendToFile(filePath, sessionMarker); // Use internal append helper
// Perform cleanup *after* successful initialization
// Run cleanup asynchronously; don't block initialization return.
cleanupOldLogs().catch(cleanupError => {
console.warn('[Logger] Error during background log cleanup:', cleanupError);
});
break; // Exit the loop, initialization succeeded
}
catch (fileError) {
console.warn(`[Logger] Failed to initialize log file ${filePath} in directory ${logDirectoryPath}. Error:`, fileError instanceof Error ? fileError.message : String(fileError), 'Trying next directory if available.');
// Reset paths if file operation failed for *this* directory before trying the next
logDirectoryPath = null;
currentSessionLogPath = null;
isSessionInitialized = false;
successfulInit = false; // Ensure flag is false if this attempt fails
// Continue to the next directory in the loop
}
}
} // End of directory loop
// 5. Final Outcome Check
if (!successfulInit) {
console.error('[Logger] All storage locations failed. File logging disabled for this session. Tried:', dirsToTry.join(', '));
// Ensure state reflects failure
isSessionInitialized = false;
logDirectoryPath = null;
currentSessionLogPath = null;
return false; // Explicitly return false
}
return true; // Initialization was successful
}
catch (error) {
console.error('[Logger] Unexpected critical error during session initialization:', error instanceof Error ? error.message : String(error), error);
// Ensure state reflects failure
isSessionInitialized = false;
logDirectoryPath = null;
currentSessionLogPath = null;
return false; // Explicitly return false on unexpected errors
}
finally {
initializationInProgress = false; // Release the lock regardless of outcome
// console.debug('[Logger] Initialization attempt finished.');
}
};
/**
* Appends string content to a specified file. Includes logic to handle potential
* encoding issues on Android by attempting UTF-8 first and falling back to Base64
* if the UTF-8 write fails. If append fails definitively, marks the session as
* uninitialized to force re-initialization on the next write attempt.
* @internal
* @param filePath The full, absolute path to the file.
* @param content The string content to append.
* @returns {Promise<void>} Resolves when the append operation is complete or fails. Errors are logged.
* @async
*/
const appendToFile = async (filePath, content) => {
// Skip empty content writes (e.g., initial file creation check)
if (content === '') {
try {
// Ensure file exists, create if not. Write empty string with preferred encoding.
const exists = await ReactNativeBlobUtil.fs.exists(filePath);
if (!exists) {
await ReactNativeBlobUtil.fs.writeFile(filePath, '', LOG_FILE_ENCODING);
}
return; // Done.
}
catch (error) {
console.warn(`[Logger] Failed to ensure empty file exists for append: ${filePath}`, error);
// Continue to append attempt below, maybe it recovers.
}
}
try {
if (Platform.OS === 'android') {
try {
// Attempt 1: Try writing with preferred UTF-8 encoding on Android
await ReactNativeBlobUtil.fs.appendFile(filePath, content, LOG_FILE_ENCODING);
}
catch (utf8Error) {
// Attempt 2: UTF-8 failed, attempt fallback with Base64 encoding
console.warn(`[Logger] UTF-8 append failed on Android for ${filePath}. Attempting base64 fallback. Error:`, utf8Error instanceof Error ? utf8Error.message : String(utf8Error));
try {
const base64Data = Buffer.from(content, 'utf8').toString(LOG_FILE_ENCODING_FALLBACK);
await ReactNativeBlobUtil.fs.appendFile(filePath, base64Data, LOG_FILE_ENCODING_FALLBACK);
// console.debug(`[Logger] Successfully appended using base64 fallback to ${filePath}`);
}
catch (fallbackError) {
// Base64 fallback also failed, log critical error and mark session invalid
console.error(`[Logger] CRITICAL: Base64 append fallback also failed for ${filePath}. Logging potentially interrupted. Error:`, fallbackError instanceof Error ? fallbackError.message : String(fallbackError));
// Mark session as uninitialized to force re-attempt on next log
isSessionInitialized = false;
currentSessionLogPath = null;
// Do not re-throw; allow app to continue, console logging might still work.
}
}
}
else {
// For iOS and other platforms, use UTF-8 directly
await ReactNativeBlobUtil.fs.appendFile(filePath, content, LOG_FILE_ENCODING);
}
}
catch (error) {
// Catch errors from non-Android append or initial file check errors propagating
console.error(`[Logger] CRITICAL: Failed to append to log file ${filePath}. Logging potentially interrupted. Error:`, error instanceof Error ? error.message : String(error));
// Mark session as uninitialized if append fails, to force re-attempt next time.
isSessionInitialized = false;
currentSessionLogPath = null;
// Do not re-throw here, allow logging to continue to console if possible.
}
};
/**
* Deletes old log files based on configured limits (count, age, size).
* Called automatically after successful session initialization (`initSessionLog`)
* and can also be invoked manually via `loggerInterface.cleanupOldLogs()` (though less common).
*
* Cleanup Rules (applied in order):
* 1. **Count:** If total log files > `MAX_LOG_FILES`, delete oldest files until limit is met.
* 2. **Age:** Delete remaining files (excluding current session) older than `MAX_LOG_AGE_DAYS`.
* 3. **Size:** Delete remaining files (excluding current session) larger than `MAX_LOG_SIZE_MB`.
*
* @returns {Promise<void>} A promise that resolves when cleanup is complete. Errors during cleanup are logged internally but do not throw.
* @category File Management
* @see {@link MAX_LOG_FILES}
* @see {@link MAX_LOG_AGE_DAYS}
* @see {@link MAX_LOG_SIZE_MB}
* @async
*/
export const cleanupOldLogs = async () => {
if (!logDirectoryPath) {
// console.warn('[Logger] Cannot cleanup logs: Log directory path is not available.');
return; // Can't proceed without the directory path
}
if (MAX_LOG_FILES <= 0 && MAX_LOG_AGE_DAYS <= 0 && MAX_LOG_SIZE_MB <= 0) {
// console.debug('[Logger] Log cleanup skipped: All limits (MAX_LOG_FILES, MAX_LOG_AGE_DAYS, MAX_LOG_SIZE_MB) are disabled.');
return;
}
// console.log(`[Logger] Starting log cleanup in directory: ${logDirectoryPath}`); // Less verbose
try {
const items = await ReactNativeBlobUtil.fs.ls(logDirectoryPath);
// Filter items that match the log file naming pattern
const logFileNames = items.filter(file => file.startsWith(LOG_FILE_PREFIX) && file.endsWith(LOG_FILE_SUFFIX));
if (logFileNames.length === 0) {
// console.log('[Logger] No log files found matching pattern for cleanup.');
return;
}
// Get details (stats, parsed date) for each log file
const fileDetailsPromises = logFileNames.map(async (name) => {
const filePath = `${logDirectoryPath}/${name}`;
// Extract date from filename (e.g., "session_YYYY-MM-DD.txt")
const dateMatch = name.match(/(\d{4}-\d{2}-\d{2})/);
// Use start of day for consistent age comparison
const date = dateMatch ? moment(dateMatch[1], 'YYYY-MM-DD').startOf('day') : moment(0); // Fallback to epoch if pattern fails
let stats = null;
try {
stats = await ReactNativeBlobUtil.fs.stat(filePath);
if (!stats)
throw new Error('Stat returned null'); // Handle null case
}
catch (statError) {
console.warn(`[Logger] Could not get stats for file ${name} during cleanup, skipping file:`, statError);
}
// Return null if stats failed, filter out later
return stats ? { name, date, path: filePath, stats } : null;
});
// Wait for all stats promises and filter out any nulls (files that couldn't be stat'd)
const detailedFiles = (await Promise.all(fileDetailsPromises)).filter((f) => !!f);
// Sort files by date (most recent first) for easier processing of limits
detailedFiles.sort((a, b) => b.date.valueOf() - a.date.valueOf());
const filesToDelete = new Set(); // Use a Set to avoid duplicate deletion attempts
let filesToKeep = [...detailedFiles]; // Start with all valid, sorted files
// --- Apply Cleanup Rules ---
// 1. Max File Count Rule (if enabled)
if (MAX_LOG_FILES > 0 && filesToKeep.length > MAX_LOG_FILES) {
const excessCount = filesToKeep.length - MAX_LOG_FILES;
const oldestFiles = filesToKeep.slice(MAX_LOG_FILES); // Get the oldest files beyond the limit
console.log(`[Logger] Max files (${MAX_LOG_FILES}) exceeded by ${excessCount}. Marking oldest for deletion.`);
oldestFiles.forEach(file => {
// Crucially, never delete the currently active log file, even if it falls into the oldest list somehow
if (file.path !== currentSessionLogPath) {
filesToDelete.add(file.name);
}
else {
// console.warn(`[Logger] Cleanup logic tried to delete the active log file (${file.name}) by count. Preventing deletion.`);
}
});
// Update filesToKeep for subsequent checks (only keep the newest MAX_LOG_FILES count)
filesToKeep = filesToKeep.slice(0, MAX_LOG_FILES);
}
// 2. Age and Size Rules (for remaining files, excluding the current session file)
const today = moment().startOf('day'); // Use start of day for consistent age diff
const currentFileName = currentSessionLogPath ? currentSessionLogPath.split('/').pop() : null;
filesToKeep.forEach(file => {
// Always skip the currently active log file from age/size deletion checks
if (file.name === currentFileName) {
return;
}
// Skip if already marked for deletion by count limit
if (filesToDelete.has(file.name)) {
return;
}
// Check Age (if enabled)
if (MAX_LOG_AGE_DAYS > 0) {
const daysOld = today.diff(file.date, 'days');
if (daysOld > MAX_LOG_AGE_DAYS) {
// console.log( // Less verbose
// `[Logger] Marking old log file ${file.name} (${daysOld} days old > ${MAX_LOG_AGE_DAYS}) for deletion.`,
// );
filesToDelete.add(file.name);
return; // Mark for deletion and skip size check if already too old
}
}
// Check Size (if enabled)
if (MAX_LOG_SIZE_MB > 0) {
const sizeMB = file.stats.size / (1024 * 1024);
if (sizeMB > MAX_LOG_SIZE_MB) {
// console.log( // Less verbose
// `[Logger] Marking large log file ${file.name} (${sizeMB.toFixed(2)} MB > ${MAX_LOG_SIZE_MB} MB) for deletion.`,
// );
filesToDelete.add(file.name);
}
}
});
// 3. Perform Deletions
if (filesToDelete.size > 0) {
console.log(`[Logger] Deleting ${filesToDelete.size} log file(s) based on cleanup rules...`);
const deletePromises = Array.from(filesToDelete).map(filename =>
// Use internal helper which handles errors gracefully
deleteLogFileInternal(filename));
await Promise.all(deletePromises);
console.log('[Logger] Finished deleting marked log files.');
}
else {
// console.log('[Logger] No log files marked for deletion based on cleanup rules.'); // Less verbose
}
// console.log('[Logger] Log cleanup finished.'); // Less verbose
}
catch (error) {
console.error('[Logger] Error during log cleanup process:', error);
// Don't re-throw, allow application to continue.
}
};
/**
* Internal helper to delete a single log file by its name within the configured log directory.
* Handles 'file not found' errors gracefully (considers them success). Logs other errors.
* **Never deletes the currently active session log file.**
* @internal
* @param filename The name of the file to delete (e.g., `session_YYYY-MM-DD.txt`).
* @returns {Promise<boolean>} True if deletion was successful or file didn't exist or was the active file, false on other errors or if deletion was skipped.
* @async
*/
const deleteLogFileInternal = async (filename) => {
if (!logDirectoryPath) {
console.warn('[Logger] deleteLogFileInternal: Cannot delete, log directory path not set.');
return false;
}
if (!filename || !filename.startsWith(LOG_FILE_PREFIX) || !filename.endsWith(LOG_FILE_SUFFIX)) {
console.warn(`[Logger] deleteLogFileInternal: Invalid or empty filename provided: "${filename}"`);
return false;
}
const filePath = `${logDirectoryPath}/${filename}`;
// Safety Check: Absolutely do not delete the currently active log file.
if (filePath === currentSessionLogPath && !__DEV__) {
console.warn(`[Logger] deleteLogFileInternal: Attempted to delete the ACTIVE log file, skipping: ${filename}`);
return false; // Indicate deletion was skipped
}
try {
// console.debug(`[Logger] Attempting to delete log file: ${filePath}`);
await ReactNativeBlobUtil.fs.unlink(filePath);
// console.log(`[Logger] Deleted log file: ${filename}`); // Less verbose
return true;
}
catch (error) {
// Check if the error is a 'file not found' error, which is acceptable.
const errorMsg = String(error instanceof Error ? error.message : error).toLowerCase();
// Common phrases indicating file not found across different systems/versions
if (errorMsg.includes('exist') ||
errorMsg.includes('not found') ||
errorMsg.includes('no such file')) {
// console.debug(`[Logger] File not found during deletion attempt (already deleted?): ${filename}`);
return true; // Consider success if file doesn't exist.
}
// Log other unexpected errors during deletion.
console.error(`[Logger] Error deleting log file ${filename}:`, error);
return false; // Indicate failure
}
};
/**
* Writes a prepared log entry (message content) to the current session file,
* prepending timestamp and level. Handles session initialization automatically if needed.
* Checks file size *after* writing and triggers session rollover for the *next* write if limit exceeded.
* This is the primary function used by `log()` to persist messages to the filesystem.
*
* @param {string} message The plain text message content to write (should already be formatted, without colors).
* @param {LogLevel} level The log level associated with the message ('debug', 'info', 'warn', 'error').
* @returns {Promise<void>} A promise that resolves when the write operation (including potential init) is attempted. Errors are handled internally.
* @category Core
* @async
*/
export const writeToFile = async (message, level) => {
// 1. Ensure Session is Initialized (or attempt to initialize)
// This check handles cases where `log` is called before explicit `initSessionLog` or if a previous error invalidated the session.
const needsInit = !isSessionInitialized || !currentSessionLogPath || !logDirectoryPath;
if (needsInit) {
// console.debug('[Logger] writeToFile called but session not initialized. Attempting initialization...');
const initialized = await initSessionLog(); // Await initialization here
if (!initialized || !currentSessionLogPath) {
// Check state *after* awaiting init
// Log error only if init *failed* and we still don't have a path.
// console.error('[Logger] Cannot write to file: Session initialization failed or yielded no path.');
// Avoid flooding console if init fails repeatedly. Init logs its own errors.
return; // Bail out if init failed, cannot write.
}
// If init succeeded, state variables (isSessionInitialized, currentSessionLogPath, logDirectoryPath) are now set.
}
// Use non-null assertion for path, as the code block above ensures it's set if we reach here.
const currentPath = currentSessionLogPath;
try {
// 2. Prepare Log Entry for File
const timestamp = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
// File Format: Timestamp [LEVEL] Actual message content
const logEntry = `${timestamp} [${level.toUpperCase()}] ${message}\n`; // Ensure newline
// 3. Append to File (using helper for encoding safety)
// First, verify file still exists (paranoid check against external deletion)
const exists = await ReactNativeBlobUtil.fs.exists(currentPath);
let pathToWrite = currentPath;
if (!exists) {
console.warn(`[Logger] Current log file (${currentPath}) disappeared unexpectedly. Re-initializing session before writing...`);
isSessionInitialized = false; // Mark for re-init
const reinitialized = await initSessionLog(); // Attempt re-initialization
if (!reinitialized || !currentSessionLogPath) {
// Check path *again* after re-init attempt
console.error('[Logger] Failed to re-initialize session after file deletion. Cannot write current log entry.');
return; // Bail out if re-init fails
}
// Re-init succeeded, use the potentially updated path (though likely the same filename)
pathToWrite = currentSessionLogPath;
// Log entry content remains the same, just writing to the newly ensured file path.
await appendToFile(pathToWrite, logEntry);
}
else {
// File exists, proceed with normal append using the helper
await appendToFile(pathToWrite, logEntry);
}
// 4. Check File Size for Rollover (After successful write)
if (MAX_LOG_SIZE_MB > 0 && isSessionInitialized) {
// Only check if enabled and session is considered valid
try {
const stats = await ReactNativeBlobUtil.fs.stat(pathToWrite);
if (stats && stats.size / (1024 * 1024) > MAX_LOG_SIZE_MB) {
// console.log( // Less verbose
// `[Logger] Log file ${pathToWrite} reached size limit (${MAX_LOG_SIZE_MB}MB). ` +
// `Next log will trigger a new session file.`,
// );
// Mark session as needing re-initialization *for the next write*
// This will cause the next call to initSessionLog (via writeToFile or direct call)
// to potentially generate a new filename if the date window has also changed,
// or simply re-initialize with the same name if the window hasn't changed (effectively just resetting state).
// The desired behavior is typically to force a *new* file on size limit, which requires more complex filename generation (e.g., adding timestamps or counters).
// Current simple behavior: Size limit simply forces re-check/re-init. If date window same, file reused. If different, new file used.
// TO-DO: Implement true file rotation on size limit (e.g., session_YYYY-MM-DD_1.txt) if needed.
isSessionInitialized = false;
currentSessionLogPath = null; // Ensure new file path is determined next time
}
}
catch (statError) {
console.warn(`[Logger] Could not check file size for ${pathToWrite} after write:`, statError);
}
}
}
catch (error) {
// Catch errors specifically from the writeToFile logic (e.g., timestamp format, string concat)
console.error('[Logger] Error preparing or writing log entry:', error);
// Mark session as uninitialized if a significant error occurs during the write attempt
isSessionInitialized = false;
currentSessionLogPath = null;
}
};
// --- Logger Interface Implementation ---
/**
* Provides methods for interacting with the generated log files.
* Allows retrieving file lists, reading content, and deleting files.
* Accessed via the `loggerInterface` export from the library's main entry point.
*
* @example Using the LoggerInterface
* ```typescript
* import { loggerInterface, LoggerInterface } from 'react-native-beautiful-logs';
*
* async function manageLogs() {
* try {
* const files = await loggerInterface.getLogFiles();
* console.log("Available log files:", files);
*
* if (files.length > 0) {
* const newestLogName = files[0]; // Files are sorted newest first
* console.log(`Reading content of ${newestLogName}...`);
* const content = await loggerInterface.readLogFile(newestLogName);
*
* if (content) {
* console.log(`Content of ${newestLogName} (first 500 chars):\n`, content.substring(0, 500));
* // You could now upload content, display it, etc.
* } else {
* console.log(`Could not read or file empty: ${newestLogName}`);
* }
* }
*
* // Example: Delete all logs except the current one
* // console.log("Deleting old logs...");
* // const deleted = await loggerInterface.deleteAllLogs();
* // console.log("Deletion successful:", deleted);
*
* } catch (error) {
* console.error("Error managing logs:", error);
* }
* }
*
* manageLogs();
* ```
*
* @category File Management
*/
export const loggerInterface = {
/** @inheritdoc */
async getLogFiles() {
// Ensure initialization is attempted if needed before listing
if (!logDirectoryPath) {
// console.debug('[Logger] Log directory not initialized, attempting init before getting log files.');
const initialized = await initSessionLog();
if (!initialized || !logDirectoryPath) {
// Check path again after init attempt
console.warn('[Logger] Initialization failed or yielded no path, cannot get log files.');
return []; // Return empty array if dir isn't available
}
}
try {
// Use non-null assertion as we ensured initialization above
const items = await ReactNativeBlobUtil.fs.ls(logDirectoryPath);
// Filter and sort the files based on the naming convention
const logFiles = items
.filter(file => file.startsWith(LOG_FILE_PREFIX) && file.endsWith(LOG_FILE_SUFFIX))
.sort((a, b) => {
// Extract date part (YYYY-MM-DD) for robust sorting
const dateA = a.substring(LOG_FILE_PREFIX.length, a.length - LOG_FILE_SUFFIX.length);
const dateB = b.substring(LOG_FILE_PREFIX.length, b.length - LOG_FILE_SUFFIX.length);
// Compare strings lexicographically (YYYY-MM-DD format sorts correctly)
// Sort descending for newest first
return dateB.localeCompare(dateA);
});
return logFiles;
}
catch (error) {
console.error('[Logger] Error listing log files:', error);
return []; // Return empty array on error
}
},
/** @inheritdoc */
async getCurrentSessionLog() {
// Ensure initialization is attempted if needed
if (!currentSessionLogPath) {
// console.debug('[Logger] No active session log path. Attempting init...');
const initialized = await initSessionLog();
if (!initialized || !currentSessionLogPath) {
// Check path again after init attempt
console.warn('[Logger] Initialization failed or yielded no path, cannot get current session log path.');
return ''; // Return empty string if unavailable
}
}
try {
// Use non-null assertion as path is checked/set above
const filename = currentSessionLogPath.split('/').pop();
if (!filename) {
console.error('[Logger] Could not extract filename from current session path.');
return ''; // Should not happen if path is valid
}
// Use the generic readLogFile method to benefit from its logic (encoding fallback etc.)
const content = await this.readLogFile(filename);
return content !== null && content !== void 0 ? content : ''; // Return empty string if readLogFile returns null
}
catch (error) {
console.error('[Logger] Error reading current session log content:', error);
return ''; // Return empty string on error
}
},
/** @inheritdoc */
async readLogFile(filename) {
// Ensure initialization is attempted if needed (to get logDirectoryPath)
if (!logDirectoryPath) {
// console.debug('[Logger] Log directory not initialized, attempting init before reading file.');
const initialized = await initSessionLog();
if (!initialized || !logDirectoryPath) {
// Check path again after init attempt
console.warn('[Logger] Initialization failed or yielded no path, cannot read log file.');
return null;
}
}
// Basic filename format validation for robustness
if (!filename || !filename.startsWith(LOG_FILE_PREFIX) || !filename.endsWith(LOG_FILE_SUFFIX)) {
console.error(`[Logger] Invalid filename format provided to readLogFile: "${filename}". Expected format like "${LOG_FILE_PREFIX}YYYY-MM-DD${LOG_FILE_SUFFIX}".`);
return null; // Return null for invalid filename format
}
// Use non-null assertion for directory path
const filePath = `${logDirectoryPath}/${filename}`;
try {
const exists = await ReactNativeBlobUtil.fs.exists(filePath);
if (!exists) {
// console.warn(`[Logger] Log file not found for reading: ${filePath}`);
return null; // File doesn't exist
}
const stats = await ReactNativeBlobUtil.fs.stat(filePath);
// Check if stat succeeded and file has size. (Stat might return undefined on error)
if (!stats || stats.size === 0) {
// console.warn(`[Logger] Log file is empty or failed to stat: ${filePath}`);
// Optionally, delete empty files here? Could be unexpected side effect.
// await deleteLogFileInternal(filename);
return null; // File is empty or stat failed
}
// Read file content, handling potential Base64 encoding on Android
let content = null;
if (Platform.OS === 'android') {
try {
// Attempt 1: Read as UTF-8 first
content = await ReactNativeBlobUtil.fs.readFile(filePath, LOG_FILE_ENCODING);
}
catch (utf8Error) {
// Attempt 2: UTF-8 read failed, try reading as Base64 and decoding
console.warn(`[Logger] UTF-8 read failed for ${filename} on Android. Attempting base64 fallback decode. Error:`, utf8Error instanceof Error ? utf8Error.message : String(utf8Error));
try {
const base64Content = await ReactNativeBlobUtil.fs.readFile(filePath, LOG_FILE_ENCODING_FALLBACK);
// Decode the base64 string back to UTF-8
content = Buffer.from(base64Content, LOG_FILE_ENCODING_FALLBACK).toString(LOG_FILE_ENCODING);
// console.debug(`[Logger] Successfully read and decoded base64 content from ${filename}`);
}
catch (base64Error) {
console.error(`[Logger] Base64 read fallback also failed for ${filename}. Cannot read file content.`, base64Error);
return null; // Return null if both UTF-8 and Base64 read attempts fail
}
}
}
else {
// For iOS and other platforms, read directly as UTF-8
content = await ReactNativeBlobUtil.fs.readFile(filePath, LOG_FILE_ENCODING);
}
// Final check: Read operation might succeed but yield null or empty string (less likely now with size check)
if (content === null || content.trim() === '') {
// console.warn(`[Logger] Read operation resulted in null or effectively empty content for: ${filePath}`);
return null; // Treat effectively empty content as null
}
return content; // Return the successfully read and decoded content
}
catch (error) {
console.error(`[Logger] Error reading log file ${filename}:`, error);
return null; // Return null on any unexpected error during read process
}
},
/** @inheritdoc */
async deleteLogFile(filename) {
// Ensure initialization is attempted if needed (to get logDirectoryPath and currentSessionLogPath)
if (!logDirectoryPath) {
// console.debug('[Logger] Log directory not initialized, attempting init before deleting file.');
const initialized = await initSessionLog();
if (!initialized || !logDirectoryPath) {
// Check path again after init attempt
console.warn('[Logger] Initialization failed or yielded no path, cannot delete log file.');
return false;
}
}
// Filename validation happens within deleteLogFileInternal
// Safety check for active file also happens within deleteLogFileInternal
// Use the internal helper which includes safety checks and error handling
return deleteLogFileInternal(filename);
},
/** @inheritdoc */
async deleteAllLogs() {
// Ensure initialization is attempted if needed (to get file list and current path)
if (!logDirectoryPath) {
// console.debug('[Logger] Log directory not initialized, attempting init before deleting all logs.');
const initialized = await initSessionLog();
if (!initialized || !logDirectoryPath) {
// Check path again after init attempt
console.warn('[Logger] Initialization failed or yielded no path, cannot delete logs.');
return false;
}
}
try {
// Get the list of all log files first
const files = await this.getLogFiles(); // Already sorted newest first
const currentFileName = currentSessionLogPath ? currentSessionLogPath.split('/').pop() : null;
if (files.length === 0 || (files.length === 1 && files[0] === currentFileName)) {
console.log('[Logger] No non-active log files found to delete.');
return true; // Nothing to delete or only the active file exists
}
const deletePromises = [];
let deleteCount = 0;
for (const filename of files) {
// Use the internal delete function which skips the active file automatically
if (filename !== currentFileName) {
deletePromises.push(deleteLogFileInternal(filename));
deleteCount++;
}
}
if (deletePromises.length > 0) {
console.log(`[Logger] Attempting to delete ${deleteCount} non-active log file(s)...`);
const results = await Promise.all(deletePromises);
// Check if all attempted deletions succeeded (or file was already gone)
const allSucceeded = results.every(success => success);
if (allSucceeded) {
console.log('[Logger] Finished deleting all non-active log files successfully.');
}
else {
console.warn('[Logger] Finished deleting non-active logs, but some deletions may have failed (check previous errors).');
}
return allSucceeded;
}
else {
// This case should be caught earlier, but added for completeness
console.log('[Logger] No non-active log files were found to delete.');
return true;
}
}
catch (error) {
console.error('[Logger] Error during deleteAllLogs operation:', error);
return false;
}
},
/** @inheritdoc */
async cleanupCurrentSession() {
// console.log('[Logger] Cleaning up current session state...'); // Less verbose
if (currentSessionLogPath && logDirectoryPath) {
const filename = currentSessionLogPath.split('/').pop();
if (filename) {
try {
const stats = await ReactNativeBlobUtil.fs.stat(currentSessionLogPath);
// Check if the file exists and is empty (or stat failed)
if (!stats || stats.size === 0) {
// console.log( // Less verbose
// `[Logger] Current session log file ${filename} is empty or stat failed, deleting it during cleanup.`,
// );
// Use internal delete which *won't* delete the active file path normally,
// but since we are cleaning up the *session state* afterwards, it's okay to try deleting it if empty.
// Temporarily clear currentSessionLogPath to allow deletion by the internal helper.
const pathToDelete = currentSessionLogPath;
currentSessionLogPath = null; // Allow deletion temporarily
await deleteLogFileInternal(filename); // Attempt deletion
currentSessionLogPath = pathToDelete; // Restore path momentarily if needed (though state is reset below)
}
}
catch (error) {
// Ignore errors if file doesn't exist (already deleted?) or stat fails. Log other errors.
const errorMsg = String(error instanceof Error ? error.message : error).toLowerCase();
if (!errorMsg.includes('exist') &&
!errorMsg.includes('not found') &&
!errorMsg.includes('no such file')) {
console.warn(`[Logger] Could not stat or delete current session file ${filename} during cleanup. Error:`, error instanceof Error ? error.message : String(error));
}
}
}
}
// Always reset internal state regardless of file deletion outcome.
// This ensures that the next log() or initSessionLog() call starts fresh.
currentSessionLogPath = null;
isSessionInitialized = false;
// Keep `logDirectoryPath`? It might be reused on the next init. Resetting it forces rediscovery. Let's keep it for potential optimization.
// logDirectoryPath = null;
console.log('[Logger] Session state has been reset.');
},
};
//# sourceMappingURL=fileManager.js.map