parse.ts (13923B)
1 import type { 2 RadrootsCoreDiscount, 3 RadrootsCoreDecimal, 4 RadrootsCoreMoney, 5 RadrootsCoreQuantity, 6 RadrootsCoreQuantityPrice, 7 } from "@radroots/core-bindings"; 8 import { 9 KIND_LISTING, 10 type RadrootsListing, 11 type RadrootsListingAvailability, 12 type RadrootsListingDeliveryMethod, 13 type RadrootsFarmRef, 14 type RadrootsListingImage, 15 type RadrootsListingProduct, 16 type RadrootsListingStatus, 17 } from "@radroots/events-bindings"; 18 import type { NostrEvent } from "../../types/nostr.js"; 19 import { get_event_tag, get_event_tags, parse_nostr_event_basis } from "../lib.js"; 20 import type { NostrEventBasis } from "../subscription.js"; 21 22 export type RadrootsListingNostrEvent = NostrEventBasis<typeof KIND_LISTING> & { listing: RadrootsListing }; 23 24 type CoreUnit = RadrootsCoreQuantity["unit"]; 25 type CoreCurrency = RadrootsCoreMoney["currency"]; 26 27 type ListingBinDraft = { 28 bin_id: string; 29 quantity?: RadrootsCoreQuantity; 30 price_per_canonical_unit?: RadrootsCoreQuantityPrice; 31 display_amount?: RadrootsCoreDecimal; 32 display_unit?: CoreUnit; 33 display_label?: string; 34 display_price?: RadrootsCoreMoney; 35 display_price_unit?: CoreUnit; 36 }; 37 38 type ListingBinDraftComplete = ListingBinDraft & { 39 quantity: RadrootsCoreQuantity; 40 price_per_canonical_unit: RadrootsCoreQuantityPrice; 41 }; 42 43 type ListingLocationDraft = { 44 primary?: string; 45 city?: string; 46 region?: string; 47 country?: string; 48 geohash?: string; 49 lat?: number; 50 lng?: number; 51 }; 52 53 type ListingImageDraft = { 54 url: string; 55 size?: { 56 w: number; 57 h: number; 58 }; 59 }; 60 61 const to_number = (value?: string): number | undefined => { 62 if (value === undefined) return undefined; 63 const num = Number(value); 64 return Number.isFinite(num) ? num : undefined; 65 }; 66 67 const clean_string = (value?: string | null) => value?.trim() || undefined; 68 69 const parse_currency_code = (code?: string): CoreCurrency | undefined => { 70 const cleaned = clean_string(code); 71 if (!cleaned || cleaned.length < 3) return undefined; 72 return cleaned.toUpperCase(); 73 }; 74 75 const parse_decimal = (value?: string): RadrootsCoreDecimal | undefined => { 76 const cleaned = clean_string(value); 77 if (!cleaned) return undefined; 78 return Number.isFinite(Number(cleaned)) ? cleaned : undefined; 79 }; 80 81 const parse_unit_code = (unit?: string): CoreUnit | undefined => { 82 switch ((unit ?? "").trim().toLowerCase()) { 83 case "each": 84 case "ea": 85 case "count": 86 return "each"; 87 case "kg": 88 case "kilogram": 89 case "kilograms": 90 return "kg"; 91 case "g": 92 case "gram": 93 case "grams": 94 return "g"; 95 case "oz": 96 case "ounce": 97 case "ounces": 98 return "oz"; 99 case "lb": 100 case "pound": 101 case "pounds": 102 return "lb"; 103 case "l": 104 case "liter": 105 case "litre": 106 case "liters": 107 case "litres": 108 return "l"; 109 case "ml": 110 case "milliliter": 111 case "millilitre": 112 case "milliliters": 113 case "millilitres": 114 return "ml"; 115 default: 116 return undefined; 117 } 118 }; 119 120 const parse_bin_tag = (tag: string[]): ListingBinDraft | undefined => { 121 if (tag.length < 4) return undefined; 122 const bin_id = clean_string(tag[1]); 123 if (!bin_id) return undefined; 124 const amount = parse_decimal(tag[2]); 125 const unit = parse_unit_code(tag[3]); 126 if (amount === undefined || !unit) return undefined; 127 const quantity: RadrootsCoreQuantity = { amount, unit, label: null }; 128 const draft: ListingBinDraft = { bin_id, quantity }; 129 const display_amount = parse_decimal(tag[4]); 130 const display_unit = parse_unit_code(tag[5]); 131 if (display_amount !== undefined && display_unit) { 132 draft.display_amount = display_amount; 133 draft.display_unit = display_unit; 134 const display_label = clean_string(tag[6]); 135 if (display_label) draft.display_label = display_label; 136 } 137 return draft; 138 }; 139 140 const parse_bin_price_tag = (tag: string[]): ListingBinDraft | undefined => { 141 if (tag.length < 6) return undefined; 142 const bin_id = clean_string(tag[1]); 143 if (!bin_id) return undefined; 144 const amount = parse_decimal(tag[2]); 145 const currency = parse_currency_code(tag[3]); 146 const quantity_amount = parse_decimal(tag[4]); 147 const quantity_unit = parse_unit_code(tag[5]); 148 if (amount === undefined || !currency || quantity_amount === undefined || !quantity_unit) return undefined; 149 const money: RadrootsCoreMoney = { amount, currency }; 150 const quantity: RadrootsCoreQuantity = { amount: quantity_amount, unit: quantity_unit, label: null }; 151 const price_per_canonical_unit: RadrootsCoreQuantityPrice = { amount: money, quantity }; 152 const draft: ListingBinDraft = { bin_id, price_per_canonical_unit }; 153 const display_price_amount = parse_decimal(tag[6]); 154 const display_price_unit = parse_unit_code(tag[7]); 155 if (display_price_amount !== undefined && display_price_unit) { 156 draft.display_price = { amount: display_price_amount, currency }; 157 draft.display_price_unit = display_price_unit; 158 } 159 return draft; 160 }; 161 162 const parse_discount_tag = (tag: string[]): RadrootsCoreDiscount | undefined => { 163 if (tag[0] !== "radroots:discount" || !tag[1]) return undefined; 164 try { 165 const payload = JSON.parse(tag[1]); 166 return payload as RadrootsCoreDiscount; 167 } catch { 168 return undefined; 169 } 170 }; 171 172 const parse_image_tag = (tag: string[]): RadrootsListingImage | undefined => { 173 if (tag[0] !== "image" || !tag[1]) return undefined; 174 const image: ListingImageDraft = { url: tag[1] }; 175 if (tag[2]) { 176 const [w, h] = tag[2].split("x").map(v => Number(v)); 177 if (Number.isFinite(w) && Number.isFinite(h)) image.size = { w, h }; 178 } 179 return image; 180 }; 181 182 const parse_farm_ref = (farm_pubkey?: string, farm_address?: string): RadrootsFarmRef | undefined => { 183 const pubkey = clean_string(farm_pubkey); 184 const address = clean_string(farm_address); 185 if (!pubkey || !address) return undefined; 186 const parts = address.split(":"); 187 if (parts.length !== 3) return undefined; 188 const [kind, addr_pubkey, d_tag] = parts; 189 if (kind !== "30340") return undefined; 190 if (addr_pubkey !== pubkey) return undefined; 191 if (!d_tag?.trim()) return undefined; 192 return { pubkey, d_tag }; 193 }; 194 195 const is_listing_discount = ( 196 value: RadrootsCoreDiscount | undefined, 197 ): value is RadrootsCoreDiscount => Boolean(value); 198 199 const is_listing_image = (value: RadrootsListingImage | undefined): value is RadrootsListingImage => 200 Boolean(value); 201 202 const is_listing_bin = (value: ListingBinDraft | undefined): value is ListingBinDraftComplete => 203 Boolean(value?.quantity && value.price_per_canonical_unit); 204 205 const upsert_bin_draft = ( 206 drafts: Record<string, ListingBinDraft>, 207 order: string[], 208 draft: ListingBinDraft, 209 ): void => { 210 const existing = drafts[draft.bin_id]; 211 if (!existing) order.push(draft.bin_id); 212 drafts[draft.bin_id] = { ...existing, ...draft, bin_id: draft.bin_id }; 213 }; 214 215 const parse_listing_status = (value?: string): RadrootsListingStatus | undefined => { 216 const cleaned = clean_string(value); 217 if (!cleaned) return undefined; 218 if (cleaned === "active") return { kind: "active" }; 219 if (cleaned === "sold") return { kind: "sold" }; 220 return { kind: "other", amount: { value: cleaned } }; 221 }; 222 223 const parse_listing_availability = (tags: string[][]): RadrootsListingAvailability | undefined => { 224 const status = parse_listing_status(get_event_tag(tags, "status")); 225 if (status) return { kind: "status", amount: { status } }; 226 const start = to_number(get_event_tag(tags, "published_at")); 227 const end = to_number(get_event_tag(tags, "expires_at")); 228 if (start === undefined && end === undefined) return undefined; 229 return { kind: "window", amount: { start, end } }; 230 }; 231 232 const parse_delivery_method = (tags: string[][]): RadrootsListingDeliveryMethod | undefined => { 233 const delivery_tag = get_event_tags(tags, "delivery")[0]; 234 const kind = clean_string(delivery_tag?.[1]); 235 if (!kind) return undefined; 236 if (kind === "pickup") return { kind: "pickup" }; 237 if (kind === "local_delivery") return { kind: "local_delivery" }; 238 if (kind === "shipping") return { kind: "shipping" }; 239 if (kind !== "other") return undefined; 240 const method = clean_string(delivery_tag?.[2]); 241 if (!method) return undefined; 242 return { kind: "other", amount: { method } }; 243 }; 244 245 export const parse_nostr_listing_event = ( 246 event: NostrEvent, 247 ): RadrootsListingNostrEvent | undefined => { 248 const ev = parse_nostr_event_basis(event, KIND_LISTING); 249 if (!ev) return undefined; 250 try { 251 const tags = event.tags; 252 253 const d_tag = get_event_tag(tags, "d"); 254 const listing_d_tag = clean_string(d_tag); 255 if (!listing_d_tag) return undefined; 256 const farm_pubkey = get_event_tag(tags, "p"); 257 const farm_address = get_event_tag(tags, "a"); 258 const farm = parse_farm_ref(farm_pubkey, farm_address); 259 if (!farm) return undefined; 260 261 const product_raw = { 262 key: get_event_tag(tags, "key"), 263 title: get_event_tag(tags, "title"), 264 category: get_event_tag(tags, "category"), 265 summary: get_event_tag(tags, "summary"), 266 process: get_event_tag(tags, "process"), 267 lot: get_event_tag(tags, "lot"), 268 location: get_event_tag(tags, "location"), 269 profile: get_event_tag(tags, "profile"), 270 year: get_event_tag(tags, "year"), 271 }; 272 273 const product_key = clean_string(product_raw.key); 274 const product_title = clean_string(product_raw.title); 275 const product_category = clean_string(product_raw.category); 276 if (!product_key || !product_title || !product_category) return undefined; 277 const product: RadrootsListingProduct = { 278 key: product_key, 279 title: product_title, 280 category: product_category, 281 summary: clean_string(product_raw.summary) ?? null, 282 process: clean_string(product_raw.process) ?? null, 283 lot: clean_string(product_raw.lot) ?? null, 284 location: clean_string(product_raw.location) ?? null, 285 profile: clean_string(product_raw.profile) ?? null, 286 year: clean_string(product_raw.year) ?? null, 287 }; 288 289 const bin_drafts: Record<string, ListingBinDraft> = {}; 290 const bin_order: string[] = []; 291 for (const tag of get_event_tags(tags, "radroots:bin")) { 292 const draft = parse_bin_tag(tag); 293 if (draft) upsert_bin_draft(bin_drafts, bin_order, draft); 294 } 295 for (const tag of get_event_tags(tags, "radroots:price")) { 296 const draft = parse_bin_price_tag(tag); 297 if (draft) upsert_bin_draft(bin_drafts, bin_order, draft); 298 } 299 const bins = bin_order 300 .map(bin_id => bin_drafts[bin_id]) 301 .filter(is_listing_bin); 302 if (!bins.length) return undefined; 303 const primary_bin_tag = clean_string(get_event_tag(tags, "radroots:primary_bin")); 304 const primary_bin_id = bins.some(bin => bin.bin_id === primary_bin_tag) 305 ? primary_bin_tag 306 : bins[0]?.bin_id; 307 if (!primary_bin_id) return undefined; 308 309 const discounts = get_event_tags(tags, "radroots:discount") 310 .map(parse_discount_tag) 311 .filter(is_listing_discount); 312 313 const images = get_event_tags(tags, "image") 314 .map(parse_image_tag) 315 .filter(is_listing_image); 316 317 const location_parts = get_event_tags(tags, "location")[0]?.slice(1) ?? []; 318 319 const location_raw: ListingLocationDraft = {}; 320 if (location_parts[0]) location_raw.primary = location_parts[0]; 321 if (location_parts[1]) location_raw.city = location_parts[1]; 322 if (location_parts[2]) location_raw.region = location_parts[2]; 323 if (location_parts[3]) location_raw.country = location_parts[3]; 324 325 if (location_raw.primary) { 326 const geohash = get_event_tags(tags, "g")[0]?.[1]; 327 if (geohash) location_raw.geohash = geohash; 328 329 for (const loc_tag of get_event_tags(tags, "l")) { 330 if (loc_tag.length < 3) continue; 331 const coord = Number(loc_tag[1]); 332 if (!Number.isFinite(coord)) continue; 333 if (loc_tag[2] === "dd.lat") location_raw.lat = coord; 334 if (loc_tag[2] === "dd.lon") location_raw.lng = coord; 335 } 336 } 337 338 const location = location_raw.primary 339 ? { 340 primary: location_raw.primary, 341 city: location_raw.city ?? null, 342 region: location_raw.region ?? null, 343 country: location_raw.country ?? null, 344 lat: location_raw.lat ?? null, 345 lng: location_raw.lng ?? null, 346 geohash: location_raw.geohash ?? null, 347 } 348 : undefined; 349 350 const inventory_available = parse_decimal(get_event_tag(tags, "inventory")); 351 const availability = parse_listing_availability(tags); 352 const delivery_method = parse_delivery_method(tags); 353 354 const listing: RadrootsListing = { 355 d_tag: listing_d_tag, 356 farm, 357 product, 358 primary_bin_id, 359 bins, 360 discounts: discounts.length ? discounts : null, 361 inventory_available, 362 availability, 363 delivery_method, 364 location, 365 images: images.length ? images : null, 366 resource_area: null, 367 plot: null, 368 }; 369 return { ...ev, listing }; 370 } catch { 371 return undefined; 372 } 373 };