Files
sd-park-pass-ntfy/mock.js
2025-07-14 00:47:17 -07:00

414 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}