Skip to content

Commit a09a93e

Browse files
authored
Feat/211 allow import empty unit price (#220)
update import/export to handle items with 'default' price (represented as missing field for price in JSON) and allow import of list of URLs (non-JSON, unstructured data) additionally: * update utilities with functions for loading test file and extracting tralbum data from bandcamp html
1 parent 7d3820d commit a09a93e

File tree

12 files changed

+1572
-108
lines changed

12 files changed

+1572
-108
lines changed

src/audioFeatures.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export function applyAudioConfig(
4646
canvasDisplayToggle: HTMLInputElement,
4747
log: Logger
4848
): void {
49+
if (!msg.config) {
50+
return;
51+
}
4952
log.info('config recieved from backend' + JSON.stringify(msg.config));
5053
canvas.style.display = msg.config.displayWaveform ? 'inherit' : 'none';
5154
canvasDisplayToggle.checked = msg.config.displayWaveform;

src/background.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { initWaveformBackend } from './background/waveform_backend.js';
33
import { initConfigBackend } from './background/config_backend.js';
44
import { initHideUnhideCollectionBackend } from './background/hide_unhide_collection_backend.js';
55
import { initDownloadBackend } from './background/download_backend.js';
6+
import { initCartImportBackend } from './background/cart_import_backend.js';
67

78
initLabelViewBackend();
89
initWaveformBackend();
910
initConfigBackend();
1011
initHideUnhideCollectionBackend();
1112
initDownloadBackend();
13+
initCartImportBackend();
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import Logger from '../logger.js';
2+
import { getTralbumDetails, getTralbumDetailsFromPage, CURRENCY_MINIMUMS } from '../bclient.js';
3+
4+
const BASE_URL = 'http://bandcamp.com';
5+
6+
const log = new Logger();
7+
8+
interface CartImportItem {
9+
item_id: number;
10+
item_type: 'a' | 't';
11+
item_title: string;
12+
band_name: string;
13+
currency: string;
14+
url: string;
15+
unit_price?: number;
16+
}
17+
18+
type ImportOperation = 'import' | 'url_import';
19+
20+
interface CartImportState {
21+
isProcessing: boolean;
22+
processedCount: number;
23+
totalCount: number;
24+
errors: string[];
25+
operation: ImportOperation;
26+
}
27+
28+
class CartImportTracker {
29+
private isProcessing = false;
30+
private processedCount = 0;
31+
private totalCount = 0;
32+
private errors: string[] = [];
33+
private currentOperation: ImportOperation = 'import';
34+
private port?: chrome.runtime.Port;
35+
36+
constructor(port?: chrome.runtime.Port) {
37+
this.port = port;
38+
}
39+
40+
async processItems(items: (CartImportItem | string)[]): Promise<void> {
41+
if (items.length === 0) {
42+
this.port?.postMessage({
43+
cartImportComplete: { message: 'No items found to import' }
44+
});
45+
return;
46+
}
47+
48+
const operation: ImportOperation = typeof items[0] === 'string' ? 'url_import' : 'import';
49+
this.currentOperation = operation;
50+
51+
this.isProcessing = true;
52+
this.processedCount = 0;
53+
this.totalCount = items.length;
54+
this.errors = [];
55+
56+
log.info(`Starting ${operation} operation with ${items.length} items`);
57+
this.broadcastState();
58+
59+
for (const originalItem of items) {
60+
try {
61+
if (typeof originalItem !== 'string' && originalItem.unit_price !== undefined) {
62+
log.info(
63+
`Using existing price ${originalItem.unit_price} for item ${originalItem.item_id} (${originalItem.item_type})`
64+
);
65+
this.processedCount += 1;
66+
67+
this.port?.postMessage({
68+
cartAddRequest: {
69+
item_id: originalItem.item_id,
70+
item_type: originalItem.item_type,
71+
item_title: originalItem.item_title,
72+
band_name: originalItem.band_name,
73+
unit_price: originalItem.unit_price,
74+
currency: originalItem.currency,
75+
url: originalItem.url
76+
}
77+
});
78+
79+
this.broadcastState();
80+
continue;
81+
}
82+
83+
const item = await (async (): Promise<CartImportItem> => {
84+
if (typeof originalItem !== 'string') {
85+
return originalItem;
86+
}
87+
88+
log.info(`Extracting info from URL: ${originalItem}`);
89+
const pageInfo = await getTralbumDetailsFromPage(originalItem);
90+
return {
91+
item_id: pageInfo.id,
92+
item_type: pageInfo.type as 'a' | 't',
93+
item_title: pageInfo.title,
94+
band_name: pageInfo.tralbum_artist,
95+
currency: pageInfo.currency,
96+
url: pageInfo.bandcamp_url
97+
};
98+
})();
99+
100+
log.info(`Fetching full details for item ${item.item_id} (${item.item_type})`);
101+
const apiDetails = await getTralbumDetails(item.item_id, item.item_type, BASE_URL);
102+
103+
if (!apiDetails.is_purchasable) {
104+
throw new Error(`Item "${item.item_title}" is not purchasable`);
105+
}
106+
107+
const finalPrice = apiDetails.price > 0.0 ? apiDetails.price : CURRENCY_MINIMUMS[item.currency];
108+
109+
const tralbumInfo = {
110+
id: item.item_id,
111+
type: item.item_type,
112+
title: item.item_title,
113+
tralbum_artist: item.band_name,
114+
currency: item.currency,
115+
price: finalPrice,
116+
bandcamp_url: item.url
117+
};
118+
this.processedCount += 1;
119+
120+
log.info(`Sending cart add request for item ${tralbumInfo.id} with price ${tralbumInfo.price}`);
121+
this.port?.postMessage({
122+
cartAddRequest: {
123+
item_id: tralbumInfo.id,
124+
item_type: tralbumInfo.type,
125+
item_title: tralbumInfo.title,
126+
band_name: tralbumInfo.tralbum_artist,
127+
unit_price: tralbumInfo.price,
128+
currency: tralbumInfo.currency,
129+
url: tralbumInfo.bandcamp_url
130+
}
131+
});
132+
} catch (error) {
133+
const errorMsg = error instanceof Error ? error.message : String(error);
134+
const itemId = typeof originalItem === 'string' ? originalItem : originalItem.item_id;
135+
const itemTitle = typeof originalItem === 'string' ? originalItem : originalItem.item_title;
136+
const fullErrorMsg = `Error processing ${
137+
typeof originalItem === 'string' ? 'URL' : 'item'
138+
} ${itemId}: ${errorMsg}`;
139+
this.errors.push(fullErrorMsg);
140+
log.error(fullErrorMsg);
141+
142+
this.port?.postMessage({
143+
cartItemError: {
144+
message: `Failed to add "${itemTitle}" to cart`
145+
}
146+
});
147+
}
148+
149+
this.broadcastState();
150+
}
151+
152+
this.isProcessing = false;
153+
this.processedCount = items.length;
154+
155+
log.info(`Completed ${operation} operation. Sent ${items.length} items to frontend.`);
156+
this.broadcastState();
157+
158+
const successCount = items.length - this.errors.length;
159+
const failureCount = this.errors.length;
160+
161+
const completionMessage = (() => {
162+
if (failureCount === 0) {
163+
return `Successfully added ${successCount} items to cart`;
164+
}
165+
if (successCount === 0) {
166+
return `${failureCount} items could not be added`;
167+
}
168+
return `Successfully added ${successCount} items to cart. ${failureCount} items could not be added`;
169+
})();
170+
171+
this.port?.postMessage({ cartImportComplete: { message: completionMessage } });
172+
}
173+
174+
private broadcastState(): void {
175+
const state: CartImportState = {
176+
isProcessing: this.isProcessing,
177+
processedCount: this.processedCount,
178+
totalCount: this.totalCount,
179+
errors: [...this.errors],
180+
operation: this.currentOperation
181+
};
182+
183+
this.port?.postMessage({ cartImportState: state });
184+
}
185+
186+
getState(): CartImportState {
187+
return {
188+
isProcessing: this.isProcessing,
189+
processedCount: this.processedCount,
190+
totalCount: this.totalCount,
191+
errors: [...this.errors],
192+
operation: this.currentOperation
193+
};
194+
}
195+
}
196+
197+
let importTracker: CartImportTracker;
198+
199+
export function connectionListenerCallback(
200+
port: chrome.runtime.Port,
201+
portState: { port?: chrome.runtime.Port }
202+
): void {
203+
log.info('cart import backend connection listener callback');
204+
205+
if (port.name !== 'bes') {
206+
log.error(`Unexpected chrome.runtime.onConnect port name: ${port.name}`);
207+
return;
208+
}
209+
210+
portState.port = port;
211+
importTracker = new CartImportTracker(port);
212+
213+
portState.port.onMessage.addListener((msg: any) => portListenerCallback(msg, portState));
214+
}
215+
216+
export async function portListenerCallback(msg: any, portState: { port?: chrome.runtime.Port }): Promise<void> {
217+
log.info('cart import backend port listener callback');
218+
219+
if (msg.cartImport) {
220+
try {
221+
log.info('Starting cart import process');
222+
await importTracker.processItems(msg.cartImport.items);
223+
} catch (error) {
224+
log.error(`Error in cart import process: ${error}`);
225+
const errorMessage = error instanceof Error ? error.message : String(error);
226+
portState.port?.postMessage({ cartImportError: { message: errorMessage } });
227+
}
228+
}
229+
230+
if (msg.cartUrlImport) {
231+
try {
232+
log.info('Starting cart URL import process');
233+
await importTracker.processItems(msg.cartUrlImport.urls);
234+
} catch (error) {
235+
log.error(`Error in cart URL import process: ${error}`);
236+
const errorMessage = error instanceof Error ? error.message : String(error);
237+
portState.port?.postMessage({ cartImportError: { message: errorMessage } });
238+
}
239+
}
240+
241+
if (msg.getCartImportState) {
242+
const state = importTracker?.getState();
243+
portState.port?.postMessage({ cartImportState: state });
244+
}
245+
}
246+
247+
export async function initCartImportBackend(): Promise<void> {
248+
const portState: { port?: chrome.runtime.Port } = {};
249+
250+
log.info('initializing CartImportBackend');
251+
252+
chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => connectionListenerCallback(port, portState));
253+
}

0 commit comments

Comments
 (0)