web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

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 };