414 lines
15 KiB
JavaScript
414 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
// Mock script for testing SD Park Pass Monitor with multi-item support
|
||
// This script simulates API responses and can test notifications
|
||
|
||
import fs from 'fs';
|
||
import fetch from 'node-fetch';
|
||
|
||
// Use the same configuration as the main script
|
||
const CONFIG = {
|
||
API_URL: 'https://sandiego.bibliocommons.com/v2/search',
|
||
ITEMS: [
|
||
{
|
||
id: 'S161C1805116',
|
||
name: 'Hiking Backpack with Park Pass',
|
||
query: 'formattype:(KIT ) AND keyword:(backpack park pass) AND available:true'
|
||
},
|
||
{
|
||
id: 'S161C1690437',
|
||
name: 'Pure Parking Pass',
|
||
query: 'formattype:(KIT ) AND keyword:(pure) AND available:true'
|
||
}
|
||
],
|
||
BRANCH_NAME: 'Rancho Penasquitos',
|
||
NTFY_TOPIC: 'sdparkpass', // Use same topic as main app
|
||
NTFY_SERVER: 'https://ntfy.sh', // Use same server as main app
|
||
STATE_FILE: './park-pass-state.json',
|
||
CHECK_INTERVAL: '*/5 * * * *' // Every 5 minutes
|
||
};
|
||
|
||
// Import shared functions from the main script
|
||
async function sendNotification(title, message, priority = 'default') {
|
||
try {
|
||
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'text/plain',
|
||
'Title': title,
|
||
'Priority': priority,
|
||
'Tags': 'books,library,park-pass'
|
||
},
|
||
body: message
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
console.log(`✅ Notification sent: ${title}`);
|
||
} catch (error) {
|
||
console.error(`❌ Failed to send notification: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function countAvailableItemsInResponse(apiResponse, itemId) {
|
||
if (!apiResponse?.entities?.bibItems) {
|
||
return 0;
|
||
}
|
||
|
||
const items = Object.values(apiResponse.entities.bibItems);
|
||
|
||
return items.filter(item => {
|
||
// Check if this item matches the ID we're looking for
|
||
if (item.id !== itemId) {
|
||
return false;
|
||
}
|
||
|
||
// Check if it's available and at the correct branch
|
||
return item.availability?.statusType === 'AVAILABLE' &&
|
||
item.branchName === CONFIG.BRANCH_NAME;
|
||
}).length;
|
||
}
|
||
|
||
function countAllAvailableItems(apiResponse) {
|
||
const counts = {};
|
||
for (const item of CONFIG.ITEMS) {
|
||
counts[item.id] = countAvailableItemsInResponse(apiResponse, item.id);
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
// Create mock API response with specified item counts
|
||
function createMockApiResponse(itemCounts) {
|
||
const bibItems = {};
|
||
let itemCounter = 1;
|
||
|
||
// Create mock items for each type
|
||
for (const item of CONFIG.ITEMS) {
|
||
const count = itemCounts[item.id] || 0;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const mockId = `mock_${item.id}_${i + 1}`;
|
||
bibItems[mockId] = {
|
||
id: item.id,
|
||
title: item.name,
|
||
author: "San Diego Public Library",
|
||
callNumber: {
|
||
"code": "7"
|
||
},
|
||
inSiteScope: true,
|
||
availability: {
|
||
status: "AVAILABLE",
|
||
circulationType: "NON_CIRCULATING",
|
||
libraryUseOnly: false,
|
||
libraryStatus: "Available",
|
||
group: "AVAILABLE_ITEMS",
|
||
statusType: "AVAILABLE"
|
||
},
|
||
branchName: CONFIG.BRANCH_NAME,
|
||
local: false,
|
||
requestFormUrl: null
|
||
};
|
||
itemCounter++;
|
||
}
|
||
}
|
||
|
||
return {
|
||
entities: {
|
||
bibItems: bibItems
|
||
}
|
||
};
|
||
}
|
||
|
||
async function simulateMockCheck(itemCounts) {
|
||
const totalCount = Object.values(itemCounts).reduce((sum, count) => sum + count, 0);
|
||
console.log(`\n🔄 Simulating availability check...`);
|
||
const itemDetails = CONFIG.ITEMS.map(item => `${item.name}: ${itemCounts[item.id] || 0}`).join(', ');
|
||
console.log(` Items: ${itemDetails}`);
|
||
console.log(` Total: ${totalCount}`);
|
||
console.log(`--- Starting mock availability check ---`);
|
||
console.log(`Time: ${new Date().toISOString()}`);
|
||
|
||
try {
|
||
// Create mock API response
|
||
const mockApiResponse = createMockApiResponse(itemCounts);
|
||
console.log(`📚 Created mock API response for ${CONFIG.BRANCH_NAME}`);
|
||
|
||
// Count currently available items using the real functions
|
||
const currentCounts = countAllAvailableItems(mockApiResponse);
|
||
const totalCurrentCount = Object.values(currentCounts).reduce((sum, count) => sum + count, 0);
|
||
|
||
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${totalCurrentCount}`);
|
||
for (const item of CONFIG.ITEMS) {
|
||
const count = currentCounts[item.id] || 0;
|
||
console.log(` ${item.name}: ${count}`);
|
||
}
|
||
|
||
// Load previous counts
|
||
const previousCounts = loadLastAvailabilityCounts();
|
||
const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0);
|
||
console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`);
|
||
|
||
// Check for changes and send notifications
|
||
const notifications = [];
|
||
let hasChanges = false;
|
||
|
||
for (const item of CONFIG.ITEMS) {
|
||
const currentCount = currentCounts[item.id] || 0;
|
||
const previousCount = previousCounts[item.id] || 0;
|
||
|
||
if (currentCount > previousCount) {
|
||
const newItems = currentCount - previousCount;
|
||
notifications.push(`${newItems} ${item.name} became available`);
|
||
hasChanges = true;
|
||
console.log(`📈 ${item.name}: +${newItems} (${previousCount} → ${currentCount})`);
|
||
} else if (currentCount < previousCount) {
|
||
const lostItems = previousCount - currentCount;
|
||
console.log(`📉 ${item.name}: -${lostItems} (${previousCount} → ${currentCount})`);
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
|
||
if (notifications.length > 0) {
|
||
const title = 'SD Park Pass Available! (MOCK)';
|
||
const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`;
|
||
|
||
console.log(`📢 Sending notification: ${message}`);
|
||
await sendNotification(title, message, 'high');
|
||
} else if (hasChanges) {
|
||
console.log('Items became unavailable (no notification sent)');
|
||
} else {
|
||
console.log('No change in availability');
|
||
}
|
||
|
||
// Save current state
|
||
saveAvailabilityCounts(currentCounts, {
|
||
previousCounts,
|
||
totalCurrent: totalCurrentCount,
|
||
totalPrevious: totalPreviousCount,
|
||
branch: CONFIG.BRANCH_NAME,
|
||
mock: true
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error in mock check:', error.message);
|
||
|
||
// Send error notification
|
||
await sendNotification(
|
||
'SD Park Pass Monitor Error (MOCK)',
|
||
`Error in mock check: ${error.message}`,
|
||
'low'
|
||
);
|
||
}
|
||
|
||
console.log('--- Mock availability check completed ---\n');
|
||
}
|
||
|
||
// Helper functions for state management
|
||
function loadLastAvailabilityCounts() {
|
||
try {
|
||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||
// Support both old single-item format and new multi-item format
|
||
if (data.availabilityCount !== undefined) {
|
||
// Old format - assume it's for the backpack item
|
||
return { 'S161C1805116': data.availabilityCount || 0 };
|
||
}
|
||
return data.itemCounts || {};
|
||
}
|
||
} catch (error) {
|
||
console.error('Error reading state file:', error.message);
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function saveAvailabilityCounts(itemCounts, metadata = {}) {
|
||
try {
|
||
const data = {
|
||
itemCounts,
|
||
lastUpdated: new Date().toISOString(),
|
||
branch: CONFIG.BRANCH_NAME,
|
||
...metadata
|
||
};
|
||
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
|
||
console.log(`Saved availability counts:`, itemCounts);
|
||
} catch (error) {
|
||
console.error('Error saving state file:', error.message);
|
||
}
|
||
}
|
||
|
||
async function testNotification() {
|
||
console.log('\n📱 Testing notification system...');
|
||
|
||
await sendNotification(
|
||
'Test Notification',
|
||
'This is a test notification from the SD Park Pass Monitor mock script. If you see this, notifications are working!',
|
||
'high'
|
||
);
|
||
|
||
console.log('✅ Test notification sent');
|
||
}
|
||
|
||
function showCurrentState() {
|
||
console.log('\n📊 Current State:');
|
||
try {
|
||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||
if (data.itemCounts) {
|
||
console.log(' Item counts:');
|
||
Object.entries(data.itemCounts).forEach(([itemId, count]) => {
|
||
const item = CONFIG.ITEMS.find(i => i.id === itemId);
|
||
console.log(` ${item?.name || itemId}: ${count}`);
|
||
});
|
||
} else if (data.availabilityCount !== undefined) {
|
||
console.log(` Available items (legacy): ${data.availabilityCount}`);
|
||
}
|
||
console.log(` Last updated: ${data.lastUpdated}`);
|
||
console.log(` Branch: ${data.branch}`);
|
||
} else {
|
||
console.log(' No state file found (first run)');
|
||
}
|
||
} catch (error) {
|
||
console.log(' Error reading state file:', error.message);
|
||
}
|
||
}
|
||
|
||
function resetState() {
|
||
console.log('\n🔄 Resetting state...');
|
||
try {
|
||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||
fs.unlinkSync(CONFIG.STATE_FILE);
|
||
console.log('✅ State file deleted');
|
||
} else {
|
||
console.log('ℹ️ No state file to delete');
|
||
}
|
||
} catch (error) {
|
||
console.log('❌ Error deleting state file:', error.message);
|
||
}
|
||
}
|
||
|
||
async function interactiveMode() {
|
||
const readline = await import('readline');
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
function question(query) {
|
||
return new Promise(resolve => rl.question(query, resolve));
|
||
}
|
||
|
||
console.log('\n🎮 Interactive Mode');
|
||
console.log('Commands:');
|
||
console.log(' test <backpack_count> <pass_count> - Simulate availability');
|
||
console.log(' test <total_count> - Simulate total count (split between types)');
|
||
console.log(' notify - Send test notification');
|
||
console.log(' status - Show current state');
|
||
console.log(' reset - Reset state file');
|
||
console.log(' quit - Exit');
|
||
|
||
while (true) {
|
||
const input = await question('\n> ');
|
||
const [command, ...args] = input.trim().split(' ');
|
||
|
||
switch (command.toLowerCase()) {
|
||
case 'test': {
|
||
if (args.length === 2) {
|
||
// Two arguments: backpack count and pass count
|
||
const backpackCount = parseInt(args[0]) || 0;
|
||
const passCount = parseInt(args[1]) || 0;
|
||
const itemCounts = {
|
||
'S161C1805116': backpackCount,
|
||
'S161C1690437': passCount
|
||
};
|
||
await simulateMockCheck(itemCounts);
|
||
} else if (args.length === 1) {
|
||
// One argument: total count (split evenly)
|
||
const totalCount = parseInt(args[0]) || 0;
|
||
const backpackCount = Math.floor(totalCount / 2);
|
||
const passCount = totalCount - backpackCount;
|
||
const itemCounts = {
|
||
'S161C1805116': backpackCount,
|
||
'S161C1690437': passCount
|
||
};
|
||
await simulateMockCheck(itemCounts);
|
||
} else {
|
||
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
|
||
}
|
||
break;
|
||
}
|
||
case 'notify':
|
||
await testNotification();
|
||
break;
|
||
case 'status':
|
||
showCurrentState();
|
||
break;
|
||
case 'reset':
|
||
resetState();
|
||
break;
|
||
case 'quit':
|
||
case 'exit':
|
||
rl.close();
|
||
return;
|
||
default:
|
||
console.log('Unknown command. Try: test, notify, status, reset, or quit');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Main function
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
|
||
if (args.length === 0) {
|
||
// Interactive mode
|
||
await interactiveMode();
|
||
} else if (args[0] === 'test') {
|
||
// Direct test mode
|
||
showCurrentState();
|
||
if (args.length === 3) {
|
||
// test <backpack_count> <pass_count>
|
||
const backpackCount = parseInt(args[1]) || 0;
|
||
const passCount = parseInt(args[2]) || 0;
|
||
const itemCounts = {
|
||
'S161C1805116': backpackCount,
|
||
'S161C1690437': passCount
|
||
};
|
||
await simulateMockCheck(itemCounts);
|
||
} else if (args.length === 2) {
|
||
// test <total_count>
|
||
const totalCount = parseInt(args[1]) || 0;
|
||
const backpackCount = Math.floor(totalCount / 2);
|
||
const passCount = totalCount - backpackCount;
|
||
const itemCounts = {
|
||
'S161C1805116': backpackCount,
|
||
'S161C1690437': passCount
|
||
};
|
||
await simulateMockCheck(itemCounts);
|
||
} else {
|
||
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
|
||
}
|
||
} else if (args[0] === 'notify') {
|
||
// Test notification
|
||
await testNotification();
|
||
} else if (args[0] === 'status') {
|
||
showCurrentState();
|
||
} else if (args[0] === 'reset') {
|
||
resetState();
|
||
} else {
|
||
console.log('Usage:');
|
||
console.log(' node mock.js - Interactive mode');
|
||
console.log(' node mock.js test <backpack> <pass> - Simulate specific counts');
|
||
console.log(' node mock.js test <total> - Simulate total count');
|
||
console.log(' node mock.js notify - Send test notification');
|
||
console.log(' node mock.js status - Show current state');
|
||
console.log(' node mock.js reset - Reset state file');
|
||
}
|
||
}
|
||
|
||
// Only run if this file is executed directly
|
||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||
main().catch(console.error);
|
||
}
|