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:
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 "";
+};