web_lib

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

commit 3050d9930a0d793d12c3890312cf9aeed8e567e9
parent 881f77a51e30729f217edfffe7091c57c1f7be00
Author: triesap <triesap@radroots.dev>
Date:   Thu, 20 Nov 2025 13:48:16 +0000

utils-nostr: add structured listing and job tag parsing for quantities, prices, discounts, images, locations, and relay form field normalization

Diffstat:
Mutils-nostr/src/domain/trade/tags.ts | 6+++---
Mutils-nostr/src/events/job/tags.ts | 7+++----
Mutils-nostr/src/events/job/utils.ts | 2+-
Mutils-nostr/src/events/listing/parse.ts | 182++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mutils-nostr/src/events/listing/tags.ts | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mutils-nostr/src/index.ts | 2++
Autils-nostr/src/utils/relays.ts | 22++++++++++++++++++++++
7 files changed, 255 insertions(+), 71 deletions(-)

diff --git a/utils-nostr/src/domain/trade/tags.ts b/utils-nostr/src/domain/trade/tags.ts @@ -1,4 +1,4 @@ -import { JobInputType, RadrootsJobInput } from "@radroots/events-bindings"; +import { RadrootsJobInput } from "@radroots/events-bindings"; import { tags_job_request, tags_job_result } from "../../events/job/tags.js"; export type CommonRequestOpts = { @@ -28,7 +28,7 @@ export const make_event_input = ( relay?: string ): RadrootsJobInput => ({ data: id, - input_type: JobInputType.Event, + input_type: "event", ...(relay ? { relay } : {}), marker, }); @@ -38,7 +38,7 @@ export const make_text_input = ( marker: string ): RadrootsJobInput => ({ data: typeof payload === "string" ? payload : JSON.stringify(payload), - input_type: JobInputType.Text, + input_type: "text", marker, }); diff --git a/utils-nostr/src/events/job/tags.ts b/utils-nostr/src/events/job/tags.ts @@ -23,7 +23,7 @@ export const tags_job_providers = (pubkeys: string[]): NostrEventTags => export const tags_job_topics = (topics: string[]): NostrEventTags => topics.map(t => ["t", t]); -export const tag_job_amount = (msat: number, bolt11?: string): NostrEventTag => +export const tag_job_amount = (msat: number, bolt11?: string | null): NostrEventTag => bolt11 ? ["amount", String(msat), bolt11] : ["amount", String(msat)]; export const tag_job_encrypted = (): NostrEventTag => ["encrypted"]; @@ -55,7 +55,7 @@ export const tags_job_result = (opts: RadrootsJobResult): NostrEventTags => { } if (opts.encrypted) tags.push(tag_job_encrypted()); return tags; -} +}; export const tags_job_feedback = (opts: RadrootsJobFeedback): NostrEventTags => { const tags: NostrEventTags = []; @@ -72,4 +72,4 @@ export const tags_job_feedback = (opts: RadrootsJobFeedback): NostrEventTags => if (opts.customer_pubkey) tags.push(["p", opts.customer_pubkey]); if (opts.encrypted) tags.push(tag_job_encrypted()); return tags; -} -\ No newline at end of file +}; diff --git a/utils-nostr/src/events/job/utils.ts b/utils-nostr/src/events/job/utils.ts @@ -9,7 +9,7 @@ import type { NostrEventTags } from "../../types/lib.js"; export function get_job_input_data_for_marker( tags: NostrEventTags, marker: string, - input_type: JobInputType = JobInputType.Event + input_type: JobInputType = "event" ): string | undefined { for (const t of tags) { if (t[0] !== "i") continue; diff --git a/utils-nostr/src/events/listing/parse.ts b/utils-nostr/src/events/listing/parse.ts @@ -1,10 +1,114 @@ import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { type RadrootsListing, radroots_listing_location_schema, radroots_listing_price_schema, radroots_listing_product_schema, radroots_listing_quantity_schema, radroots_listing_schema } from "@radroots/events-bindings"; +import type { RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice } from "@radroots/core-bindings"; +import { type RadrootsListing, type RadrootsListingDiscount, radroots_listing_discount_schema, radroots_listing_image_schema, radroots_listing_location_schema, radroots_listing_price_schema, radroots_listing_product_schema, radroots_listing_quantity_schema, radroots_listing_schema } from "@radroots/events-bindings"; import { get_event_tag, get_event_tags, parse_nostr_event_basis } from "../lib.js"; import { NdkEventBasis } from "../subscription.js"; import { KIND_RADROOTS_LISTING, type KindRadrootsListing } from "./lib.js"; -export type RadrootsListingNostrEvent = NdkEventBasis<KindRadrootsListing> & { listing: RadrootsListing; } +export type RadrootsListingNostrEvent = NdkEventBasis<KindRadrootsListing> & { listing: RadrootsListing; }; + +type CoreUnit = RadrootsCoreQuantity["unit"]; +type CoreCurrency = RadrootsCoreMoney["currency"]; + +const to_number = (value?: string): number | undefined => { + if (value === undefined) return undefined; + const num = Number(value); + return Number.isFinite(num) ? num : undefined; +}; + +const clean_string = (value?: string | null) => value?.trim() || undefined; + +const parse_currency_code = (code?: string): CoreCurrency | undefined => { + const cleaned = clean_string(code); + if (!cleaned || cleaned.length < 3) return undefined; + const upper = cleaned.toUpperCase(); + return [upper.charCodeAt(0), upper.charCodeAt(1), upper.charCodeAt(2)] as CoreCurrency; +}; + +const parse_unit_code = (unit?: string): CoreUnit | undefined => { + switch ((unit ?? "").trim().toLowerCase()) { + case "each": + case "ea": + case "count": + return "Each" as CoreUnit; + case "kg": + case "kilogram": + case "kilograms": + return "MassKg" as CoreUnit; + case "g": + case "gram": + case "grams": + return "MassG" as CoreUnit; + case "oz": + case "ounce": + case "ounces": + return "MassOz" as CoreUnit; + case "lb": + case "pound": + case "pounds": + return "MassLb" as CoreUnit; + case "l": + case "liter": + case "litre": + case "liters": + case "litres": + return "VolumeL" as CoreUnit; + case "ml": + case "milliliter": + case "millilitre": + case "milliliters": + case "millilitres": + return "VolumeMl" as CoreUnit; + default: + return undefined; + } +}; + +const parse_quantity_tag = (tag: string[]) => { + if (tag.length < 3) return undefined; + const amount = to_number(tag[1]); + const unit = parse_unit_code(tag[2]); + if (amount === undefined || !unit) return undefined; + const label = clean_string(tag[3]); + const count = to_number(tag[4]); + const value: RadrootsCoreQuantity = label ? { amount, unit, label } : { amount, unit }; + return radroots_listing_quantity_schema.parse({ value, label, count }); +}; + +const parse_price_tag = (tag: string[]) => { + if (tag.length < 5) return undefined; + const amount = to_number(tag[1]); + const currency = parse_currency_code(tag[2]); + const quantity_amount = to_number(tag[3]); + const quantity_unit = parse_unit_code(tag[4]); + if (amount === undefined || !currency || quantity_amount === undefined || !quantity_unit) return undefined; + const label = clean_string(tag[5]); + const money: RadrootsCoreMoney = { amount, currency }; + const quantity: RadrootsCoreQuantity = label ? { amount: quantity_amount, unit: quantity_unit, label } : { amount: quantity_amount, unit: quantity_unit }; + return radroots_listing_price_schema.parse({ amount: money, quantity }); +}; + +const parse_discount_tag = (tag: string[]) => { + const prefix = "price-discount-"; + if (!tag[0]?.startsWith(prefix) || tag.length < 2) return undefined; + const kind = tag[0].slice(prefix.length) as RadrootsListingDiscount["kind"]; + try { + const amount = JSON.parse(tag[1]); + return radroots_listing_discount_schema.parse({ kind, amount }); + } catch { + return undefined; + } +}; + +const parse_image_tag = (tag: string[]) => { + if (tag[0] !== "image" || !tag[1]) return undefined; + const image: any = { url: tag[1] }; + if (tag[2]) { + const [w, h] = tag[2].split("x").map(v => Number(v)); + if (Number.isFinite(w) && Number.isFinite(h)) image.size = { w, h }; + } + return radroots_listing_image_schema.parse(image); +}; export const parse_nostr_listing_event = (event: NDKEvent): RadrootsListingNostrEvent | undefined => { const ev = parse_nostr_event_basis(event, KIND_RADROOTS_LISTING); @@ -12,47 +116,40 @@ export const parse_nostr_listing_event = (event: NDKEvent): RadrootsListingNostr try { const tags = event.tags; - const d_tag = get_event_tag(tags, 'd'); + const d_tag = get_event_tag(tags, "d"); const product_raw = { - key: get_event_tag(tags, 'key'), - title: get_event_tag(tags, 'title'), - category: get_event_tag(tags, 'category'), - summary: get_event_tag(tags, 'summary'), - process: get_event_tag(tags, 'process'), - lot: get_event_tag(tags, 'lot'), - location: get_event_tag(tags, 'location'), - profile: get_event_tag(tags, 'profile'), - year: get_event_tag(tags, 'year') + key: get_event_tag(tags, "key"), + title: get_event_tag(tags, "title"), + category: get_event_tag(tags, "category"), + summary: get_event_tag(tags, "summary"), + process: get_event_tag(tags, "process"), + lot: get_event_tag(tags, "lot"), + location: get_event_tag(tags, "location"), + profile: get_event_tag(tags, "profile"), + year: get_event_tag(tags, "year") }; const product = radroots_listing_product_schema.parse(product_raw); - const quantities = get_event_tags(tags, 'quantity') - .map(q => { - if (q.length < 3) return undefined; - return radroots_listing_quantity_schema.parse({ - amt: q[1], - unit: q[2], - label: q[3] - }); - }) + const quantities = get_event_tags(tags, "quantity") + .map(parse_quantity_tag) + .filter(Boolean) as RadrootsListing["quantities"]; + + const prices = get_event_tags(tags, "price") + .map(parse_price_tag) + .filter(Boolean) as RadrootsCoreQuantityPrice[]; + + const discounts = tags + .filter(t => t[0]?.startsWith("price-discount-")) + .map(parse_discount_tag) .filter(Boolean); - const prices = get_event_tags(tags, 'price') - .map(p => { - if (p.length < 6) return undefined; - return radroots_listing_price_schema.parse({ - amt: p[1], - currency: p[2], - qty_amt: p[3], - qty_unit: p[4], - qty_key: p[5] - }); - }) + const images = get_event_tags(tags, "image") + .map(parse_image_tag) .filter(Boolean); - const location_parts = get_event_tags(tags, 'location')[0]?.slice(1) ?? []; + const location_parts = get_event_tags(tags, "location")[0]?.slice(1) ?? []; const location_raw: any = {}; if (location_parts[0]) location_raw.primary = location_parts[0]; @@ -60,7 +157,20 @@ export const parse_nostr_listing_event = (event: NDKEvent): RadrootsListingNostr if (location_parts[2]) location_raw.region = location_parts[2]; if (location_parts[3]) location_raw.country = location_parts[3]; - const location = Object.keys(location_raw).length + if (location_raw.primary) { + const geohash = get_event_tags(tags, "g")[0]?.[1]; + if (geohash) location_raw.geohash = geohash; + + for (const locTag of get_event_tags(tags, "l")) { + if (locTag.length < 3) continue; + const coord = Number(locTag[1]); + if (!Number.isFinite(coord)) continue; + if (locTag[2] === "dd.lat") location_raw.lat = coord; + if (locTag[2] === "dd.lon") location_raw.lng = coord; + } + } + + const location = location_raw.primary ? radroots_listing_location_schema.parse(location_raw) : undefined; @@ -69,7 +179,9 @@ export const parse_nostr_listing_event = (event: NDKEvent): RadrootsListingNostr product, quantities, prices, - location + discounts: discounts.length ? discounts : undefined, + location, + images: images.length ? images : undefined }); return { ...ev, listing }; } catch { diff --git a/utils-nostr/src/events/listing/tags.ts b/utils-nostr/src/events/listing/tags.ts @@ -1,29 +1,80 @@ -import { RadrootsListingDiscount, RadrootsListingPrice, RadrootsListingQuantity, type RadrootsListing } from "@radroots/events-bindings"; -import { ngeotags, type InputData as NostrGeotagsInputData } from "nostr-geotags"; -import { NostrEventTag, NostrEventTagImage, NostrEventTagLocation, NostrEventTags } from "../../types/lib.js"; +import type { RadrootsCoreQuantityPrice } from "@radroots/core-bindings"; +import type { RadrootsListing, RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation, RadrootsListingQuantity } from "@radroots/events-bindings"; +import ngeotags, { type InputData as NostrGeotagsInputData } from "nostr-geotags"; +import { NostrEventTag, NostrEventTagLocation, NostrEventTags } from "../../types/lib.js"; -const tags_map = (tag: any[]) => tag.map(i => String(i).toLowerCase()); +type CoreUnit = RadrootsListingQuantity["value"]["unit"]; +type CoreCurrency = RadrootsCoreQuantityPrice["amount"]["currency"]; + +const currency_to_code = (currency: CoreCurrency): string => { + if (Array.isArray(currency) && currency.length >= 3) { + const [a, b, c] = currency; + return String.fromCharCode(Number(a), Number(b), Number(c)); + } + return String(currency); +}; + +const unit_to_code = (unit: CoreUnit): string => { + switch (unit) { + case "Each": return "each"; + case "MassKg": return "kg"; + case "MassG": return "g"; + case "MassOz": return "oz"; + case "MassLb": return "lb"; + case "VolumeL": return "l"; + case "VolumeMl": return "ml"; + default: return String(unit).toLowerCase(); + } +}; + +const clean_label = (value?: string | null) => value?.trim() || undefined; + +const normalize_listing_location = (location?: RadrootsListingLocation | null): NostrEventTagLocation | undefined => { + if (!location?.primary) return undefined; + const { primary, city, region, country, lat, lng } = location; + return { + primary, + city: city ?? undefined, + region: region ?? undefined, + country: country ?? undefined, + lat: typeof lat === "number" ? lat : undefined, + lng: typeof lng === "number" ? lng : undefined, + }; +}; + +const normalize_image_size = (size: RadrootsListingImage["size"]) => + size && typeof size?.w === "number" && typeof size?.h === "number" ? size : undefined; export const tag_listing_quantity = (opts: RadrootsListingQuantity): NostrEventTag => { - const tag = [`quantity`, opts.value.amount, opts.value.unit]; - if (opts.label) tag.push(opts.label); - return tags_map(tag); + const tag: NostrEventTag = ["quantity", String(opts.value.amount), unit_to_code(opts.value.unit)]; + const label = clean_label(opts.label ?? opts.value.label); + if (label) tag.push(label); + if (opts.count !== undefined && opts.count !== null) tag.push(String(opts.count)); + return tag; }; -export const tag_listing_price = (price: RadrootsListingPrice): NostrEventTag => { - const tag = [`price`, price.amount, price.amount.amount, price.quantity.amount, price.quantity.unit, price.quantity.label || ``]; - return tags_map(tag); +export const tag_listing_price = (price: RadrootsCoreQuantityPrice): NostrEventTag => { + const tag: NostrEventTag = [ + "price", + String(price.amount.amount), + currency_to_code(price.amount.currency).toLowerCase(), + String(price.quantity.amount), + unit_to_code(price.quantity.unit), + ]; + const label = clean_label(price.quantity.label); + if (label) tag.push(label); + return tag; }; export const tag_listing_price_discount = (discount: RadrootsListingDiscount): NostrEventTag => { - const tag = [`price-discount-${Object.keys(discount)[0]}`]; - for (const [key, value] of Object.entries(discount.amount)) tag.push(`${key}:${value}`); - return tags_map(tag); + const tag: NostrEventTag = [`price-discount-${discount.kind}`]; + tag.push(JSON.stringify(discount.amount)); + return tag; }; export const tag_listing_location = (opts: NostrEventTagLocation): NostrEventTag => { if (!opts.primary) return []; - const tag = [`location`, opts.primary]; + const tag: NostrEventTag = ["location", opts.primary]; if (opts.city) tag.push(opts.city); if (opts.region) tag.push(opts.region); if (opts.country) tag.push(opts.country); @@ -38,16 +89,16 @@ export const tags_listing_location_geotags = (opts: NostrEventTagLocation): Nost return ngeotags({ lat, lon, city, regionName, countryCode, countryName } satisfies NostrGeotagsInputData, { geohash: true, gps: true, city: true, iso31662: true }); }; - -export const tag_listing_image = (opts: NostrEventTagImage): NostrEventTag => { - const tag = [`image`, opts.url]; - if (opts.size) tag.push(`${opts.size.w}x${opts.size.h}`) +export const tag_listing_image = (opts: RadrootsListingImage): NostrEventTag => { + const tag: NostrEventTag = ["image", opts.url]; + const size = normalize_image_size(opts.size); + if (size) tag.push(`${size.w}x${size.h}`); return tag; }; export const tags_listing = (opts: RadrootsListing): NostrEventTags => { const { d_tag, product, quantities, prices } = opts; - const tags: NostrEventTags = [[`d`, d_tag]]; + const tags: NostrEventTags = [["d", d_tag]]; for (const [k, v] of Object.entries(product)) if (v) tags.push([k, String(v)]); for (const quantity of quantities) { tags.push(tag_listing_quantity(quantity)); @@ -55,13 +106,12 @@ export const tags_listing = (opts: RadrootsListing): NostrEventTags => { for (const price of prices) { tags.push(tag_listing_price(price)); } - for (const discount of opts.discounts || []) { - tags.push(tag_listing_price_discount(discount)); + if (opts.discounts?.length) for (const discount of opts.discounts) if (discount) tags.push(tag_listing_price_discount(discount)); + const location = normalize_listing_location(opts.location); + if (location) { + tags.push(tag_listing_location(location)); + tags.push(...tags_listing_location_geotags(location)); } - if (opts.location) { - tags.push(tag_listing_location(opts.location)); - tags.push(...tags_listing_location_geotags(opts.location)); - } - if (opts.images) for (const image_tags of opts.images) tags.push(tag_listing_image(image_tags)); + if (opts.images) for (const image_tags of opts.images) if (image_tags) tags.push(tag_listing_image(image_tags)); return tags; -}; -\ No newline at end of file +}; diff --git a/utils-nostr/src/index.ts b/utils-nostr/src/index.ts @@ -33,4 +33,6 @@ export * from "./schemas/lib.js" export * from "./types/lib.js" export * from "./types/ndk.js" export * from "./utils/ndk.js" +export * from "./utils/relays.js" export * from "./utils/tags.js" + diff --git a/utils-nostr/src/utils/relays.ts b/utils-nostr/src/utils/relays.ts @@ -0,0 +1,22 @@ +import type { RadrootsRelayDocument } from "@radroots/events-bindings"; + +const nostr_relay_form_field_record: Record<keyof RadrootsRelayDocument, true> = { + name: true, + description: true, + pubkey: true, + contact: true, + supported_nips: true, + software: true, + version: true +}; + +const is_nostr_relay_form_field = (value: string): value is keyof RadrootsRelayDocument => { + return value in nostr_relay_form_field_record; +}; + +export const nostr_relay_parse_form_keys = (value: string): keyof RadrootsRelayDocument | "" => { + if (is_nostr_relay_form_field(value)) { + return value; + } + return ""; +};