pkg-track 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/open.js ADDED
@@ -0,0 +1,209 @@
1
+ const chalk = require('chalk');
2
+ const inquirer = require('inquirer');
3
+ const open = require('open');
4
+ const { getAllPackages } = require('./db');
5
+ const { showBanner } = require('./banner');
6
+
7
+ /**
8
+ * Get tracking URL for a carrier
9
+ */
10
+ function getTrackingUrl(carrier, trackingNumber) {
11
+ const urls = {
12
+ 'UPS': `https://www.ups.com/track?tracknum=${trackingNumber}`,
13
+ 'FedEx': `https://www.fedex.com/fedextrack/?trknbr=${trackingNumber}`,
14
+ 'USPS': `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`,
15
+ 'Amazon Logistics': `https://track.amazon.com/tracking/${trackingNumber}`
16
+ };
17
+ return urls[carrier] || null;
18
+ }
19
+
20
+ /**
21
+ * Get status emoji and label
22
+ */
23
+ function getStatusDisplay(status) {
24
+ const statusMap = {
25
+ 'delivered': { emoji: 'โœ…', label: 'Delivered' },
26
+ 'in_transit': { emoji: '๐Ÿšš', label: 'In Transit' },
27
+ 'out_for_delivery': { emoji: '๐Ÿ“ฆ', label: 'Out for Delivery' },
28
+ 'pending': { emoji: 'โณ', label: 'Pending' },
29
+ 'delayed': { emoji: 'โš ๏ธ', label: 'Delayed' },
30
+ };
31
+ return statusMap[status] || { emoji: 'โ“', label: 'Unknown' };
32
+ }
33
+
34
+ /**
35
+ * Interactive package opener
36
+ */
37
+ async function openPackages() {
38
+ // Show banner
39
+ showBanner();
40
+
41
+ const SUPPORTED = ['UPS', 'FedEx', 'USPS', 'Amazon Logistics'];
42
+ const allPackages = getAllPackages(false); // Only non-delivered
43
+
44
+ // Filter to supported carriers only
45
+ const packages = allPackages.filter(pkg => SUPPORTED.includes(pkg.carrier));
46
+
47
+ if (packages.length === 0) {
48
+ console.log(chalk.yellow('\n๐Ÿ“ญ No packages to track'));
49
+ console.log(chalk.gray('Run `pkg sync` to scan for new packages\n'));
50
+ return;
51
+ }
52
+
53
+ console.log(chalk.gray(`Showing ${packages.length} undelivered package(s) from your last sync`));
54
+ console.log(chalk.gray('๐Ÿ’ก Tip: Run `pkg sync` again to get the latest updates from your email\n'));
55
+
56
+ // Pagination settings
57
+ const PAGE_SIZE = 5;
58
+ let currentPage = 0;
59
+ const totalPages = Math.ceil(packages.length / PAGE_SIZE);
60
+
61
+ while (true) {
62
+ // Get packages for current page
63
+ const startIdx = currentPage * PAGE_SIZE;
64
+ const endIdx = Math.min(startIdx + PAGE_SIZE, packages.length);
65
+ const pagePackages = packages.slice(startIdx, endIdx);
66
+
67
+ // Build choices for current page
68
+ const choices = [];
69
+
70
+ // Always show Open All at the top
71
+ choices.push({
72
+ name: chalk.bold('๐ŸŒ Open all packages in browser'),
73
+ value: 'ALL',
74
+ short: 'Open all'
75
+ });
76
+
77
+ // Add separator if there are packages
78
+ if (pagePackages.length > 0) {
79
+ choices.push(new inquirer.Separator());
80
+ }
81
+
82
+ // Add packages for this page
83
+ pagePackages.forEach(pkg => {
84
+ const description = pkg.description || 'Package';
85
+ const shortDesc = description.length > 35 ? description.substring(0, 32) + '...' : description;
86
+ const { emoji, label } = getStatusDisplay(pkg.status);
87
+
88
+ // Format date
89
+ let dateStr = '';
90
+ if (pkg.last_email_date) {
91
+ const daysSince = Math.floor((Date.now() / 1000 - pkg.last_email_date) / 86400);
92
+ if (daysSince === 0) {
93
+ dateStr = 'today';
94
+ } else if (daysSince === 1) {
95
+ dateStr = 'yesterday';
96
+ } else if (daysSince < 7) {
97
+ dateStr = `${daysSince}d ago`;
98
+ } else {
99
+ const date = new Date(pkg.last_email_date * 1000);
100
+ dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
101
+ }
102
+ } else if (pkg.created_at) {
103
+ const date = new Date(pkg.created_at * 1000);
104
+ dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
105
+ }
106
+
107
+ choices.push({
108
+ name: `${emoji} ${shortDesc} (${pkg.carrier} - ${label}) ยท ${dateStr}`,
109
+ value: pkg,
110
+ short: shortDesc
111
+ });
112
+ });
113
+
114
+ // Add pagination controls if needed
115
+ if (totalPages > 1) {
116
+ choices.push(new inquirer.Separator());
117
+
118
+ if (currentPage < totalPages - 1) {
119
+ choices.push({
120
+ name: chalk.cyan(`โ†’ Next page (${currentPage + 2}/${totalPages})`),
121
+ value: 'NEXT_PAGE',
122
+ short: 'Next page'
123
+ });
124
+ }
125
+
126
+ if (currentPage > 0) {
127
+ choices.push({
128
+ name: chalk.cyan(`โ† Previous page (${currentPage}/${totalPages})`),
129
+ value: 'PREV_PAGE',
130
+ short: 'Previous page'
131
+ });
132
+ }
133
+ }
134
+
135
+ // Always show Exit at the very bottom
136
+ choices.push(new inquirer.Separator());
137
+ choices.push({
138
+ name: chalk.gray('โŒ Exit'),
139
+ value: 'EXIT',
140
+ short: 'Exit'
141
+ });
142
+
143
+ const { selected } = await inquirer.prompt([{
144
+ type: 'list',
145
+ name: 'selected',
146
+ message: `Page ${currentPage + 1}/${totalPages}:`,
147
+ choices,
148
+ pageSize: 12
149
+ }]);
150
+
151
+ if (selected === 'EXIT') {
152
+ console.log(chalk.gray('\nGoodbye! ๐Ÿ‘‹\n'));
153
+ break;
154
+ }
155
+
156
+ if (selected === 'NEXT_PAGE') {
157
+ currentPage++;
158
+ continue;
159
+ }
160
+
161
+ if (selected === 'PREV_PAGE') {
162
+ currentPage--;
163
+ continue;
164
+ }
165
+
166
+ if (selected === 'ALL') {
167
+ console.log(chalk.blue('\n๐ŸŒ Opening all packages in browser...\n'));
168
+ for (const pkg of packages) {
169
+ const url = getTrackingUrl(pkg.carrier, pkg.tracking_number);
170
+ if (url) {
171
+ console.log(chalk.gray(` Opening ${pkg.description}...`));
172
+ await open(url);
173
+ // Small delay to avoid overwhelming the browser
174
+ await new Promise(resolve => setTimeout(resolve, 500));
175
+ }
176
+ }
177
+ console.log(chalk.green('\nโœ“ Opened all packages\n'));
178
+ break;
179
+ }
180
+
181
+ // Open individual package
182
+ const url = getTrackingUrl(selected.carrier, selected.tracking_number);
183
+ if (url) {
184
+ console.log(chalk.blue(`\n๐ŸŒ Opening ${selected.description} in browser...\n`));
185
+ await open(url);
186
+
187
+ // Ask if they want to track another
188
+ const { another } = await inquirer.prompt([{
189
+ type: 'confirm',
190
+ name: 'another',
191
+ message: 'Track another package?',
192
+ default: false
193
+ }]);
194
+
195
+ if (!another) {
196
+ console.log(chalk.gray('\nGoodbye! ๐Ÿ‘‹\n'));
197
+ break;
198
+ }
199
+
200
+ // Reset to first page when returning to menu
201
+ currentPage = 0;
202
+ console.log(); // Add spacing
203
+ }
204
+ }
205
+ }
206
+
207
+ module.exports = {
208
+ openPackages
209
+ };
package/src/parser.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Regex patterns for supported shipping carriers
3
+ * UPS, FedEx, USPS, Amazon Logistics
4
+ */
5
+ const TRACKING_PATTERNS = {
6
+ ups: {
7
+ pattern: /\b(1Z[A-Z0-9]{16})\b/gi,
8
+ carrier: 'UPS'
9
+ },
10
+ usps: {
11
+ pattern: /\b(9[0-9]{21}|92[0-9]{18}|94[0-9]{20}|82[0-9]{8})\b/gi,
12
+ carrier: 'USPS'
13
+ },
14
+ amazon: {
15
+ pattern: /\b(TBA\d{12})\b/gi,
16
+ carrier: 'Amazon Logistics'
17
+ },
18
+ fedex: {
19
+ // FedEx is tricky - only match if we see FedEx keywords nearby
20
+ pattern: /\b(\d{12}|\d{15}|\d{20})\b/g,
21
+ carrier: 'FedEx',
22
+ requiresContext: true // Needs "fedex" in surrounding text
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Extract tracking numbers from email text
28
+ * @param {string} text - Email body or subject
29
+ * @returns {Array} Array of { trackingNumber, carrier } objects
30
+ */
31
+ function extractTrackingNumbers(text) {
32
+ const found = [];
33
+ const seen = new Set(); // Avoid duplicates
34
+ const lowerText = text.toLowerCase();
35
+
36
+ // Try each carrier pattern
37
+ for (const [key, config] of Object.entries(TRACKING_PATTERNS)) {
38
+ const { pattern, carrier, requiresContext } = config;
39
+
40
+ // For FedEx, only match if "fedex" appears in text
41
+ if (requiresContext && !lowerText.includes('fedex')) {
42
+ continue;
43
+ }
44
+
45
+ const matches = text.matchAll(pattern);
46
+
47
+ for (const match of matches) {
48
+ const trackingNumber = match[1];
49
+
50
+ // Basic validation to reduce false positives
51
+ if (isValidTrackingNumber(trackingNumber, key) && !seen.has(trackingNumber)) {
52
+ found.push({ trackingNumber, carrier });
53
+ seen.add(trackingNumber);
54
+ }
55
+ }
56
+ }
57
+
58
+ return found;
59
+ }
60
+
61
+ /**
62
+ * Additional validation to reduce false positives
63
+ */
64
+ function isValidTrackingNumber(number, carrierKey) {
65
+ // UPS: Always starts with 1Z
66
+ if (carrierKey === 'ups') {
67
+ return number.startsWith('1Z');
68
+ }
69
+
70
+ // USPS: Specific prefixes
71
+ if (carrierKey === 'usps') {
72
+ return /^(9[0-9]|92|94|82)/.test(number);
73
+ }
74
+
75
+ // Amazon: TBA prefix + exactly 12 digits after
76
+ if (carrierKey === 'amazon') {
77
+ return number.startsWith('TBA') && number.length === 15;
78
+ }
79
+
80
+ // FedEx: Length-based validation (very strict to avoid false positives)
81
+ if (carrierKey === 'fedex') {
82
+ const len = number.length;
83
+ // Only accept if exactly 12, 15, or 20 digits
84
+ return (len === 12 || len === 15 || len === 20) && /^\d+$/.test(number);
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Detect retailer from email headers and body
92
+ */
93
+ function detectRetailer(emailData) {
94
+ const from = emailData.from.toLowerCase();
95
+ const subject = emailData.subject.toLowerCase();
96
+
97
+ const retailers = {
98
+ 'Amazon': ['amazon.com', 'amazon'],
99
+ 'Target': ['target.com', 'target'],
100
+ 'Walmart': ['walmart.com', 'walmart'],
101
+ 'Etsy': ['etsy.com', 'etsy'],
102
+ 'eBay': ['ebay.com', 'ebay'],
103
+ 'Best Buy': ['bestbuy.com', 'best buy'],
104
+ 'Apple': ['apple.com', 'apple'],
105
+ 'Nike': ['nike.com', 'nike'],
106
+ 'Adidas': ['adidas.com', 'adidas'],
107
+ };
108
+
109
+ for (const [retailer, keywords] of Object.entries(retailers)) {
110
+ if (keywords.some(kw => from.includes(kw) || subject.includes(kw))) {
111
+ return retailer;
112
+ }
113
+ }
114
+
115
+ return 'Unknown';
116
+ }
117
+
118
+ /**
119
+ * Extract shipped date from email
120
+ */
121
+ function extractShippedDate(emailData) {
122
+ const { subject, body, date } = emailData;
123
+ const text = `${subject} ${body}`;
124
+
125
+ // Look for common date patterns in shipping emails
126
+ const datePatterns = [
127
+ /shipped on ([A-Z][a-z]+\s+\d{1,2},?\s+\d{4})/i,
128
+ /ship date:?\s*([A-Z][a-z]+\s+\d{1,2},?\s+\d{4})/i,
129
+ /estimated ship date:?\s*([A-Z][a-z]+\s+\d{1,2},?\s+\d{4})/i,
130
+ /(\d{1,2}\/\d{1,2}\/\d{4})/g, // MM/DD/YYYY
131
+ /(\d{4}-\d{2}-\d{2})/g, // YYYY-MM-DD
132
+ ];
133
+
134
+ for (const pattern of datePatterns) {
135
+ const match = text.match(pattern);
136
+ if (match) {
137
+ try {
138
+ const parsedDate = new Date(match[1] || match[0]);
139
+ if (!isNaN(parsedDate.getTime())) {
140
+ return Math.floor(parsedDate.getTime() / 1000);
141
+ }
142
+ } catch (e) {
143
+ // Continue trying other patterns
144
+ }
145
+ }
146
+ }
147
+
148
+ // Fallback to email date (when email was received)
149
+ if (date) {
150
+ try {
151
+ const parsedDate = new Date(date);
152
+ if (!isNaN(parsedDate.getTime())) {
153
+ return Math.floor(parsedDate.getTime() / 1000);
154
+ }
155
+ } catch (e) {
156
+ // ignore
157
+ }
158
+ }
159
+
160
+ // Last resort: use current time
161
+ return Math.floor(Date.now() / 1000);
162
+ }
163
+
164
+ /**
165
+ * Extract product/order details from email body
166
+ */
167
+ function extractProductDetails(emailData) {
168
+ const { subject, body } = emailData;
169
+
170
+ // Try to extract product name from subject first
171
+ const subjectPatterns = [
172
+ /shipped:?\s*(.+?)(?:\s*-\s*tracking|$)/i,
173
+ /order.*?:\s*(.+?)(?:\s*has shipped|$)/i,
174
+ /your\s+(.+?)\s+(?:has|have) shipped/i,
175
+ ];
176
+
177
+ for (const pattern of subjectPatterns) {
178
+ const match = subject.match(pattern);
179
+ if (match && match[1]) {
180
+ const cleaned = match[1].trim();
181
+ if (cleaned.length > 3 && cleaned.length < 100) {
182
+ return cleaned;
183
+ }
184
+ }
185
+ }
186
+
187
+ // Try to extract from body
188
+ const bodyPatterns = [
189
+ /item:?\s*(.+?)(?:\n|quantity|price|tracking)/i,
190
+ /product:?\s*(.+?)(?:\n|quantity|price|tracking)/i,
191
+ /order summary:?\s*(.+?)(?:\n\n|quantity|price|tracking)/i,
192
+ ];
193
+
194
+ for (const pattern of bodyPatterns) {
195
+ const match = body.match(pattern);
196
+ if (match && match[1]) {
197
+ const cleaned = match[1].trim().replace(/\s+/g, ' ');
198
+ if (cleaned.length > 3 && cleaned.length < 100) {
199
+ return cleaned;
200
+ }
201
+ }
202
+ }
203
+
204
+ // Fallback to a cleaned-up subject line
205
+ const cleanedSubject = subject
206
+ .replace(/^(re:|fwd?:)\s*/gi, '')
207
+ .replace(/shipped|tracking|delivered|order confirmation/gi, '')
208
+ .replace(/\s+/g, ' ')
209
+ .trim();
210
+
211
+ if (cleanedSubject.length > 3 && cleanedSubject.length < 100) {
212
+ return cleanedSubject;
213
+ }
214
+
215
+ return 'Package';
216
+ }
217
+
218
+ module.exports = {
219
+ extractTrackingNumbers,
220
+ detectRetailer,
221
+ extractShippedDate,
222
+ extractProductDetails
223
+ };
package/src/tracker.js ADDED
@@ -0,0 +1,103 @@
1
+ const { upsertPackage } = require('./db');
2
+ const { extractTrackingNumbers } = require('./parser');
3
+ const inquirer = require('inquirer');
4
+ const chalk = require('chalk');
5
+
6
+ const CARRIERS = [
7
+ 'UPS',
8
+ 'FedEx',
9
+ 'USPS',
10
+ 'Amazon Logistics'
11
+ ];
12
+
13
+ /**
14
+ * Manually add a package with interactive prompts
15
+ */
16
+ async function addPackage(trackingNumber, options = {}) {
17
+ console.log(chalk.blue('\n๐Ÿ“ฆ Manual Package Entry\n'));
18
+ console.log(chalk.gray('Add a tracking number that wasn\'t found in your email'));
19
+ console.log(chalk.gray('Supports: UPS, FedEx, USPS, Amazon Logistics\n'));
20
+
21
+ // Sanitize input - remove whitespace and ensure alphanumeric
22
+ trackingNumber = trackingNumber.trim().toUpperCase();
23
+
24
+ // Basic security check - only allow alphanumeric tracking numbers
25
+ if (!/^[A-Z0-9]+$/.test(trackingNumber)) {
26
+ throw new Error('Invalid tracking number format. Only letters and numbers allowed.');
27
+ }
28
+
29
+ // Length check - tracking numbers are typically 10-22 characters
30
+ if (trackingNumber.length < 10 || trackingNumber.length > 22) {
31
+ throw new Error('Invalid tracking number length. Must be between 10-22 characters.');
32
+ }
33
+
34
+ // Validate and auto-detect carrier from tracking number
35
+ const extracted = extractTrackingNumbers(trackingNumber);
36
+
37
+ let detectedCarrier = null;
38
+ let validNumber = trackingNumber;
39
+
40
+ if (extracted.length > 0) {
41
+ detectedCarrier = extracted[0].carrier;
42
+ validNumber = extracted[0].trackingNumber;
43
+ console.log(chalk.green(`โœ“ Valid tracking number detected`));
44
+ if (detectedCarrier) {
45
+ console.log(chalk.gray(` Carrier: ${detectedCarrier}`));
46
+ }
47
+ } else {
48
+ console.log(chalk.yellow('โš ๏ธ Could not auto-detect carrier from tracking number'));
49
+ }
50
+
51
+ console.log(chalk.gray(` Tracking: ${validNumber}\n`));
52
+
53
+ // Interactive prompts
54
+ const answers = await inquirer.prompt([
55
+ {
56
+ type: 'input',
57
+ name: 'description',
58
+ message: 'What is this package?',
59
+ default: options.name || 'Package',
60
+ validate: (input) => input.trim().length > 0 || 'Description is required'
61
+ },
62
+ {
63
+ type: 'list',
64
+ name: 'carrier',
65
+ message: 'Select carrier:',
66
+ choices: detectedCarrier
67
+ ? [
68
+ { name: `${detectedCarrier} (detected)`, value: detectedCarrier },
69
+ new inquirer.Separator(),
70
+ ...CARRIERS.filter(c => c !== detectedCarrier)
71
+ ]
72
+ : CARRIERS,
73
+ default: detectedCarrier
74
+ },
75
+ {
76
+ type: 'input',
77
+ name: 'retailer',
78
+ message: 'Where did you order from? (optional)',
79
+ default: 'Manual'
80
+ }
81
+ ]);
82
+
83
+ // Add the package
84
+ const pkg = upsertPackage({
85
+ trackingNumber: validNumber,
86
+ carrier: answers.carrier,
87
+ retailer: answers.retailer || 'Manual',
88
+ description: answers.description,
89
+ emailId: null,
90
+ shippedDate: Math.floor(Date.now() / 1000), // Use current time as shipped date
91
+ lastEmailDate: Math.floor(Date.now() / 1000),
92
+ });
93
+
94
+ console.log(chalk.green('\nโœ“ Package added successfully!'));
95
+ console.log(chalk.gray(` ${answers.description}`));
96
+ console.log(chalk.gray(` ${answers.carrier} ยท ${validNumber}\n`));
97
+
98
+ return pkg;
99
+ }
100
+
101
+ module.exports = {
102
+ addPackage,
103
+ };