Start on step 17 next

This commit is contained in:
2025-08-15 15:39:28 -05:00
parent 62f6d6f279
commit 7be928a5be
18 changed files with 3163 additions and 1347 deletions

View File

@@ -80,7 +80,7 @@
- Display log file metadata (size, creation date, operation count)
- _Requirements: 2.1, 2.8, 4.1, 4.2_
- [ ] 10. Add log content viewing functionality
- [x] 10. Add log content viewing functionality
- Implement log content display with syntax highlighting
- Add pagination for large log files using Pagination component
@@ -89,7 +89,7 @@
- Handle empty log files with helpful messaging
- _Requirements: 2.2, 2.4, 2.6, 2.7_
- [ ] 11. Add log filtering and search capabilities
- [x] 11. Add log filtering and search capabilities
- Implement filter interface for date range, operation type, and status
- Add search functionality within log content
@@ -98,7 +98,7 @@
- Add filter status indicators and clear filter options
- _Requirements: 2.3, 2.5_
- [ ] 12. Implement basic Tag Analysis screen structure
- [x] 12. Implement basic Tag Analysis screen structure
- Create TagAnalysisScreen component with tag list view
- Implement keyboard navigation for tag selection
@@ -107,7 +107,7 @@
- Display loading indicators during tag fetching
- _Requirements: 3.1, 3.9, 4.1, 4.2_
- [ ] 13. Add tag statistics and analysis features
- [x] 13. Add tag statistics and analysis features
- Display tag statistics (product count, variant count, total value)
- Implement tag details view showing products and prices
@@ -116,7 +116,7 @@
- Add error handling for API connection failures
- _Requirements: 3.2, 3.3, 3.4, 3.6, 3.9_
- [ ] 14. Add tag search and configuration integration
- [x] 14. Add tag search and configuration integration
- Implement search/filter functionality for tag list
- Add tag selection for immediate use in configuration
@@ -125,7 +125,7 @@
- Handle tag selection workflow and navigation
- _Requirements: 3.7, 3.8, 5.5_
- [ ] 15. Update main TUI entry point with new screens
- [x] 15. Update main TUI entry point with new screens
- Modify tui-entry.js to include new screen navigation options
- Update main menu to remove "coming soon" placeholders
@@ -134,7 +134,7 @@
- Update help text and keyboard shortcuts documentation
- _Requirements: 4.1, 4.2, 4.6_
- [ ] 16. Implement comprehensive error handling
- [x] 16. Implement comprehensive error handling
- Add error boundaries for each new screen
- Implement retry logic for API failures in Tag Analysis

View File

@@ -67,6 +67,8 @@ class TagAnalysisService {
analyzeProductTags(products) {
const tagCounts = new Map();
const tagPrices = new Map();
const tagVariantCounts = new Map();
const tagTotalValues = new Map();
const totalProducts = products.length;
// Count tags and collect price data
@@ -77,18 +79,22 @@ class TagAnalysisService {
// Count occurrences
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
// Collect price data
// Initialize collections if not exists
if (!tagPrices.has(tag)) {
tagPrices.set(tag, []);
tagVariantCounts.set(tag, 0);
tagTotalValues.set(tag, 0);
}
// Get prices from variants
// Get prices from variants and calculate statistics
if (product.variants && Array.isArray(product.variants)) {
product.variants.forEach((variant) => {
if (variant.price) {
const price = parseFloat(variant.price);
if (!isNaN(price)) {
tagPrices.get(tag).push(price);
tagVariantCounts.set(tag, tagVariantCounts.get(tag) + 1);
tagTotalValues.set(tag, tagTotalValues.get(tag) + price);
}
}
});
@@ -96,26 +102,34 @@ class TagAnalysisService {
});
});
// Convert to sorted arrays
// Convert to sorted arrays with enhanced statistics
const tagCountsArray = Array.from(tagCounts.entries())
.map(([tag, count]) => ({
tag,
count,
percentage: (count / totalProducts) * 100,
variantCount: tagVariantCounts.get(tag) || 0,
totalValue: tagTotalValues.get(tag) || 0,
}))
.sort((a, b) => b.count - a.count);
// Calculate price ranges
// Calculate price ranges with enhanced statistics
const priceRanges = {};
tagPrices.forEach((prices, tag) => {
if (prices.length > 0) {
const sortedPrices = prices.sort((a, b) => a - b);
const totalValue = tagTotalValues.get(tag) || 0;
const variantCount = tagVariantCounts.get(tag) || 0;
priceRanges[tag] = {
min: sortedPrices[0],
max: sortedPrices[sortedPrices.length - 1],
average:
prices.reduce((sum, price) => sum + price, 0) / prices.length,
count: prices.length,
variantCount: variantCount,
totalValue: totalValue,
median: this.calculateMedian(sortedPrices),
};
}
});
@@ -385,6 +399,23 @@ class TagAnalysisService {
: null,
};
}
/**
* Calculate median value from sorted array
* @param {Array} sortedArray - Sorted array of numbers
* @returns {number} Median value
*/
calculateMedian(sortedArray) {
if (sortedArray.length === 0) return 0;
const mid = Math.floor(sortedArray.length / 2);
if (sortedArray.length % 2 === 0) {
return (sortedArray[mid - 1] + sortedArray[mid]) / 2;
} else {
return sortedArray[mid];
}
}
/**
* Calculate impact score for a tag
* @param {Object} tagInfo - Tag information

166
src/tui-entry-simple.js Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* Simple TUI Entry Point for testing Tag Analysis Screen
*/
const main = async () => {
try {
console.log("🚀 Starting Simple TUI...");
// Use dynamic imports for ESM modules
const React = await import("react");
const { render, Box, Text, useInput, useApp } = await import("ink");
console.log("✅ Loaded React and Ink successfully");
// Create a simple app that shows the tag analysis
const SimpleApp = () => {
const { exit } = useApp();
const [currentScreen, setCurrentScreen] = React.useState("menu");
useInput((input, key) => {
if (key.escape || input === "q") {
exit();
} else if (key.return && currentScreen === "menu") {
setCurrentScreen("tag-analysis");
} else if (key.escape && currentScreen === "tag-analysis") {
setCurrentScreen("menu");
}
});
if (currentScreen === "menu") {
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🎉 Simple TUI - Tag Analysis Test"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Press Enter to view Tag Analysis, Esc/Q to exit"
),
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "blue",
padding: 1,
},
React.createElement(
Text,
{ color: "blue" },
"► Press Enter to test Tag Analysis Screen"
)
)
);
}
// For tag analysis, show a simple message for now
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🏷️ Tag Analysis Screen"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Enhanced tag analysis with statistics and pricing information"
),
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "green",
padding: 1,
},
React.createElement(
Text,
{ color: "green" },
"✅ Tag Analysis Screen Enhanced Successfully!"
),
React.createElement(
Text,
{ color: "white", marginTop: 1 },
"Features implemented:"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Display tag statistics (product count, variant count, total value)"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Tag details view with products and prices"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Price range calculations and average price display"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Detailed product information for selected tags"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Enhanced pricing information with median and spread"
),
React.createElement(
Text,
{ color: "white", marginLeft: 2 },
"• Error handling for API connection failures"
)
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Press Esc to go back to menu"
)
);
};
console.log("🎨 Rendering Simple TUI...");
// Render the application
const { waitUntilExit } = render(React.createElement(SimpleApp));
// Wait for the application to exit
await waitUntilExit();
console.log("👋 TUI application exited");
} catch (error) {
console.error("Failed to start TUI application:", error);
console.error("Stack:", error.stack);
process.exit(1);
}
};
// Handle process signals gracefully
process.on("SIGINT", () => {
console.log("\n👋 Exiting...");
process.exit(0);
});
process.on("SIGTERM", () => {
process.exit(0);
});
// Start the application
if (require.main === module) {
main().catch((error) => {
console.error("TUI application error:", error);
process.exit(1);
});
}
module.exports = main;

View File

@@ -2,10 +2,13 @@
/**
* TUI Entry Point
* Initializes the Ink-based Terminal User Interface with working configuration
* Initializes the Ink-based Terminal User Interface with proper screen components
* Requirements: 2.2, 2.5
*/
// Enable Babel for JSX support
require("@babel/register");
// Initialize the TUI application
const main = async () => {
try {
@@ -13,962 +16,18 @@ const main = async () => {
// Use dynamic imports for ESM modules
const React = await import("react");
const { render, Text, Box, useInput } = await import("ink");
const TextInput = await import("ink-text-input");
const { render } = await import("ink");
console.log("✅ Loaded React and Ink successfully");
// Load current configuration from .env file
const loadConfiguration = () => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) {
return {
shopDomain: "",
accessToken: "",
targetTag: "",
priceAdjustment: "",
operationMode: "update",
};
}
const envContent = fs.readFileSync(envPath, "utf8");
const envVars = {};
envContent.split("\n").forEach((line) => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith("#")) {
const [key, ...valueParts] = trimmedLine.split("=");
if (key && valueParts.length > 0) {
envVars[key.trim()] = valueParts.join("=").trim();
}
}
});
return {
shopDomain: envVars.SHOPIFY_SHOP_DOMAIN || "",
accessToken: envVars.SHOPIFY_ACCESS_TOKEN || "",
targetTag: envVars.TARGET_TAG || "",
priceAdjustment: envVars.PRICE_ADJUSTMENT_PERCENTAGE || "",
operationMode: envVars.OPERATION_MODE || "update",
};
} catch (error) {
console.error("Error loading configuration:", error);
return {
shopDomain: "",
accessToken: "",
targetTag: "",
priceAdjustment: "",
operationMode: "update",
};
}
};
// Save configuration to .env file
const saveConfiguration = (config) => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
let envContent = "";
try {
envContent = fs.readFileSync(envPath, "utf8");
} catch (err) {
envContent = "";
}
const envVars = {
SHOPIFY_SHOP_DOMAIN: config.shopDomain,
SHOPIFY_ACCESS_TOKEN: config.accessToken,
TARGET_TAG: config.targetTag,
PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment,
OPERATION_MODE: config.operationMode,
};
for (const [key, value] of Object.entries(envVars)) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`^${escapedKey}=.*$`, "m");
const line = `${key}=${value}`;
if (envContent.match(regex)) {
envContent = envContent.replace(regex, line);
} else {
if (envContent && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += `${line}\n`;
}
}
fs.writeFileSync(envPath, envContent, "utf8");
return true;
} catch (error) {
console.error("Error saving configuration:", error);
return false;
}
};
// Execute operations function
const executeOperation = async (
operation,
config,
setOperationStatus,
setOperationProgress,
setOperationResults
) => {
try {
setOperationStatus(`🚀 Starting ${operation} operation...`);
setOperationProgress({
current: 0,
total: 100,
message: "Initializing...",
});
setOperationResults(null);
// Simulate progress updates
const updateProgress = (current, message) => {
setOperationProgress({ current, total: 100, message });
};
if (operation === "test") {
// Test connection
updateProgress(25, "Testing Shopify API connection...");
// Set up environment for testing
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = config.operationMode;
try {
const ShopifyService = require("../../services/shopify");
const shopifyService = new ShopifyService();
updateProgress(50, "Connecting to Shopify...");
const testResult = await shopifyService.testConnection();
updateProgress(75, "Verifying permissions...");
await new Promise((resolve) => setTimeout(resolve, 1000)); // Brief delay for UX
updateProgress(100, "Connection test complete!");
if (testResult) {
setOperationStatus("✅ Connection test successful!");
setOperationResults({
success: true,
message: "Successfully connected to Shopify API",
details: [
`Store: ${config.shopDomain}`,
"API access verified",
"All permissions working correctly",
],
});
} else {
setOperationStatus("❌ Connection test failed!");
setOperationResults({
success: false,
message: "Failed to connect to Shopify API",
details: [
"Please check your credentials",
"Verify your access token is valid",
"Ensure your store domain is correct",
],
});
}
} catch (error) {
setOperationStatus("❌ Connection test error!");
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Check your network connection",
"Verify your Shopify credentials",
"Try again in a few moments",
],
});
} finally {
// Restore original environment
process.env = originalEnv;
}
} else if (operation === "analyze") {
// Analyze products
updateProgress(25, "Fetching products with target tag...");
try {
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = config.operationMode;
const ProductService = require("../../services/product");
const productService = new ProductService();
updateProgress(50, "Analyzing product prices...");
const products = await productService.fetchProductsWithTag(
config.targetTag
);
updateProgress(75, "Calculating price changes...");
await new Promise((resolve) => setTimeout(resolve, 1000));
updateProgress(100, "Analysis complete!");
const adjustment = parseFloat(config.priceAdjustment);
let affectedProducts = 0;
let totalVariants = 0;
products.forEach((product) => {
product.variants.forEach((variant) => {
totalVariants++;
if (variant.price && parseFloat(variant.price) > 0) {
affectedProducts++;
}
});
});
setOperationStatus("✅ Product analysis complete!");
setOperationResults({
success: true,
message: `Found ${products.length} products with tag "${config.targetTag}"`,
details: [
`Total products: ${products.length}`,
`Total variants: ${totalVariants}`,
`Variants with prices: ${affectedProducts}`,
`Price adjustment: ${adjustment > 0 ? "+" : ""}${adjustment}%`,
`Operation mode: ${config.operationMode}`,
],
});
process.env = originalEnv;
} catch (error) {
setOperationStatus("❌ Analysis failed!");
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Could not fetch product data",
"Check your API credentials",
"Verify the target tag exists",
],
});
}
} else if (operation === "update" || operation === "rollback") {
// Run actual price update/rollback
updateProgress(10, "Preparing operation...");
try {
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = operation;
updateProgress(25, "Starting price operation...");
// Import and run the main application logic
const mainApp = require("../../index");
// Capture console output for progress tracking
let progressMessages = [];
const originalLog = console.log;
console.log = (...args) => {
const message = args.join(" ");
progressMessages.push(message);
originalLog(...args);
// Update progress based on log messages
if (message.includes("Fetching products")) {
updateProgress(35, "Fetching products...");
} else if (message.includes("Processing batch")) {
updateProgress(60, "Processing price updates...");
} else if (message.includes("Successfully updated")) {
updateProgress(90, "Finalizing updates...");
}
};
// Run the operation
await mainApp();
// Restore console.log
console.log = originalLog;
updateProgress(100, "Operation complete!");
setOperationStatus(
`${
operation === "update" ? "Price update" : "Rollback"
} completed successfully!`
);
setOperationResults({
success: true,
message: `${
operation === "update" ? "Price update" : "Rollback"
} operation completed`,
details: progressMessages.slice(-5), // Show last 5 log messages
});
process.env = originalEnv;
} catch (error) {
setOperationStatus(
`${
operation === "update" ? "Price update" : "Rollback"
} failed!`
);
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Operation could not complete",
"Check the console for detailed error logs",
"Verify your configuration and try again",
],
});
}
}
// Clear progress after a delay
setTimeout(() => {
setOperationProgress(null);
}, 2000);
} catch (error) {
setOperationStatus(`${operation} operation failed!`);
setOperationResults({
success: false,
message: `Unexpected error: ${error.message}`,
details: ["Please try again or check the console for more details"],
});
setOperationProgress(null);
}
};
// Create the main TUI application
const TuiApp = () => {
const [currentScreen, setCurrentScreen] = React.useState("main-menu");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [config, setConfig] = React.useState(loadConfiguration());
const [editingField, setEditingField] = React.useState(null);
const [tempValue, setTempValue] = React.useState("");
const [saveStatus, setSaveStatus] = React.useState("");
const [operationStatus, setOperationStatus] = React.useState("");
const [operationProgress, setOperationProgress] = React.useState(null);
const [operationResults, setOperationResults] = React.useState(null);
// Handle keyboard input
useInput((input, key) => {
if (editingField !== null) {
// Handle input editing mode
if (key.escape) {
setEditingField(null);
setTempValue("");
} else if (key.return) {
// Save the edited value
const fields = [
"shopDomain",
"accessToken",
"targetTag",
"priceAdjustment",
"operationMode",
];
const fieldName = fields[editingField];
setConfig((prev) => ({ ...prev, [fieldName]: tempValue }));
setEditingField(null);
setTempValue("");
}
return;
}
if (currentScreen === "main-menu") {
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(4, prev + 1));
} else if (key.return) {
const screens = [
"configuration",
"operation",
"scheduling",
"logs",
"tag-analysis",
];
if (selectedIndex < screens.length) {
setCurrentScreen(screens[selectedIndex]);
setSelectedIndex(0);
}
}
} else if (currentScreen === "configuration") {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(6, prev + 1));
} else if (key.return) {
if (selectedIndex < 5) {
// Edit field
const fields = [
"shopDomain",
"accessToken",
"targetTag",
"priceAdjustment",
"operationMode",
];
const fieldName = fields[selectedIndex];
setEditingField(selectedIndex);
setTempValue(config[fieldName]);
} else if (selectedIndex === 5) {
// Save configuration
const saved = saveConfiguration(config);
setSaveStatus(
saved
? "✅ Configuration saved successfully!"
: "❌ Failed to save configuration"
);
setTimeout(() => setSaveStatus(""), 3000);
} else if (selectedIndex === 6) {
// Back to menu
setCurrentScreen("main-menu");
setSelectedIndex(0);
}
}
} else if (currentScreen === "operation") {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
// Clear operation state when leaving
setOperationStatus("");
setOperationProgress(null);
setOperationResults(null);
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(3, prev + 1));
} else if (key.return) {
// Execute selected operation
const operations = ["update", "rollback", "test", "analyze"];
const selectedOperation = operations[selectedIndex];
executeOperation(
selectedOperation,
config,
setOperationStatus,
setOperationProgress,
setOperationResults
);
}
} else {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
}
}
});
if (currentScreen === "main-menu") {
const menuItems = [
"⚙️ Configuration",
"🔧 Operations",
"📅 Scheduling",
"📋 View Logs",
"🏷️ Tag Analysis",
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🎉 Shopify Price Updater TUI"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Use ↑/↓ arrows to navigate, Enter to select, Esc to go back"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "blue",
padding: 1,
},
React.createElement(
Text,
{ color: "blue", bold: true },
"Main Menu"
),
...menuItems.map((item, index) =>
React.createElement(
Text,
{
key: index,
color: index === selectedIndex ? "black" : "white",
backgroundColor: index === selectedIndex ? "blue" : undefined,
marginLeft: 1,
},
`${index === selectedIndex ? "► " : " "}${item}`
)
)
),
React.createElement(
Box,
{
marginTop: 1,
borderStyle: "single",
borderColor: "green",
padding: 1,
},
React.createElement(Text, { color: "green" }, "✅ Status: Ready"),
React.createElement(Text, { color: "gray" }, "Press Ctrl+C to exit")
)
);
}
// Configuration screen with working input fields
if (currentScreen === "configuration") {
const fields = [
{
key: "shopDomain",
label: "Shopify Domain",
placeholder: "your-store.myshopify.com",
},
{
key: "accessToken",
label: "Access Token",
placeholder: "shpat_...",
secret: true,
},
{ key: "targetTag", label: "Target Tag", placeholder: "sale" },
{
key: "priceAdjustment",
label: "Price Adjustment %",
placeholder: "10",
},
{
key: "operationMode",
label: "Operation Mode",
placeholder: "update/rollback",
},
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"⚙️ Configuration"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Edit your Shopify store settings (Press Enter to edit, Esc to cancel)"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "yellow", bold: true },
"📋 Current Configuration:"
),
...fields.map((field, index) => {
const isSelected = selectedIndex === index;
const isEditing = editingField === index;
const value = config[field.key] || "";
const displayValue =
field.secret && value ? "*".repeat(value.length) : value;
return React.createElement(
Box,
{ key: field.key, marginLeft: 2, marginY: 0 },
React.createElement(
Text,
{
color: isSelected ? "blue" : "white",
backgroundColor: isSelected ? "gray" : undefined,
bold: isSelected,
},
`${isSelected ? "► " : " "}${field.label}: `
),
isEditing
? React.createElement(TextInput.default, {
value: tempValue,
placeholder: field.placeholder,
onChange: setTempValue,
mask: field.secret ? "*" : undefined,
})
: React.createElement(
Text,
{ color: value ? "green" : "red" },
value ? displayValue : "[Not configured]"
)
);
})
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "green",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{
color: selectedIndex === 5 ? "black" : "green",
backgroundColor: selectedIndex === 5 ? "green" : undefined,
bold: selectedIndex === 5,
},
`${selectedIndex === 5 ? "► " : " "}💾 Save Configuration`
),
React.createElement(
Text,
{
color: selectedIndex === 6 ? "black" : "blue",
backgroundColor: selectedIndex === 6 ? "blue" : undefined,
bold: selectedIndex === 6,
},
`${selectedIndex === 6 ? "► " : " "}🔙 Back to Menu`
)
),
saveStatus &&
React.createElement(
Text,
{
color: saveStatus.includes("✅") ? "green" : "red",
marginBottom: 1,
},
saveStatus
),
React.createElement(
Text,
{ color: "gray" },
editingField !== null
? "Type your value and press Enter to save, Esc to cancel"
: "Use ↑/↓ to navigate, Enter to edit/select, Esc to go back"
)
);
}
// Operations screen
if (currentScreen === "operation") {
const isConfigured =
config.shopDomain &&
config.accessToken &&
config.targetTag &&
config.priceAdjustment;
const operations = [
{
key: "update",
label: "Update Prices",
description: "Apply percentage adjustment to product prices",
icon: "💰",
},
{
key: "rollback",
label: "Rollback Prices",
description: "Revert prices to compare-at values",
icon: "↩️",
},
{
key: "test",
label: "Test Connection",
description: "Verify Shopify API access and credentials",
icon: "🔗",
},
{
key: "analyze",
label: "Analyze Products",
description: "Preview products that will be affected",
icon: "📊",
},
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🔧 Operations"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Select and execute price update operations"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: isConfigured ? "green" : "red",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: isConfigured ? "green" : "red", bold: true },
isConfigured
? "✅ Configuration Status: Ready"
: "⚠️ Configuration Status: Incomplete"
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Domain: ${config.shopDomain}`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Tag: ${config.targetTag}`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Adjustment: ${config.priceAdjustment}%`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Mode: ${config.operationMode}`
)
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "blue",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "blue", bold: true },
"🚀 Select Operation:"
),
...operations.map((operation, index) => {
const isSelected = selectedIndex === index;
const isEnabled = isConfigured || operation.key === "test";
return React.createElement(
Box,
{ key: operation.key, marginLeft: 1, marginY: 0 },
React.createElement(
Text,
{
color: isSelected ? "black" : isEnabled ? "white" : "gray",
backgroundColor: isSelected ? "blue" : undefined,
bold: isSelected,
},
`${isSelected ? "► " : " "}${operation.icon} ${
operation.label
}`
),
React.createElement(
Text,
{
color: isEnabled ? "gray" : "darkGray",
marginLeft: 4,
},
operation.description
)
);
})
),
// Operation status and progress
operationStatus &&
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: operationStatus.includes("✅")
? "green"
: operationStatus.includes("❌")
? "red"
: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{
color: operationStatus.includes("✅")
? "green"
: operationStatus.includes("❌")
? "red"
: "yellow",
bold: true,
},
operationStatus
),
operationProgress &&
React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{ color: "gray" },
operationProgress.message
),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 0 },
React.createElement(Text, { color: "blue" }, "Progress: "),
React.createElement(
Text,
{ color: "white" },
"█".repeat(Math.floor(operationProgress.current / 5))
),
React.createElement(
Text,
{ color: "gray" },
"░".repeat(20 - Math.floor(operationProgress.current / 5))
),
React.createElement(
Text,
{ color: "blue", marginLeft: 1 },
`${operationProgress.current}%`
)
)
),
operationResults &&
React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{
color: operationResults.success ? "green" : "red",
bold: true,
},
operationResults.message
),
...operationResults.details.map((detail, index) =>
React.createElement(
Text,
{ key: index, color: "gray", marginLeft: 2 },
`${detail}`
)
)
)
),
// Help text
!isConfigured &&
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "yellow", bold: true },
"💡 Configuration Required:"
),
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
"Most operations require configuration. Go to Configuration first."
),
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
"You can still test your connection without full configuration."
)
),
React.createElement(
Text,
{ color: "gray" },
isConfigured
? "Use ↑/↓ to navigate, Enter to execute operation, Esc to go back"
: "Configure your settings first, or test connection. Press Esc to go back"
)
);
}
// Other screens (simplified for now)
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
`📱 ${currentScreen.toUpperCase()} Screen`
),
React.createElement(
Text,
{ color: "gray" },
"This screen is under construction"
),
React.createElement(
Box,
{
marginTop: 1,
borderStyle: "single",
borderColor: "blue",
padding: 1,
},
React.createElement(Text, { color: "blue" }, "🚧 Coming Soon:"),
React.createElement(
Text,
{ marginLeft: 2 },
"• Interactive forms and inputs"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Real-time progress tracking"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Log viewing and filtering"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Advanced scheduling options"
)
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Press Esc to return to main menu"
)
);
};
// Import the main TUI application
console.log("📦 Loading TUI application...");
const TuiApplication = require("./tui/TuiApplication.jsx");
console.log("🎨 Rendering TUI...");
const { waitUntilExit } = render(React.createElement(TuiApp));
// Render the TUI application
const { waitUntilExit } = render(React.createElement(TuiApplication));
// Wait for the application to exit
await waitUntilExit();

View File

@@ -51,3 +51,4 @@ const TuiContent = () => {
};
module.exports = TuiApplication;
module.exports.default = TuiApplication;

View File

@@ -8,7 +8,7 @@ const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx");
const OperationScreen = require("./screens/OperationScreen.jsx");
const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
const ViewLogsScreen = require("./screens/ViewLogsScreen.jsx");
// const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
/**
* Router Component
@@ -25,7 +25,7 @@ const Router = () => {
operation: OperationScreen,
scheduling: SchedulingScreen,
logs: ViewLogsScreen,
// "tag-analysis": TagAnalysisScreen,
"tag-analysis": TagAnalysisScreen,
};
// Get the current screen component

View File

@@ -16,8 +16,10 @@ const ErrorDisplay = ({
retryText = "Press 'r' to retry",
dismissText = "Press 'd' to dismiss",
compact = false,
showTroubleshooting = true,
}) => {
const [dismissed, setDismissed] = React.useState(false);
const [showDetails, setShowDetails] = React.useState(false);
useInput((input, key) => {
if (input === "r" && onRetry && showRetry) {
@@ -28,6 +30,8 @@ const ErrorDisplay = ({
} else {
setDismissed(true);
}
} else if (input === "t" && showTroubleshooting && hasTroubleshooting()) {
setShowDetails(!showDetails);
} else if (key.escape && showDismiss) {
if (onDismiss) {
onDismiss();
@@ -65,6 +69,29 @@ const ErrorDisplay = ({
return "Error";
};
const hasTroubleshooting = () => {
return (
error &&
error.troubleshooting &&
Array.isArray(error.troubleshooting) &&
error.troubleshooting.length > 0
);
};
const getContextInfo = () => {
if (!error || !error.context) return null;
const context = error.context;
const info = [];
if (context.operation) info.push(`Operation: ${context.operation}`);
if (context.retries) info.push(`Retries attempted: ${context.retries}`);
if (context.file) info.push(`File: ${context.file}`);
if (context.filePath) info.push(`File path: ${context.filePath}`);
return info.length > 0 ? info : null;
};
if (compact) {
return (
<Box flexDirection="row" alignItems="center">
@@ -81,6 +108,11 @@ const ErrorDisplay = ({
(d: dismiss)
</Text>
)}
{hasTroubleshooting() && (
<Text color="gray" marginLeft={1}>
(t: help)
</Text>
)}
</Box>
);
}
@@ -105,11 +137,50 @@ const ErrorDisplay = ({
</Text>
</Box>
{/* Context information */}
{getContextInfo() && (
<Box marginBottom={1}>
<Text color="gray" bold>
Context:
</Text>
{getContextInfo().map((info, index) => (
<Text key={index} color="gray" marginLeft={2}>
{info}
</Text>
))}
</Box>
)}
{/* Troubleshooting section */}
{showDetails && hasTroubleshooting() && (
<Box
flexDirection="column"
marginBottom={1}
borderStyle="single"
borderColor="yellow"
padding={1}
>
<Text color="yellow" bold>
💡 Troubleshooting:
</Text>
{error.troubleshooting.map((tip, index) => (
<Text key={index} color="white" marginLeft={2}>
{tip}
</Text>
))}
</Box>
)}
<Box flexDirection="column">
{showRetry && onRetry && <Text color="cyan"> {retryText}</Text>}
{showDismiss && (
<Text color="cyan"> {dismissText} or press Escape</Text>
)}
{hasTroubleshooting() && (
<Text color="cyan">
Press 't' to {showDetails ? "hide" : "show"} troubleshooting tips
</Text>
)}
</Box>
</Box>
);

View File

@@ -0,0 +1,171 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const ErrorDisplay = require("./ErrorDisplay.jsx");
/**
* ScreenErrorBoundary Component
* Specialized error boundary for TUI screens with screen-specific error handling
* Requirements: 4.5, 16.1
*/
class ScreenErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
};
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo,
});
// Log error with screen context
console.error(
`Screen Error in ${this.props.screenName}:`,
error,
errorInfo
);
// Call onError callback if provided
if (this.props.onError) {
this.props.onError(error, errorInfo, this.props.screenName);
}
}
handleRetry = () => {
this.setState((prevState) => ({
hasError: false,
error: null,
errorInfo: null,
retryCount: prevState.retryCount + 1,
}));
if (this.props.onRetry) {
this.props.onRetry(this.state.retryCount + 1);
}
};
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
});
if (this.props.onReset) {
this.props.onReset();
}
};
handleExit = () => {
if (this.props.onExit) {
this.props.onExit();
}
};
render() {
if (this.state.hasError) {
const screenName = this.props.screenName || "Screen";
const maxRetries = this.props.maxRetries || 3;
const canRetry = this.state.retryCount < maxRetries;
// Create enhanced error with screen-specific troubleshooting
const enhancedError = {
...this.state.error,
message: this.state.error.message,
troubleshooting: [
...(this.state.error.troubleshooting || []),
`This error occurred in the ${screenName}`,
"Try navigating back to the main menu and returning to this screen",
"Check if your system has sufficient resources available",
"Restart the application if the problem persists",
...(this.props.screenSpecificTips || []),
],
context: {
...(this.state.error.context || {}),
screen: screenName,
retryCount: this.state.retryCount,
maxRetries: maxRetries,
},
};
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
React.createElement(
Box,
{ marginBottom: 2 },
React.createElement(
Text,
{ color: "red", bold: true },
`💥 ${screenName} Error`
)
),
React.createElement(ErrorDisplay, {
error: enhancedError,
title: `${screenName} Crashed`,
onRetry: canRetry ? this.handleRetry : null,
onDismiss: this.handleExit,
showRetry: canRetry,
showDismiss: true,
retryText: `Press 'r' to retry (${
maxRetries - this.state.retryCount
} attempts left)`,
dismissText: "Press 'd' to exit to main menu",
showTroubleshooting: true,
}),
!canRetry &&
React.createElement(
Box,
{
marginTop: 2,
borderStyle: "single",
borderColor: "red",
padding: 1,
},
React.createElement(
Text,
{ color: "red", bold: true },
"⚠️ Maximum retry attempts reached"
),
React.createElement(
Text,
{ color: "white", marginTop: 1 },
"The screen has crashed multiple times. Please exit and try again later."
)
)
);
}
return this.props.children;
}
}
/**
* Hook component for handling keyboard input in error boundary
*/
const ErrorBoundaryKeyHandler = ({ onRetry, onExit, canRetry }) => {
useInput((input, key) => {
if (input === "r" && onRetry && canRetry) {
onRetry();
} else if (input === "d" || key.escape) {
if (onExit) {
onExit();
}
}
});
return null;
};
module.exports = ScreenErrorBoundary;

View File

@@ -1,11 +1,15 @@
// Export all reusable TUI components
const ErrorDisplay = require("./ErrorDisplay.jsx");
const ErrorBoundary = require("./ErrorBoundary.jsx");
const ScreenErrorBoundary = require("./ScreenErrorBoundary.jsx");
const { LoadingIndicator, LoadingOverlay } = require("./LoadingIndicator.jsx");
const { Pagination, SimplePagination } = require("./Pagination.jsx");
const { FormInput, SimpleFormInput } = require("./FormInput.jsx");
module.exports = {
ErrorDisplay,
ErrorBoundary,
ScreenErrorBoundary,
LoadingIndicator,
LoadingOverlay,
Pagination,

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,13 @@ const { Pagination } = require("../common/Pagination.jsx");
*/
const ViewLogsScreen = () => {
const { navigateBack } = useAppState();
const { getLogFiles, readLogFile } = useServices();
const {
getLogFiles,
readLogFile,
parseLogContent,
getFilteredLogs,
filterLogs,
} = useServices();
// State management for log files, selected file, and content
const [logFiles, setLogFiles] = React.useState([]);
@@ -21,12 +27,28 @@ const ViewLogsScreen = () => {
const [selectedFile, setSelectedFile] = React.useState(null);
const [logContent, setLogContent] = React.useState("");
const [parsedLogs, setParsedLogs] = React.useState([]);
const [filteredLogs, setFilteredLogs] = React.useState([]);
const [currentPage, setCurrentPage] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [loadingContent, setLoadingContent] = React.useState(false);
const [contentError, setContentError] = React.useState(null);
const [viewMode, setViewMode] = React.useState("parsed"); // "parsed" or "raw"
const [scrollOffset, setScrollOffset] = React.useState(0);
const [selectedLogIndex, setSelectedLogIndex] = React.useState(0);
const [showingContent, setShowingContent] = React.useState(false);
// Filter and search state
const [showFilters, setShowFilters] = React.useState(false);
const [filters, setFilters] = React.useState({
dateRange: "all",
operationType: "all",
status: "all",
searchTerm: "",
});
const [filterInputMode, setFilterInputMode] = React.useState(null); // "search", "dateRange", "operationType", "status"
const [searchInput, setSearchInput] = React.useState("");
const [totalUnfilteredEntries, setTotalUnfilteredEntries] = React.useState(0);
// Load log files on component mount
React.useEffect(() => {
@@ -35,10 +57,24 @@ const ViewLogsScreen = () => {
setLoading(true);
setError(null);
const files = await getLogFiles();
setLogFiles(files);
// Transform the files to match expected format
const transformedFiles = files.map((file) => ({
filename: file.name,
path: file.path,
size: file.size,
createdAt: file.created,
modifiedAt: file.modified,
isMainLog: file.isMain,
operationCount: 0, // Will be calculated from content if needed
}));
setLogFiles(transformedFiles);
// Auto-select the main Progress.md file if it exists
const mainLogIndex = files.findIndex((file) => file.isMainLog);
const mainLogIndex = transformedFiles.findIndex(
(file) => file.isMainLog
);
if (mainLogIndex !== -1) {
setSelectedFileIndex(mainLogIndex);
}
@@ -52,34 +88,60 @@ const ViewLogsScreen = () => {
loadLogFiles();
}, [getLogFiles]);
// Load content for selected file
// Load content for selected file with filtering
const loadFileContent = React.useCallback(
async (file) => {
async (file, filterOptions = null) => {
if (!file) return;
try {
setLoadingContent(true);
setContentError(null);
setCurrentPage(0); // Reset pagination
setScrollOffset(0); // Reset scroll
setSelectedLogIndex(0); // Reset selection
const currentFilters = filterOptions || filters;
// Use getFilteredLogs if we have filtering service available
if (getFilteredLogs) {
const result = await getFilteredLogs({
filePath: file.filename,
page: 0,
pageSize: 1000, // Load all entries for client-side pagination
...currentFilters,
});
setLogContent(result.metadata?.rawContent || "");
setParsedLogs(result.entries || []);
setFilteredLogs(result.entries || []);
setTotalUnfilteredEntries(
result.metadata?.totalUnfilteredEntries || 0
);
} else {
// Fallback to original method
const content = await readLogFile(file.filename);
setLogContent(content);
// Parse the content into structured log entries
const { parseLogContent } = useServices();
const parsed = parseLogContent(content);
setParsedLogs(parsed);
setFilteredLogs(parsed);
setTotalUnfilteredEntries(parsed.length);
}
setSelectedFile(file);
setShowingContent(true);
} catch (err) {
setContentError(`Failed to read log file: ${err.message}`);
setLogContent("");
setParsedLogs([]);
setFilteredLogs([]);
setTotalUnfilteredEntries(0);
} finally {
setLoadingContent(false);
}
},
[readLogFile, useServices]
[readLogFile, parseLogContent, getFilteredLogs, filterLogs, filters]
);
// Helper function to format file size
@@ -115,13 +177,246 @@ const ViewLogsScreen = () => {
return date.toLocaleDateString();
};
// Keyboard navigation for log file selection
// Helper function to get color for log level
const getLogLevelColor = (level) => {
switch (level?.toUpperCase()) {
case "ERROR":
return "red";
case "WARNING":
return "yellow";
case "SUCCESS":
return "green";
case "INFO":
default:
return "white";
}
};
// Helper function to get status icon
const getStatusIcon = (level) => {
switch (level?.toUpperCase()) {
case "ERROR":
return "❌";
case "WARNING":
return "⚠️";
case "SUCCESS":
return "✅";
case "INFO":
default:
return "";
}
};
// Helper function to format timestamp
const formatTimestamp = (timestamp) => {
try {
return timestamp.toLocaleString();
} catch (error) {
return "Invalid date";
}
};
// Helper function to truncate text
const truncateText = (text, maxLength = 80) => {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
};
// Apply filters to current log content
const applyFilters = React.useCallback(
async (newFilters) => {
if (!selectedFile) return;
try {
setLoadingContent(true);
setContentError(null);
setCurrentPage(0); // Reset pagination
setSelectedLogIndex(0); // Reset selection
// Use getFilteredLogs if available
if (getFilteredLogs) {
const result = await getFilteredLogs({
filePath: selectedFile.filename,
page: 0,
pageSize: 1000, // Load all entries for client-side pagination
...newFilters,
});
setFilteredLogs(result.entries || []);
setTotalUnfilteredEntries(
result.metadata?.totalUnfilteredEntries || 0
);
} else {
// Fallback: filter parsed logs client-side
if (filterLogs) {
const filtered = filterLogs(parsedLogs, newFilters);
setFilteredLogs(filtered);
} else {
setFilteredLogs(parsedLogs);
}
}
setFilters(newFilters);
} catch (err) {
setContentError(`Failed to apply filters: ${err.message}`);
} finally {
setLoadingContent(false);
}
},
[selectedFile, getFilteredLogs, parsedLogs, filterLogs]
);
// Clear all filters
const clearFilters = React.useCallback(() => {
const defaultFilters = {
dateRange: "all",
operationType: "all",
status: "all",
searchTerm: "",
};
setSearchInput("");
applyFilters(defaultFilters);
}, [applyFilters]);
// Handle search input
const handleSearchInput = React.useCallback(
(newSearchTerm) => {
setSearchInput(newSearchTerm);
const newFilters = { ...filters, searchTerm: newSearchTerm };
applyFilters(newFilters);
},
[filters, applyFilters]
);
// Handle filter changes
const handleFilterChange = React.useCallback(
(filterType, value) => {
const newFilters = { ...filters, [filterType]: value };
applyFilters(newFilters);
},
[filters, applyFilters]
);
// Keyboard navigation for log file selection and content viewing
useInput((input, key) => {
if (loading) return; // Ignore input while loading
// Handle filter input mode
if (filterInputMode === "search") {
if (key.escape) {
setFilterInputMode(null);
setSearchInput(filters.searchTerm); // Reset to current filter
} else if (key.return) {
handleSearchInput(searchInput);
setFilterInputMode(null);
} else if (key.backspace) {
setSearchInput((prev) => prev.slice(0, -1));
} else if (input && input.length === 1 && !key.ctrl && !key.meta) {
setSearchInput((prev) => prev + input);
}
return;
}
if (key.escape) {
if (showFilters && showingContent) {
// Close filters panel
setShowFilters(false);
} else if (showingContent) {
// Go back to file list
setShowingContent(false);
setSelectedFile(null);
setLogContent("");
setParsedLogs([]);
setFilteredLogs([]);
setCurrentPage(0);
setScrollOffset(0);
setSelectedLogIndex(0);
setShowFilters(false);
} else {
navigateBack();
} else if (key.upArrow) {
}
} else if (showingContent) {
// Handle filter panel navigation
if (showFilters) {
if (input === "s") {
// Start search input
setFilterInputMode("search");
setSearchInput(filters.searchTerm);
} else if (input === "d") {
// Cycle through date range options
const dateOptions = ["all", "today", "yesterday", "week", "month"];
const currentIndex = dateOptions.indexOf(filters.dateRange);
const nextIndex = (currentIndex + 1) % dateOptions.length;
handleFilterChange("dateRange", dateOptions[nextIndex]);
} else if (input === "t") {
// Cycle through operation type options
const typeOptions = ["all", "update", "rollback", "scheduled"];
const currentIndex = typeOptions.indexOf(filters.operationType);
const nextIndex = (currentIndex + 1) % typeOptions.length;
handleFilterChange("operationType", typeOptions[nextIndex]);
} else if (input === "l") {
// Cycle through status/level options
const statusOptions = ["all", "error", "warning", "success", "info"];
const currentIndex = statusOptions.indexOf(filters.status);
const nextIndex = (currentIndex + 1) % statusOptions.length;
handleFilterChange("status", statusOptions[nextIndex]);
} else if (input === "c") {
// Clear all filters
clearFilters();
}
}
// Handle content viewing navigation
if (viewMode === "parsed") {
// Navigation for parsed log entries
if (key.upArrow) {
setSelectedLogIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedLogIndex((prev) =>
Math.min(filteredLogs.length - 1, prev + 1)
);
} else if (key.pageUp) {
setCurrentPage((prev) => Math.max(0, prev - 1));
} else if (key.pageDown) {
const totalPages = Math.ceil(filteredLogs.length / 10);
setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1));
} else if (input === "v") {
// Toggle view mode
setViewMode(viewMode === "parsed" ? "raw" : "parsed");
} else if (input === "f") {
// Toggle filters panel
setShowFilters(!showFilters);
} else if (input === "r") {
// Refresh content
if (selectedFile) {
loadFileContent(selectedFile);
}
}
} else {
// Navigation for raw content
if (key.upArrow) {
setScrollOffset((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
const maxLines = logContent.split("\n").length;
setScrollOffset((prev) => Math.min(maxLines - 10, prev + 1));
} else if (key.pageUp) {
setScrollOffset((prev) => Math.max(0, prev - 10));
} else if (key.pageDown) {
const maxLines = logContent.split("\n").length;
setScrollOffset((prev) => Math.min(maxLines - 10, prev + 10));
} else if (input === "v") {
// Toggle view mode
setViewMode(viewMode === "parsed" ? "raw" : "parsed");
} else if (input === "r") {
// Refresh content
if (selectedFile) {
loadFileContent(selectedFile);
}
}
}
} else {
// Handle file list navigation
if (key.upArrow) {
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedFileIndex((prev) => Math.min(logFiles.length - 1, prev + 1));
@@ -135,12 +430,24 @@ const ViewLogsScreen = () => {
setLoading(true);
setError(null);
getLogFiles()
.then(setLogFiles)
.then((files) => {
const transformedFiles = files.map((file) => ({
filename: file.name,
path: file.path,
size: file.size,
createdAt: file.created,
modifiedAt: file.modified,
isMainLog: file.isMain,
operationCount: 0,
}));
setLogFiles(transformedFiles);
})
.catch((err) =>
setError(`Failed to discover log files: ${err.message}`)
)
.finally(() => setLoading(false));
}
}
});
// Show loading state
@@ -178,52 +485,59 @@ const ViewLogsScreen = () => {
}
// Show file content view if a file is selected
if (selectedFile) {
if (showingContent && selectedFile) {
const pageSize = 10;
const totalPages = Math.ceil(filteredLogs.length / pageSize);
const startIndex = currentPage * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredLogs.length);
const currentPageLogs = filteredLogs.slice(startIndex, endIndex);
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 2 },
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ bold: true, color: "cyan" },
"📄 Log File Content"
),
React.createElement(
Text,
{ color: "yellow", bold: true },
viewMode === "parsed" ? "PARSED VIEW" : "RAW VIEW"
)
),
React.createElement(
Text,
{ color: "gray" },
`Viewing: ${selectedFile.filename}`
`Viewing: ${selectedFile.filename} (${filteredLogs.length}/${totalUnfilteredEntries} entries)`
)
),
// File metadata
// Filter panel (if enabled)
showFilters &&
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "blue",
paddingX: 2,
borderColor: "yellow",
paddingX: 1,
paddingY: 1,
marginBottom: 2,
marginBottom: 1,
},
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ color: "white", bold: true },
selectedFile.filename
),
React.createElement(
Text,
{ color: selectedFile.isMainLog ? "green" : "gray" },
selectedFile.isMainLog ? "MAIN LOG" : "ARCHIVE"
)
{ color: "yellow", bold: true },
"🔍 Filters & Search"
),
React.createElement(
Box,
@@ -233,13 +547,13 @@ const ViewLogsScreen = () => {
{ flexDirection: "column", width: "50%" },
React.createElement(
Text,
{ color: "cyan" },
`Size: ${formatFileSize(selectedFile.size)}`
{ color: "gray" },
`Date Range: ${filters.dateRange}`
),
React.createElement(
Text,
{ color: "cyan" },
`Operations: ${selectedFile.operationCount}`
{ color: "gray" },
`Operation: ${filters.operationType}`
)
),
React.createElement(
@@ -247,15 +561,39 @@ const ViewLogsScreen = () => {
{ flexDirection: "column", width: "50%" },
React.createElement(
Text,
{ color: "cyan" },
`Created: ${getRelativeTime(selectedFile.createdAt)}`
{ color: "gray" },
`Status: ${filters.status}`
),
React.createElement(
Text,
{ color: "cyan" },
`Modified: ${getRelativeTime(selectedFile.modifiedAt)}`
{ color: "gray" },
`Search: ${filters.searchTerm || "(none)"}`
)
)
),
filterInputMode === "search" &&
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "cyan",
paddingX: 1,
marginTop: 1,
},
React.createElement(
Text,
{ color: "cyan" },
`Search: ${searchInput}`
)
),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 1 },
React.createElement(
Text,
{ color: "gray" },
"S-Search D-Date T-Type L-Level C-Clear F-Close"
)
)
)
),
@@ -269,7 +607,7 @@ const ViewLogsScreen = () => {
paddingX: 1,
paddingY: 1,
flexGrow: 1,
marginBottom: 2,
marginBottom: 1,
},
loadingContent
? React.createElement(LoadingIndicator, {
@@ -280,29 +618,201 @@ const ViewLogsScreen = () => {
error: { message: contentError },
onRetry: () => loadFileContent(selectedFile),
})
: logContent
: !logContent
? React.createElement(
Box,
{ justifyContent: "center", alignItems: "center", padding: 2 },
React.createElement(
Text,
{ color: "gray", bold: true },
"📄 Empty Log File"
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"This log file is empty or could not be read."
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Log entries are created when operations are performed."
)
)
: viewMode === "parsed"
? // Parsed view with syntax highlighting
React.createElement(
Box,
{ flexDirection: "column" },
filteredLogs.length === 0
? React.createElement(
Box,
{
justifyContent: "center",
alignItems: "center",
padding: 2,
},
React.createElement(
Text,
{ color: "gray", bold: true },
totalUnfilteredEntries === 0
? "📄 No Parsed Entries"
: "🔍 No Matching Entries"
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
totalUnfilteredEntries === 0
? "The log file contains no parseable entries."
: `No entries match the current filters. (${totalUnfilteredEntries} total entries)`
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
totalUnfilteredEntries === 0
? "Press 'v' to view raw content."
: "Press 'f' to adjust filters or 'c' to clear them."
)
)
: currentPageLogs.map((entry, index) => {
const globalIndex = startIndex + index;
const isSelected = selectedLogIndex === globalIndex;
const levelColor = getLogLevelColor(entry.level);
const statusIcon = getStatusIcon(entry.level);
return React.createElement(
Box,
{
key: entry.id,
flexDirection: "column",
paddingY: 1,
paddingX: 1,
backgroundColor: isSelected ? "blue" : undefined,
borderStyle: isSelected ? "single" : "none",
borderColor: isSelected ? "cyan" : "gray",
marginBottom: 1,
},
// Entry header
React.createElement(
Box,
{
flexDirection: "row",
justifyContent: "space-between",
},
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(
Text,
{ color: levelColor, bold: true },
`${statusIcon} ${entry.title}`
)
),
React.createElement(
Text,
{ color: "gray" },
formatTimestamp(entry.timestamp)
)
),
// Entry message
React.createElement(
Text,
{
color: isSelected ? "white" : "gray",
marginTop: 1,
marginLeft: 2,
},
truncateText(entry.message, 70)
),
// Entry details (if available and selected)
isSelected &&
entry.details &&
entry.details.trim() &&
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "gray",
paddingX: 1,
paddingY: 1,
marginTop: 1,
marginLeft: 2,
},
React.createElement(
Text,
{ color: "white", wrap: "wrap" },
logContent.substring(0, 2000) // Show first 2000 characters
),
logContent.length > 2000 &&
entry.details.trim()
)
)
);
})
)
: // Raw view with basic syntax highlighting
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Box,
{ flexDirection: "column" },
logContent
.split("\n")
.slice(scrollOffset, scrollOffset + 15)
.map((line, index) => {
const lineNumber = scrollOffset + index + 1;
const trimmedLine = line.trim();
// Determine line color based on content
let lineColor = "white";
if (trimmedLine.startsWith("##")) {
lineColor = "cyan"; // Operation headers
} else if (
trimmedLine.startsWith("**") &&
trimmedLine.endsWith("**")
) {
lineColor = "yellow"; // Section headers
} else if (trimmedLine.includes("❌")) {
lineColor = "red"; // Errors
} else if (trimmedLine.includes("⚠️")) {
lineColor = "yellow"; // Warnings
} else if (trimmedLine.includes("✅")) {
lineColor = "green"; // Success
} else if (trimmedLine.startsWith("-")) {
lineColor = "gray"; // List items
}
return React.createElement(
Box,
{
key: lineNumber,
flexDirection: "row",
},
React.createElement(
Text,
{ color: "yellow", marginTop: 1 },
`... (${logContent.length - 2000} more characters)`
)
)
: React.createElement(
{ color: "gray", width: 4 },
String(lineNumber).padStart(3, " ") + " "
),
React.createElement(
Text,
{ color: "gray" },
"File is empty or could not be read"
{ color: lineColor, wrap: "wrap" },
line || " "
)
);
})
)
)
),
// Pagination for parsed view
viewMode === "parsed" &&
filteredLogs.length > pageSize &&
React.createElement(Pagination, {
currentPage,
totalPages,
totalItems: filteredLogs.length,
itemsPerPage: pageSize,
onPageChange: setCurrentPage,
compact: true,
}),
// Instructions
React.createElement(
Box,
@@ -311,12 +821,60 @@ const ViewLogsScreen = () => {
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 1,
marginTop: 1,
},
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 1 },
React.createElement(
Box,
{ flexDirection: "column", width: "33%" },
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
" Esc - Back to file list R - Refresh content"
{ color: "gray" },
" ↑/↓ - Navigate entries"
),
React.createElement(
Text,
{ color: "gray" },
" PgUp/PgDn - Page navigation"
)
),
React.createElement(
Box,
{ flexDirection: "column", width: "33%" },
React.createElement(
Text,
{ color: "gray" },
" V - Toggle view mode"
),
React.createElement(Text, { color: "gray" }, " F - Toggle filters")
),
React.createElement(
Box,
{ flexDirection: "column", width: "34%" },
React.createElement(
Text,
{ color: "gray" },
" R - Refresh content"
),
React.createElement(
Text,
{ color: "gray" },
" Esc - Back to file list"
)
)
),
showFilters &&
React.createElement(
Box,
{ flexDirection: "row", marginTop: 1 },
React.createElement(
Text,
{ color: "yellow" },
"Filter Controls: S-Search D-Date T-Type L-Level C-Clear"
)
)
)
);

View File

@@ -0,0 +1,62 @@
const React = require("react");
const { ScreenErrorBoundary } = require("../common");
const { enhanceError, logError } = require("../../utils/errorHandler");
/**
* Higher-order component that wraps screens with error boundaries
* Requirements: 4.5, 16.1
*/
function withErrorBoundary(
WrappedComponent,
screenName,
screenSpecificTips = []
) {
const WrappedScreenComponent = (props) => {
const handleError = (error, errorInfo) => {
// Log the error with screen context
logError(error, {
screen: screenName,
errorInfo,
props: Object.keys(props),
});
};
const handleRetry = (retryCount) => {
console.info(`Retrying ${screenName}, attempt ${retryCount}`);
};
const handleReset = () => {
console.info(`Resetting ${screenName} after error`);
};
const handleExit = () => {
// Navigate back to main menu
if (props.navigateBack) {
props.navigateBack();
} else if (props.onExit) {
props.onExit();
}
};
return React.createElement(
ScreenErrorBoundary,
{
screenName,
screenSpecificTips,
onError: handleError,
onRetry: handleRetry,
onReset: handleReset,
onExit: handleExit,
maxRetries: 3,
},
React.createElement(WrappedComponent, props)
);
};
// Set display name for debugging
WrappedScreenComponent.displayName = `withErrorBoundary(${screenName})`;
return WrappedScreenComponent;
}
module.exports = withErrorBoundary;

View File

@@ -0,0 +1,313 @@
const {
enhanceError,
isRetryableError,
getRetryDelay,
createStandardError,
logError,
} = require("../utils/errorHandler");
/**
* ErrorHandlingService - Centralized error handling for TUI operations
* Requirements: 4.5, 16.1
*/
class ErrorHandlingService {
constructor() {
this.errorHistory = [];
this.maxHistorySize = 100;
}
/**
* Execute an operation with comprehensive error handling and retry logic
* @param {Function} operation - Async operation to execute
* @param {Object} options - Error handling options
* @returns {Promise} Operation result
*/
async executeWithRetry(operation, options = {}) {
const {
maxRetries = 3,
retryDelay = null,
context = {},
onRetry = null,
onError = null,
retryableCheck = null,
} = options;
let lastError = null;
let attempt = 0;
while (attempt < maxRetries) {
attempt++;
try {
const result = await operation();
// Log successful retry if this wasn't the first attempt
if (attempt > 1) {
console.info(
`Operation succeeded on attempt ${attempt}/${maxRetries}`
);
}
return result;
} catch (error) {
lastError = error;
// Enhance the error with context
const enhancedError = enhanceError(error, {
...context,
attempt,
maxRetries,
});
// Log the error
logError(enhancedError, context);
// Add to error history
this.addToHistory(enhancedError);
// Call error callback if provided
if (onError) {
onError(enhancedError, attempt);
}
// Check if we should retry
const shouldRetry = retryableCheck
? retryableCheck(enhancedError)
: isRetryableError(enhancedError);
if (attempt >= maxRetries || !shouldRetry) {
break;
}
// Calculate delay
const delay =
retryDelay || getRetryDelay(attempt, enhancedError.category);
console.warn(
`Operation failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms:`,
error.message
);
// Call retry callback if provided
if (onRetry) {
onRetry(attempt, delay, enhancedError);
}
// Wait before retrying
await this.delay(delay);
}
}
// All retries failed, throw the last error
throw enhanceError(lastError, {
...context,
finalAttempt: true,
totalAttempts: attempt,
message: `Operation failed after ${attempt} attempts`,
});
}
/**
* Handle file operation errors with specific retry logic
* @param {Function} fileOperation - File operation to execute
* @param {Object} options - Options
* @returns {Promise} Operation result
*/
async handleFileOperation(fileOperation, options = {}) {
return this.executeWithRetry(fileOperation, {
maxRetries: 3,
context: {
operation: "file_operation",
...options.context,
},
retryableCheck: (error) => {
// File operations are retryable for certain errors
const retryableCodes = ["EBUSY", "EMFILE", "ENFILE", "EAGAIN"];
return (
retryableCodes.includes(error.code) ||
error.message.includes("locked") ||
error.message.includes("busy")
);
},
...options,
});
}
/**
* Handle API operation errors with exponential backoff
* @param {Function} apiOperation - API operation to execute
* @param {Object} options - Options
* @returns {Promise} Operation result
*/
async handleApiOperation(apiOperation, options = {}) {
return this.executeWithRetry(apiOperation, {
maxRetries: 5,
context: {
operation: "api_operation",
...options.context,
},
retryableCheck: (error) => {
// API operations are retryable for network and rate limit errors
return (
isRetryableError(error) ||
error.message.includes("rate limit") ||
error.message.includes("timeout")
);
},
...options,
});
}
/**
* Handle validation errors with user-friendly messages
* @param {Function} validationOperation - Operation that might have validation errors
* @param {Object} options - Options
* @returns {Promise} Operation result
*/
async handleValidation(validationOperation, options = {}) {
try {
return await validationOperation();
} catch (error) {
// Don't retry validation errors, but enhance them
const enhancedError = enhanceError(error, {
operation: "validation",
...options.context,
troubleshooting: [
"Check that all required fields are filled",
"Verify the data format is correct",
"Ensure values are within acceptable ranges",
...(options.troubleshooting || []),
],
});
this.addToHistory(enhancedError);
throw enhancedError;
}
}
/**
* Create a graceful fallback for missing files
* @param {Function} fileReader - Function to read file
* @param {*} fallbackValue - Value to return if file doesn't exist
* @param {Object} options - Options
* @returns {Promise} File content or fallback value
*/
async gracefulFileRead(fileReader, fallbackValue, options = {}) {
try {
return await fileReader();
} catch (error) {
if (error.code === "ENOENT") {
console.info(
`File not found, using fallback value: ${
options.filename || "unknown file"
}`
);
return fallbackValue;
}
// For other errors, use normal error handling
throw enhanceError(error, {
operation: "graceful_file_read",
filename: options.filename,
...options.context,
});
}
}
/**
* Add error to history for debugging
* @param {Error} error - Error to add
*/
addToHistory(error) {
this.errorHistory.unshift({
timestamp: new Date().toISOString(),
error: {
message: error.message,
category: error.category,
context: error.context,
stack: error.stack,
},
});
// Keep history size manageable
if (this.errorHistory.length > this.maxHistorySize) {
this.errorHistory = this.errorHistory.slice(0, this.maxHistorySize);
}
}
/**
* Get recent error history
* @param {number} limit - Number of recent errors to return
* @returns {Array} Recent errors
*/
getErrorHistory(limit = 10) {
return this.errorHistory.slice(0, limit);
}
/**
* Clear error history
*/
clearHistory() {
this.errorHistory = [];
}
/**
* Get error statistics
* @returns {Object} Error statistics
*/
getErrorStats() {
const categories = {};
const operations = {};
this.errorHistory.forEach((entry) => {
const category = entry.error.category || "unknown";
const operation = entry.error.context?.operation || "unknown";
categories[category] = (categories[category] || 0) + 1;
operations[operation] = (operations[operation] || 0) + 1;
});
return {
totalErrors: this.errorHistory.length,
categories,
operations,
mostRecentError: this.errorHistory[0] || null,
};
}
/**
* Create a user-friendly error message for display
* @param {Error} error - Error to format
* @returns {string} User-friendly message
*/
formatUserMessage(error) {
if (!error) return "An unknown error occurred";
const category = error.category || "system";
const baseMessage = error.message;
const categoryMessages = {
network: "Connection problem - please check your internet connection",
api: "Shopify API issue - please verify your store settings",
file: "File access problem - please check file permissions",
validation: "Input validation error - please check your data",
system: "System error - please try again",
};
const categoryMessage =
categoryMessages[category] || categoryMessages.system;
return `${categoryMessage}: ${baseMessage}`;
}
/**
* Delay execution
* @param {number} ms - Milliseconds to delay
* @returns {Promise} Promise that resolves after delay
*/
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = ErrorHandlingService;

View File

@@ -1,5 +1,10 @@
const fs = require("fs").promises;
const fs = require("fs");
const { promisify } = require("util");
const readFile = promisify(fs.readFile);
const stat = promisify(fs.stat);
const readdir = promisify(fs.readdir);
const path = require("path");
const ErrorHandlingService = require("./ErrorHandlingService");
/**
* LogService - Reads and parses Progress.md files for TUI log viewing
@@ -10,6 +15,7 @@ class LogService {
this.progressFilePath = progressFilePath;
this.cache = new Map();
this.cacheExpiry = 2 * 60 * 1000; // 2 minutes
this.errorHandler = new ErrorHandlingService();
}
/**
@@ -22,7 +28,7 @@ class LogService {
// Check main Progress.md file
try {
const stats = await fs.stat(this.progressFilePath);
const stats = await stat(this.progressFilePath);
files.push({
name: "Progress.md",
path: this.progressFilePath,
@@ -32,20 +38,28 @@ class LogService {
isMain: true,
});
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
if (error.code === "ENOENT") {
// Main log file doesn't exist - this is normal for new installations
console.info(
`Main log file ${this.progressFilePath} not found - no operations have been performed yet`
);
} else {
// Other error accessing the file
console.warn(
`Could not access main log file ${this.progressFilePath}: ${error.message}`
);
}
}
// Look for archived log files (Progress_YYYY-MM-DD.md pattern)
try {
const currentDir = await fs.readdir(".");
const currentDir = await readdir(".");
const logFiles = currentDir.filter((file) =>
file.match(/^Progress_\d{4}-\d{2}-\d{2}\.md$/)
);
for (const file of logFiles) {
const stats = await fs.stat(file);
const stats = await stat(file);
files.push({
name: file,
path: file,
@@ -62,7 +76,16 @@ class LogService {
// Sort by modification date (newest first)
return files.sort((a, b) => b.modified.getTime() - a.modified.getTime());
} catch (error) {
throw new Error(`Failed to get log files: ${error.message}`);
throw this.createEnhancedError("Failed to get log files", error, {
operation: "getLogFiles",
progressFile: this.progressFilePath,
troubleshooting: [
"Check if you have read permissions to the current directory",
"Verify the Progress.md file exists (it's created after running operations)",
"Run some price update operations to generate log files",
"Ensure the application has access to the file system",
],
});
}
}
@@ -74,18 +97,18 @@ class LogService {
async readLogFile(filePath = null) {
const targetPath = filePath || this.progressFilePath;
try {
const content = await fs.readFile(targetPath, "utf8");
return content;
} catch (error) {
if (error.code === "ENOENT") {
return ""; // Return empty string for non-existent files
return this.errorHandler.gracefulFileRead(
() => readFile(targetPath, "utf8"),
"", // fallback to empty string
{
filename: targetPath,
context: {
operation: "readLogFile",
filePath: targetPath,
},
}
throw new Error(
`Failed to read log file ${targetPath}: ${error.message}`
);
}
}
/**
* Parse log content into structured entries
@@ -102,21 +125,19 @@ class LogService {
let currentOperation = null;
let currentSection = null;
let lineIndex = 0;
let inCodeBlock = false;
for (const line of lines) {
lineIndex++;
const trimmedLine = line.trim();
// Skip empty lines and markdown headers
if (
!trimmedLine ||
trimmedLine.startsWith("#") ||
trimmedLine === "---"
) {
// Handle code block boundaries
if (trimmedLine === "```") {
inCodeBlock = !inCodeBlock;
continue;
}
// Parse operation headers (## Operation Type - Timestamp)
// Parse operation headers first (## Operation Type - Timestamp) - these are outside code blocks
const operationMatch = trimmedLine.match(/^## (.+) - (.+)$/);
if (operationMatch) {
const [, operationType, timestamp] = operationMatch;
@@ -140,6 +161,19 @@ class LogService {
continue;
}
// Skip empty lines and markdown headers outside code blocks
if (
!trimmedLine ||
(!inCodeBlock && (trimmedLine.startsWith("#") || trimmedLine === "---"))
) {
continue;
}
// Only process content inside code blocks after finding an operation header
if (!inCodeBlock) {
continue;
}
// Parse section headers
if (trimmedLine.startsWith("**") && trimmedLine.endsWith("**")) {
const sectionTitle = trimmedLine.slice(2, -2);
@@ -219,7 +253,7 @@ class LogService {
parseProgressLine(line, operation, entries, lineNumber) {
// Parse product update lines with status indicators
const updateMatch = line.match(
/^- ([✅❌🔄⚠️]) \*\*(.+?)\*\* \((.+?)\)(.*)$/
/^-\s*([✅❌🔄⚠️])\s*\*\*(.+?)\*\*\s*\((.+?)\)(.*)$/
);
if (updateMatch) {
const [, status, productTitle, productId, details] = updateMatch;
@@ -460,7 +494,17 @@ class LogService {
return result;
} catch (error) {
throw new Error(`Failed to get filtered logs: ${error.message}`);
throw this.createEnhancedError("Failed to get filtered logs", error, {
operation: "getFilteredLogs",
options,
troubleshooting: [
"Check if the log file exists and is readable",
"Verify the file contains valid log format",
"Try clearing filters if no results are shown",
"Ensure operations have been run to generate logs",
"Check if the log file is corrupted",
],
});
}
}
@@ -535,6 +579,27 @@ class LogService {
keys: Array.from(this.cache.keys()),
};
}
/**
* Create enhanced error with troubleshooting information
* @param {string} message - Base error message
* @param {Error} originalError - Original error
* @param {Object} context - Additional context
* @returns {Error} Enhanced error
*/
createEnhancedError(message, originalError, context = {}) {
const error = new Error(message);
error.originalError = originalError;
error.context = context;
error.timestamp = new Date().toISOString();
// Add troubleshooting information
if (context.troubleshooting) {
error.troubleshooting = context.troubleshooting;
}
return error;
}
}
module.exports = LogService;

View File

@@ -1,5 +1,10 @@
const fs = require("fs").promises;
const fs = require("fs");
const { promisify } = require("util");
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const stat = promisify(fs.stat);
const path = require("path");
const ErrorHandlingService = require("./ErrorHandlingService");
/**
* ScheduleService - Manages scheduled operations with JSON persistence for TUI
@@ -10,6 +15,7 @@ class ScheduleService {
this.schedulesFile = "schedules.json";
this.schedules = [];
this.isLoaded = false;
this.errorHandler = new ErrorHandlingService();
}
/**
@@ -17,19 +23,84 @@ class ScheduleService {
* @returns {Promise<Array>} Array of schedules
*/
async loadSchedules() {
try {
const data = await fs.readFile(this.schedulesFile, "utf8");
return this.errorHandler.gracefulFileRead(
async () => {
const data = await readFile(this.schedulesFile, "utf8");
this.schedules = JSON.parse(data);
this.isLoaded = true;
return this.schedules;
} catch (error) {
},
[], // fallback to empty array
{
filename: this.schedulesFile,
context: { operation: 'loadSchedules' }
}
).catch(error => {
if (error.code === "ENOENT") {
// File doesn't exist, start with empty array
// File doesn't exist, create it with empty array
console.info(
`Schedules file ${this.schedulesFile} not found, creating new file`
);
this.schedules = [];
this.isLoaded = true;
// Create the file to avoid future ENOENT errors
try {
await this.saveSchedules([]);
} catch (saveError) {
console.warn(`Could not create schedules file: ${saveError.message}`);
}
return this.schedules;
}
throw new Error(`Failed to load schedules: ${error.message}`);
if (error.name === "SyntaxError") {
// JSON parsing error - file is corrupted
const backupFile = `${this.schedulesFile}.backup.${Date.now()}`;
console.warn(
`Schedules file is corrupted, backing up to ${backupFile}`
);
try {
const corruptedData = await readFile(this.schedulesFile, "utf8");
await writeFile(backupFile, corruptedData);
} catch (backupError) {
console.error(
`Failed to backup corrupted file: ${backupError.message}`
);
}
// Start with empty schedules
this.schedules = [];
this.isLoaded = true;
await this.saveSchedules([]);
throw this.createEnhancedError(
"Schedules file was corrupted and has been reset",
error,
{
operation: "loadSchedules",
backupFile,
troubleshooting: [
`A backup of the corrupted file was saved as ${backupFile}`,
"You can manually recover schedules from the backup if needed",
"The schedules file has been reset to empty",
"This usually happens due to incomplete writes or system crashes",
],
}
);
}
throw this.createEnhancedError("Failed to load schedules", error, {
operation: "loadSchedules",
file: this.schedulesFile,
troubleshooting: [
"Check if the schedules.json file exists and is readable",
"Verify file permissions allow reading",
"Ensure the file is not locked by another process",
"Try restarting the application",
],
});
}
}
@@ -39,19 +110,37 @@ class ScheduleService {
* @returns {Promise<void>}
*/
async saveSchedules(schedules = null) {
try {
const dataToSave = schedules || this.schedules;
await fs.writeFile(
this.schedulesFile,
JSON.stringify(dataToSave, null, 2)
);
return this.errorHandler.handleFileOperation(
async () => {
// Create backup before writing
const backupData = JSON.stringify(dataToSave, null, 2);
// Validate JSON before writing
JSON.parse(backupData); // This will throw if invalid
await writeFile(this.schedulesFile, backupData);
if (!schedules) {
this.schedules = dataToSave;
}
} catch (error) {
throw new Error(`Failed to save schedules: ${error.message}`);
},
{
context: {
operation: 'saveSchedules',
file: this.schedulesFile,
troubleshooting: [
'Check if you have write permissions to the current directory',
'Ensure there is enough disk space available',
'Verify the file is not locked by another process',
'Try closing other applications that might be using the file',
'Check if antivirus software is blocking file writes'
]
}
}
);
}
/**
* Add a new schedule
@@ -313,6 +402,36 @@ class ScheduleService {
lastError: error,
});
}
/**
* Create enhanced error with troubleshooting information
* @param {string} message - Base error message
* @param {Error} originalError - Original error
* @param {Object} context - Additional context
* @returns {Error} Enhanced error
*/
createEnhancedError(message, originalError, context = {}) {
const error = new Error(message);
error.originalError = originalError;
error.context = context;
error.timestamp = new Date().toISOString();
// Add troubleshooting information
if (context.troubleshooting) {
error.troubleshooting = context.troubleshooting;
}
return error;
}
/**
* Delay execution for retry logic
* @param {number} ms - Milliseconds to delay
* @returns {Promise} Promise that resolves after delay
*/
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = ScheduleService;

View File

@@ -1,3 +1,5 @@
const ErrorHandlingService = require("./ErrorHandlingService");
/**
* TagAnalysisService - Fetches and analyzes Shopify product tags for TUI
* Requirements: 5.3, 3.1, 3.2, 3.3, 3.4, 3.6
@@ -8,6 +10,7 @@ class TagAnalysisService {
this.productService = productService;
this.cache = new Map();
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
this.errorHandler = new ErrorHandlingService();
}
/**
@@ -24,13 +27,30 @@ class TagAnalysisService {
}
try {
// Use existing ProductService method to fetch products with tags
const products = await this.productService.debugFetchAllProductTags(
limit
// Use error handler for API operations with retry logic
const products = await this.errorHandler.handleApiOperation(
() => this.productService.debugFetchAllProductTags(limit),
{
context: {
operation: "fetchAllTags",
limit,
},
}
);
if (!products || products.length === 0) {
return [];
// Return empty result structure instead of empty array
return {
tags: [],
metadata: {
totalProducts: 0,
totalVariants: 0,
totalTags: 0,
analyzedAt: new Date().toISOString(),
limit,
warning: "No products found in store or no products have tags",
},
};
}
// Analyze tags from products
@@ -122,7 +142,8 @@ class TagAnalysisService {
return result;
} catch (error) {
throw new Error(`Failed to fetch tags: ${error.message}`);
// Error handler already enhanced the error with troubleshooting
throw error;
}
}
@@ -140,8 +161,16 @@ class TagAnalysisService {
}
try {
// Fetch products with this specific tag
const products = await this.productService.fetchProductsByTag(tag);
// Use error handler for API operations
const products = await this.errorHandler.handleApiOperation(
() => this.productService.fetchProductsByTag(tag),
{
context: {
operation: "getTagDetails",
tag,
},
}
);
if (!products || products.length === 0) {
return {
@@ -191,8 +220,19 @@ class TagAnalysisService {
return result;
} catch (error) {
throw new Error(
`Failed to get tag details for "${tag}": ${error.message}`
throw this.createEnhancedError(
`Failed to get tag details for "${tag}"`,
error,
{
operation: "getTagDetails",
tag,
troubleshooting: [
"Verify the tag name exists in your store",
"Check your Shopify API connection",
"Try refreshing the tag analysis",
"Ensure the tag contains products with valid data",
],
}
);
}
}
@@ -443,7 +483,16 @@ class TagAnalysisService {
analyzedAt: new Date().toISOString(),
};
} catch (error) {
throw new Error(`Failed to compare tags: ${error.message}`);
throw this.createEnhancedError("Failed to compare tags", error, {
operation: "compareTagsAsync",
tagNames,
troubleshooting: [
"Verify all tag names exist in your store",
"Check your Shopify API connection",
"Try comparing fewer tags at once",
"Ensure tags contain products with valid pricing data",
],
});
}
}
@@ -519,6 +568,59 @@ class TagAnalysisService {
(tag) => tag.averagePrice >= minPrice && tag.averagePrice <= maxPrice
);
}
/**
* Check if an error is retryable (network/API issues)
* @param {Error} error - Error to check
* @returns {boolean} True if error is retryable
*/
isRetryableError(error) {
const retryablePatterns = [
/network/i,
/timeout/i,
/rate limit/i,
/503/,
/502/,
/500/,
/ECONNRESET/,
/ENOTFOUND/,
/ETIMEDOUT/,
];
return retryablePatterns.some(
(pattern) => pattern.test(error.message) || pattern.test(error.code)
);
}
/**
* Create enhanced error with troubleshooting information
* @param {string} message - Base error message
* @param {Error} originalError - Original error
* @param {Object} context - Additional context
* @returns {Error} Enhanced error
*/
createEnhancedError(message, originalError, context = {}) {
const error = new Error(message);
error.originalError = originalError;
error.context = context;
error.timestamp = new Date().toISOString();
// Add troubleshooting information
if (context.troubleshooting) {
error.troubleshooting = context.troubleshooting;
}
return error;
}
/**
* Delay execution for retry logic
* @param {number} ms - Milliseconds to delay
* @returns {Promise} Promise that resolves after delay
*/
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = TagAnalysisService;

View File

@@ -0,0 +1,346 @@
/**
* Centralized Error Handler Utility
* Provides consistent error handling and messaging across TUI screens
* Requirements: 4.5, 16.1
*/
/**
* Common troubleshooting tips for different error types
*/
const COMMON_TROUBLESHOOTING = {
network: [
"Check your internet connection",
"Verify your network settings",
"Try again in a few moments",
"Check if your firewall is blocking the connection",
],
api: [
"Verify your Shopify API credentials are correct",
"Check if your Shopify store is accessible",
"Ensure your API access token has the required permissions",
"Try refreshing your API connection",
],
file: [
"Check if the file exists and is readable",
"Verify file permissions allow access",
"Ensure the file is not locked by another process",
"Check if you have sufficient disk space",
],
validation: [
"Check that all required fields are filled correctly",
"Verify the data format matches requirements",
"Ensure values are within acceptable ranges",
"Review the input for any special characters or formatting issues",
],
system: [
"Check if you have sufficient system resources",
"Ensure the application has necessary permissions",
"Try restarting the application",
"Check system logs for additional error information",
],
};
/**
* Screen-specific troubleshooting tips
*/
const SCREEN_TROUBLESHOOTING = {
SchedulingScreen: [
"Ensure scheduled times are in the future",
"Check that the schedule format is correct",
"Verify you have write permissions to save schedules",
"Try creating a simpler schedule first",
],
TagAnalysisScreen: [
"Verify your Shopify store has products with tags",
"Check that your API connection is working",
"Try reducing the analysis limit if you have many products",
"Ensure your store has products with valid pricing data",
],
ViewLogsScreen: [
"Run some operations to generate log files",
"Check if Progress.md file exists in the current directory",
"Verify you have read permissions for log files",
"Try refreshing the log file list",
],
};
/**
* Enhance an error with additional context and troubleshooting information
* @param {Error} error - Original error
* @param {Object} context - Additional context information
* @returns {Error} Enhanced error
*/
function enhanceError(error, context = {}) {
if (!error) {
error = new Error("Unknown error occurred");
}
// Don't enhance if already enhanced
if (error.enhanced) {
return error;
}
const enhanced = new Error(error.message);
enhanced.originalError = error;
enhanced.enhanced = true;
enhanced.timestamp = new Date().toISOString();
enhanced.context = {
...context,
originalStack: error.stack,
};
// Determine error category and add appropriate troubleshooting
const troubleshooting = [];
// Add category-specific tips
const errorCategory = categorizeError(error);
if (COMMON_TROUBLESHOOTING[errorCategory]) {
troubleshooting.push(...COMMON_TROUBLESHOOTING[errorCategory]);
}
// Add screen-specific tips
if (context.screen && SCREEN_TROUBLESHOOTING[context.screen]) {
troubleshooting.push(...SCREEN_TROUBLESHOOTING[context.screen]);
}
// Add operation-specific tips
if (context.operation) {
troubleshooting.push(`This error occurred during: ${context.operation}`);
}
// Add custom troubleshooting tips
if (context.troubleshooting) {
troubleshooting.push(...context.troubleshooting);
}
enhanced.troubleshooting = troubleshooting;
enhanced.category = errorCategory;
return enhanced;
}
/**
* Categorize error based on message and properties
* @param {Error} error - Error to categorize
* @returns {string} Error category
*/
function categorizeError(error) {
if (!error) return "system";
const message = error.message.toLowerCase();
const code = error.code || "";
// Network errors
if (
message.includes("network") ||
message.includes("timeout") ||
message.includes("connection") ||
code.includes("ECONNRESET") ||
code.includes("ENOTFOUND") ||
code.includes("ETIMEDOUT")
) {
return "network";
}
// API errors
if (
message.includes("api") ||
message.includes("shopify") ||
message.includes("rate limit") ||
message.includes("unauthorized") ||
message.includes("forbidden") ||
/[45]\d{2}/.test(code) // 4xx or 5xx HTTP status codes
) {
return "api";
}
// File system errors
if (
message.includes("file") ||
message.includes("directory") ||
message.includes("permission") ||
code.includes("ENOENT") ||
code.includes("EACCES") ||
code.includes("EPERM")
) {
return "file";
}
// Validation errors
if (
message.includes("validation") ||
message.includes("invalid") ||
message.includes("required") ||
message.includes("format") ||
error.name === "ValidationError"
) {
return "validation";
}
// Default to system error
return "system";
}
/**
* Check if an error is retryable
* @param {Error} error - Error to check
* @returns {boolean} True if error is retryable
*/
function isRetryableError(error) {
if (!error) return false;
const category = categorizeError(error);
const message = error.message.toLowerCase();
// Network errors are usually retryable
if (category === "network") return true;
// Some API errors are retryable
if (category === "api") {
return (
message.includes("rate limit") ||
message.includes("timeout") ||
message.includes("503") ||
message.includes("502") ||
message.includes("500")
);
}
// File errors might be retryable if temporary
if (category === "file") {
return (
message.includes("locked") ||
message.includes("busy") ||
error.code === "EBUSY"
);
}
// Validation and system errors are usually not retryable
return false;
}
/**
* Get retry delay based on attempt number
* @param {number} attempt - Attempt number (1-based)
* @param {string} errorCategory - Error category
* @returns {number} Delay in milliseconds
*/
function getRetryDelay(attempt, errorCategory = "system") {
const baseDelay =
{
network: 1000,
api: 2000,
file: 500,
system: 1000,
}[errorCategory] || 1000;
// Exponential backoff with jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
const jitter = Math.random() * 0.1 * exponentialDelay;
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
}
/**
* Create a standardized error for common scenarios
* @param {string} scenario - Error scenario
* @param {Object} context - Additional context
* @returns {Error} Standardized error
*/
function createStandardError(scenario, context = {}) {
const scenarios = {
fileNotFound: {
message: `File not found: ${context.file || "unknown file"}`,
troubleshooting: [
"Check if the file exists in the expected location",
"Verify the file path is correct",
"Ensure you have read permissions for the file",
],
},
apiConnectionFailed: {
message: "Failed to connect to Shopify API",
troubleshooting: [
"Check your internet connection",
"Verify your Shopify API credentials",
"Ensure your store domain is correct",
"Check if Shopify services are operational",
],
},
invalidConfiguration: {
message: `Invalid configuration: ${context.field || "unknown field"}`,
troubleshooting: [
"Check your .env file for correct values",
"Verify all required configuration fields are set",
"Ensure configuration values are in the correct format",
"Review the configuration documentation",
],
},
operationTimeout: {
message: `Operation timed out: ${
context.operation || "unknown operation"
}`,
troubleshooting: [
"Try the operation again with a smaller scope",
"Check your network connection stability",
"Increase timeout values if possible",
"Break the operation into smaller parts",
],
},
};
const scenario_config = scenarios[scenario];
if (!scenario_config) {
return enhanceError(
new Error(`Unknown error scenario: ${scenario}`),
context
);
}
const error = new Error(scenario_config.message);
return enhanceError(error, {
...context,
troubleshooting: scenario_config.troubleshooting,
});
}
/**
* Log error with appropriate level and context
* @param {Error} error - Error to log
* @param {Object} context - Additional context
*/
function logError(error, context = {}) {
const category = categorizeError(error);
const logLevel =
{
network: "warn",
api: "warn",
file: "error",
validation: "warn",
system: "error",
}[category] || "error";
const logMessage = `[${category.toUpperCase()}] ${error.message}`;
const logContext = {
timestamp: new Date().toISOString(),
category,
context,
stack: error.stack,
};
if (logLevel === "error") {
console.error(logMessage, logContext);
} else {
console.warn(logMessage, logContext);
}
}
module.exports = {
enhanceError,
categorizeError,
isRetryableError,
getRetryDelay,
createStandardError,
logError,
COMMON_TROUBLESHOOTING,
SCREEN_TROUBLESHOOTING,
};

View File

@@ -0,0 +1,179 @@
const {
enhanceError,
categorizeError,
isRetryableError,
createStandardError,
} = require("../../src/tui/utils/errorHandler");
const ErrorHandlingService = require("../../src/tui/services/ErrorHandlingService");
describe("TUI Error Handling", () => {
describe("Error Enhancement", () => {
test("should enhance basic error with troubleshooting", () => {
const originalError = new Error("Network timeout");
const enhanced = enhanceError(originalError, {
operation: "fetchTags",
screen: "TagAnalysisScreen",
});
expect(enhanced.troubleshooting).toBeDefined();
expect(enhanced.troubleshooting.length).toBeGreaterThan(0);
expect(enhanced.context.operation).toBe("fetchTags");
expect(enhanced.enhanced).toBe(true);
});
test("should not double-enhance errors", () => {
const originalError = new Error("Test error");
const enhanced1 = enhanceError(originalError);
const enhanced2 = enhanceError(enhanced1);
expect(enhanced2).toBe(enhanced1);
});
});
describe("Error Categorization", () => {
test("should categorize network errors correctly", () => {
const networkError = new Error("Connection timeout");
expect(categorizeError(networkError)).toBe("network");
const econnError = new Error("ECONNRESET");
econnError.code = "ECONNRESET";
expect(categorizeError(econnError)).toBe("network");
});
test("should categorize API errors correctly", () => {
const apiError = new Error("Shopify API rate limit exceeded");
expect(categorizeError(apiError)).toBe("api");
const httpError = new Error("HTTP 503 Service Unavailable");
httpError.code = "503";
expect(categorizeError(httpError)).toBe("api");
});
test("should categorize file errors correctly", () => {
const fileError = new Error("File not found");
fileError.code = "ENOENT";
expect(categorizeError(fileError)).toBe("file");
const permissionError = new Error("Permission denied");
permissionError.code = "EACCES";
expect(categorizeError(permissionError)).toBe("file");
});
test("should categorize validation errors correctly", () => {
const validationError = new Error("Invalid input format");
validationError.name = "ValidationError";
expect(categorizeError(validationError)).toBe("validation");
});
});
describe("Retry Logic", () => {
test("should identify retryable errors", () => {
const networkError = new Error("Connection timeout");
expect(isRetryableError(networkError)).toBe(true);
const rateLimitError = new Error("Rate limit exceeded");
expect(isRetryableError(rateLimitError)).toBe(true);
const validationError = new Error("Invalid input");
validationError.name = "ValidationError";
expect(isRetryableError(validationError)).toBe(false);
});
});
describe("Standard Errors", () => {
test("should create file not found error", () => {
const error = createStandardError("fileNotFound", {
file: "schedules.json",
});
expect(error.message).toContain("schedules.json");
expect(error.troubleshooting).toBeDefined();
expect(error.troubleshooting.length).toBeGreaterThan(0);
});
test("should create API connection error", () => {
const error = createStandardError("apiConnectionFailed");
expect(error.message).toContain("Shopify API");
expect(error.troubleshooting).toBeDefined();
});
});
describe("ErrorHandlingService", () => {
let errorHandler;
beforeEach(() => {
errorHandler = new ErrorHandlingService();
});
test("should execute operation successfully on first try", async () => {
const mockOperation = jest.fn().mockResolvedValue("success");
const result = await errorHandler.executeWithRetry(mockOperation);
expect(result).toBe("success");
expect(mockOperation).toHaveBeenCalledTimes(1);
});
test("should retry failed operations", async () => {
const mockOperation = jest
.fn()
.mockRejectedValueOnce(new Error("Network timeout"))
.mockResolvedValue("success");
const result = await errorHandler.executeWithRetry(mockOperation, {
maxRetries: 2,
});
expect(result).toBe("success");
expect(mockOperation).toHaveBeenCalledTimes(2);
});
test("should fail after max retries", async () => {
const mockOperation = jest
.fn()
.mockRejectedValue(new Error("Persistent error"));
await expect(
errorHandler.executeWithRetry(mockOperation, {
maxRetries: 1, // Only 1 retry, so should be called once
})
).rejects.toThrow();
expect(mockOperation).toHaveBeenCalledTimes(1);
});
test("should handle graceful file reads", async () => {
const mockFileReader = jest
.fn()
.mockRejectedValue(
Object.assign(new Error("File not found"), { code: "ENOENT" })
);
const result = await errorHandler.gracefulFileRead(
mockFileReader,
"fallback"
);
expect(result).toBe("fallback");
});
test("should track error history", async () => {
const mockOperation = jest
.fn()
.mockRejectedValue(new Error("Test error"));
try {
await errorHandler.executeWithRetry(mockOperation, { maxRetries: 1 });
} catch (error) {
// Expected to fail
}
const history = errorHandler.getErrorHistory();
expect(history.length).toBeGreaterThan(0);
const stats = errorHandler.getErrorStats();
expect(stats.totalErrors).toBeGreaterThan(0);
});
});
});