@stacksjs/commerce 0.75.423 → 0.75.432
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/imports/shopify.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{slugify}from"@stacksjs/slug";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,decimalToMinor,PriceFormatError}from"./money";import{optionalString}from"./text";const PAGE_SIZE=250,MAX_PAGES=400;function isDefaultOptionSet(options){if(!options||options.length===0)return!0;if(options.length!==1)return!1;const[only]=options;return only?.name==="Title"&&(only.values?.length??0)<=1&&(only.values?.[0]??"Default Title")==="Default Title"}function tagsOf(tags){return(Array.isArray(tags)?tags:typeof tags==="string"?tags.split(","):[]).map((tag)=>String(tag).trim()).filter(Boolean)}function price(value,exponent,label,externalId,warnings){try{return decimalToMinor(value,exponent)}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; left unpriced`});return null}}export function mapShopifyProduct(raw,context){if(raw?.id===void 0||raw.id===null||raw.id==="")throw TypeError("Shopify product has no id");const externalId=String(raw.id),warnings=[],name=(optionalString(raw.title)??"").replace(/\s+/g," ");if(!name)throw TypeError(`Shopify product ${externalId} has no title`);const handle=optionalString(raw.handle)??slugify(name),exponent=currencyExponent(context.currency),hasOptions=!isDefaultOptionSet(raw.options),optionNames=hasOptions?(raw.options??[]).map((option)=>optionalString(option.name)??"").filter(Boolean):[],variants=(raw.variants??[]).map((variant)=>{const variantId=String(variant.id),compareAt=price(variant.compare_at_price,exponent,`variant ${variantId} compare-at price`,externalId,warnings);return{externalId:variantId,title:optionalString(variant.title)??name,sku:optionalString(variant.sku),priceMinor:price(variant.price,exponent,`variant ${variantId} price`,externalId,warnings),compareAtMinor:compareAt?compareAt:null,available:variant.available!==!1,inventory:typeof variant.inventory_quantity==="number"?Math.max(0,Math.trunc(variant.inventory_quantity)):null,optionValues:hasOptions?[variant.option1,variant.option2,variant.option3].slice(0,optionNames.length).map((value)=>optionalString(value)??""):[]}});if(variants.length===0)warnings.push({externalId,message:`"${name}" has no variants; imported without a price`});const images=[...raw.images??[]].filter((image)=>optionalString(image?.src)).sort((a,b)=>(a.position??0)-(b.position??0)).map((image)=>({src:optionalString(image.src),alt:optionalString(image.alt)})),productType=optionalString(raw.product_type);return{product:{source:"shopify",externalId,name,handle,descriptionHtml:optionalString(raw.body_html),vendor:optionalString(raw.vendor),categories:productType?[{name:productType,slug:slugify(productType)}]:[],tags:tagsOf(raw.tags),currency:context.currency,images,optionNames,variants,hasOptions,url:`${context.storeUrl}/products/${handle}`},warnings}}export async function detectShopifyCurrency(storeUrl,fetcher=fetch){try{const{data}=await fetchJson(`${storeUrl}/meta.json`,fetcher),code=typeof data?.currency==="string"?data.currency.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}catch{return null}}async function*shopifyPages(storeUrl,options={}){const base=normalizeStoreUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,setupWarnings=[];let currency=options.currency?.toUpperCase()??null;if(!currency){currency=await detectShopifyCurrency(base,fetcher);if(!currency)setupWarnings.push({message:`${base}/meta.json did not report a currency; prices read as 2-decimal amounts. Pass --currency to be sure.`})}const seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const url=`${base}/products.json?limit=${pageSize}&page=${page}`,{data}=await fetchJson(url,fetcher,"Is this a Shopify storefront, and is it public (no storefront password)?");if(!Array.isArray(data?.products))throw new CatalogFetchError(url,`${url} did not return a "products" list. Is this a Shopify storefront?`);const raw=data.products;if(raw.length===0)return;const ids=raw.map((product)=>String(product?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${url} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const products=[],warnings=page===1?setupWarnings:[];for(const entry of raw){if(limit&&emitted>=limit)break;try{const mapped=mapShopifyProduct(entry,{storeUrl:base,currency});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}}yield{url,products,warnings};if(limit&&emitted>=limit)return}throw new CatalogFetchError(base,`Stopped after ${MAX_PAGES} pages of ${base}/products.json without reaching the end.`)}export const shopifyAdapter={name:"shopify",pages:shopifyPages};
|
package/dist/imports/shopware.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{slug}from"@stacksjs/strings";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,numberToMinor,PriceFormatError}from"./money";import{optionalString}from"./text";const PAGE_SIZE=100,MAX_PAGES=1000,API_PREFIX="/store-api",HINT="Is this a Shopware 6 store with the Store API reachable at /store-api?";export const SHOPWARE_ACCESS_KEY_HELP='Find it in the Shopware Administration under Sales Channels: open the storefront or headless sales channel and copy the key from its "API access" card. A headless storefront also ships it in its page source.';function translated(entity,field){if(!entity)return null;const record=entity,bag=record.translated;return(bag&&typeof bag==="object"&&!Array.isArray(bag)?optionalString(bag[field]):null)??optionalString(record[field])}function nameOf(entity){return translated(entity,"name")?.replace(/\s+/g," ")??null}function stockOf(raw){const value=typeof raw.availableStock==="number"?raw.availableStock:raw.stock;return typeof value==="number"&&Number.isFinite(value)?Math.max(0,Math.trunc(value)):null}function availableOf(raw){return raw.available!==!1&&raw.active!==!1}function pricesOf(raw,label,externalId,context,warnings){const exponent=currencyExponent(context.currency),unit=raw.calculatedPrice?.unitPrice;if(unit===null||unit===void 0||unit===""){warnings.push({externalId,message:`${label} has no price in ${context.currency??"the sales channel currency"}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}try{const priceMinor=numberToMinor(unit,exponent),listMinor=numberToMinor(raw.calculatedPrice?.listPrice?.price??null,exponent);return{priceMinor,compareAtMinor:listMinor!==null&&priceMinor!==null&&listMinor>priceMinor?listMinor:null}}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}}function imagesOf(raw){const entries=[...raw.media??[]].sort((a,b)=>(a?.position??0)-(b?.position??0));if(raw.cover)entries.unshift(raw.cover);const seen=new Set,images=[];for(const entry of entries){const src=optionalString(entry?.media?.url);if(!src||seen.has(src))continue;seen.add(src);const media=entry.media;images.push({src,alt:translated(media,"alt")??translated(media,"title")})}return images}function categoriesOf(raw){const seen=new Set,categories=[];for(const entry of[raw.seoCategory,...raw.categories??[]]){const name=nameOf(entry),key=name?slug(name):"";if(!name||!key||seen.has(key))continue;seen.add(key);categories.push({name,slug:key})}return categories}function optionAxes(children){const axes=new Map;for(const child of children)for(const option of child.options??[]){const name=nameOf(option.group),key=optionalString(option.groupId)??optionalString(option.group?.id)??name;if(!key||!name||axes.has(key))continue;axes.set(key,{key,name,position:option.group?.position??0,order:axes.size})}return[...axes.values()].sort((a,b)=>a.position-b.position||a.order-b.order)}function optionFor(child,axis){return(child.options??[]).find((option)=>(optionalString(option.groupId)??optionalString(option.group?.id)??nameOf(option.group))===axis.key)}export function mapShopwareProduct(raw,context){if(!optionalString(raw?.id))throw TypeError("Shopware product has no id");const externalId=raw.id,warnings=[],name=nameOf(raw);if(!name)throw TypeError(`Shopware product ${externalId} has no name`);const children=context.children??[],childCount=typeof raw.childCount==="number"?raw.childCount:0,hasOptions=children.length>0;let variants,optionNames=[];if(hasOptions){const axes=optionAxes(children);optionNames=axes.map((axis)=>axis.name);const rows=children.map((child)=>{const picked=axes.map((axis)=>optionFor(child,axis)),optionValues=picked.map((option)=>nameOf(option)??""),productNumber=optionalString(child.productNumber),label=`variant ${productNumber??child.id}`,prices=pricesOf(child,label,externalId,context,warnings);return{variant:{externalId:String(child.id),title:optionValues.filter(Boolean).join(" / ")||nameOf(child)||productNumber||name,sku:productNumber,priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(child),inventory:stockOf(child),optionValues},sortKey:picked.map((option)=>option?.position??0),productNumber:productNumber??""}});rows.sort((a,b)=>{for(let i=0;i<a.sortKey.length;i++){const delta=(a.sortKey[i]??0)-(b.sortKey[i]??0);if(delta!==0)return delta}return a.productNumber.localeCompare(b.productNumber)});variants=rows.map((row)=>row.variant)}else if(childCount>0){warnings.push({externalId,message:`"${name}" has ${childCount} variant${childCount===1?"":"s"} in Shopware, none visible to this sales channel; imported without a price`});variants=[]}else{const prices=pricesOf(raw,`"${name}"`,externalId,context,warnings);variants=[{externalId,title:name,sku:optionalString(raw.productNumber),priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(raw),inventory:stockOf(raw),optionValues:[]}]}return{product:{source:"shopware",externalId,name,handle:slug(name),descriptionHtml:translated(raw,"description"),vendor:nameOf(raw.manufacturer),categories:categoriesOf(raw),tags:(raw.tags??[]).map((tag)=>optionalString(tag?.name)).filter((tag)=>tag!==null),currency:context.currency,images:imagesOf(raw),optionNames,variants,hasOptions,url:`${context.storeUrl}/detail/${externalId}`},warnings}}export function shopwareBaseUrl(storeUrl){return normalizeStoreUrl(storeUrl).replace(/\/store-api$/i,"")}export function missingAccessKeyMessage(){return`Shopware's Store API needs the sales channel access key: pass --access-key <key> or set SHOPWARE_ACCESS_KEY. It is public by design and not a secret. ${SHOPWARE_ACCESS_KEY_HELP}`}function shopwareErrorDetail(body){if(!body)return null;try{const first=JSON.parse(body)?.errors?.[0];return optionalString(first?.detail)??optionalString(first?.title)}catch{return null}}function explain(error){const detail=shopwareErrorDetail(error.body),said=detail?` Shopware said: "${detail}"`:"";if(error.status===401||error.status===403||error.status===412)return new CatalogFetchError(error.url,`${error.message}${said} The sales channel access key looks wrong: check --access-key or SHOPWARE_ACCESS_KEY. ${SHOPWARE_ACCESS_KEY_HELP}`,error.status,error.body);return new CatalogFetchError(error.url,`${error.message}${said} ${HINT}`,error.status,error.body)}async function storeApi(url,accessKey,fetcher,json){try{const{data}=await fetchJson(url,fetcher,"",{headers:{"sw-access-key":accessKey},json});return data}catch(error){if(!(error instanceof CatalogFetchError))throw error;throw explain(error)}}function isKeyError(error){return error instanceof CatalogFetchError&&(error.status===401||error.status===403||error.status===412)}export async function detectShopwareCurrency(base,accessKey,fetcher=fetch){try{const data=await storeApi(`${base}${API_PREFIX}/context`,accessKey,fetcher),code=typeof data?.currency?.isoCode==="string"?data.currency.isoCode.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}catch(error){if(isKeyError(error))throw error;return null}}const INCLUDES={product:["id","parentId","productNumber","name","description","translated","calculatedPrice","stock","availableStock","available","active","childCount","manufacturer","seoCategory","categories","tags","cover","media","options"],calculated_price:["unitPrice","quantity","listPrice"],cart_list_price:["price"],product_media:["position","media"],media:["url","alt","title","translated"],product_manufacturer:["name","translated"],category:["name","translated"],tag:["name"],property_group_option:["id","name","translated","position","groupId","group"],property_group:["id","name","translated","position"]};export function shopwareProductCriteria(page,limit){return{page,limit,"total-count-mode":"exact",filter:[{type:"equals",field:"parentId",value:null}],sort:[{field:"id",order:"ASC"}],associations:{cover:{},media:{associations:{media:{}}},manufacturer:{},categories:{},tags:{}},includes:INCLUDES}}export function shopwareVariantCriteria(parentIds,page){return{page,limit:PAGE_SIZE,"total-count-mode":"exact",filter:[{type:"equalsAny",field:"parentId",value:parentIds}],sort:[{field:"id",order:"ASC"}],associations:{options:{associations:{group:{}}}},includes:INCLUDES}}function isLastPage(data,page,pageSize,received){if(received<pageSize)return!0;const total=typeof data.total==="number"?data.total:Number.NaN;return Number.isInteger(total)&&page*pageSize>=total}async function fetchChildren(base,accessKey,parentIds,fetcher){const found=new Map;if(parentIds.length===0)return found;const url=`${base}${API_PREFIX}/product`;for(let page=1;page<=MAX_PAGES;page++){const data=await storeApi(url,accessKey,fetcher,shopwareVariantCriteria(parentIds,page));if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${url} did not return a product list for the variants. ${HINT}`);const elements=data.elements;for(const child of elements){const parent=optionalString(child?.parentId);if(!parent)continue;const list=found.get(parent)??[];list.push(child);found.set(parent,list)}if(elements.length===0||isLastPage(data,page,PAGE_SIZE,elements.length))return found}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of variants from ${url} without reaching the end.`)}async function*shopwarePages(storeUrl,options={}){const accessKey=options.accessKey?.trim();if(!accessKey)throw TypeError(missingAccessKeyMessage());const base=shopwareBaseUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,url=`${base}${API_PREFIX}/product`,setupWarnings=[],override=options.currency?.trim().toUpperCase()||null,detected=await detectShopwareCurrency(base,accessKey,fetcher),currency=detected??override;if(detected&&override&&detected!==override)setupWarnings.push({message:`--currency ${override} ignored: this sales channel prices in ${detected}, and the Store API does not convert.`});if(!currency)setupWarnings.push({message:`${base}${API_PREFIX}/context did not report a currency; prices read as 2-decimal amounts. Pass --currency to be sure.`});const seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const label=`${url} (page ${page})`;let data;try{data=await storeApi(url,accessKey,fetcher,shopwareProductCriteria(page,pageSize))}catch(error){if(error instanceof CatalogFetchError)throw new CatalogFetchError(error.url,error.message.replace(error.url,label),error.status,error.body);throw error}if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${label} did not return a product list. ${HINT}`);const elements=data.elements;if(elements.length===0)return;const ids=elements.map((element)=>String(element?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${label} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const take=limit?elements.slice(0,Math.max(0,limit-emitted)):elements,warnings=page===1?setupWarnings:[],parents=take.filter((item)=>typeof item?.childCount==="number"&&item.childCount>0).map((item)=>String(item.id)),children=await fetchChildren(base,accessKey,parents,fetcher),products=[];for(const entry of take)try{const mapped=mapShopwareProduct(entry,{storeUrl:base,currency,children:children.get(String(entry?.id))});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}yield{url:label,products,warnings};if(limit&&emitted>=limit)return;if(isLastPage(data,page,pageSize,elements.length))return}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of ${url} without reaching the end.`)}export const shopwareAdapter={name:"shopware",pages:shopwarePages};
|
|
1
|
+
import{slugify}from"@stacksjs/slug";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,numberToMinor,PriceFormatError}from"./money";import{optionalString}from"./text";const PAGE_SIZE=100,MAX_PAGES=1000,API_PREFIX="/store-api",HINT="Is this a Shopware 6 store with the Store API reachable at /store-api?";export const SHOPWARE_ACCESS_KEY_HELP='Find it in the Shopware Administration under Sales Channels: open the storefront or headless sales channel and copy the key from its "API access" card. A headless storefront also ships it in its page source.';function translated(entity,field){if(!entity)return null;const record=entity,bag=record.translated;return(bag&&typeof bag==="object"&&!Array.isArray(bag)?optionalString(bag[field]):null)??optionalString(record[field])}function nameOf(entity){return translated(entity,"name")?.replace(/\s+/g," ")??null}function stockOf(raw){const value=typeof raw.availableStock==="number"?raw.availableStock:raw.stock;return typeof value==="number"&&Number.isFinite(value)?Math.max(0,Math.trunc(value)):null}function availableOf(raw){return raw.available!==!1&&raw.active!==!1}function pricesOf(raw,label,externalId,context,warnings){const exponent=currencyExponent(context.currency),unit=raw.calculatedPrice?.unitPrice;if(unit===null||unit===void 0||unit===""){warnings.push({externalId,message:`${label} has no price in ${context.currency??"the sales channel currency"}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}try{const priceMinor=numberToMinor(unit,exponent),listMinor=numberToMinor(raw.calculatedPrice?.listPrice?.price??null,exponent);return{priceMinor,compareAtMinor:listMinor!==null&&priceMinor!==null&&listMinor>priceMinor?listMinor:null}}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}}function imagesOf(raw){const entries=[...raw.media??[]].sort((a,b)=>(a?.position??0)-(b?.position??0));if(raw.cover)entries.unshift(raw.cover);const seen=new Set,images=[];for(const entry of entries){const src=optionalString(entry?.media?.url);if(!src||seen.has(src))continue;seen.add(src);const media=entry.media;images.push({src,alt:translated(media,"alt")??translated(media,"title")})}return images}function categoriesOf(raw){const seen=new Set,categories=[];for(const entry of[raw.seoCategory,...raw.categories??[]]){const name=nameOf(entry),key=name?slugify(name):"";if(!name||!key||seen.has(key))continue;seen.add(key);categories.push({name,slug:key})}return categories}function optionAxes(children){const axes=new Map;for(const child of children)for(const option of child.options??[]){const name=nameOf(option.group),key=optionalString(option.groupId)??optionalString(option.group?.id)??name;if(!key||!name||axes.has(key))continue;axes.set(key,{key,name,position:option.group?.position??0,order:axes.size})}return[...axes.values()].sort((a,b)=>a.position-b.position||a.order-b.order)}function optionFor(child,axis){return(child.options??[]).find((option)=>(optionalString(option.groupId)??optionalString(option.group?.id)??nameOf(option.group))===axis.key)}export function mapShopwareProduct(raw,context){if(!optionalString(raw?.id))throw TypeError("Shopware product has no id");const externalId=raw.id,warnings=[],name=nameOf(raw);if(!name)throw TypeError(`Shopware product ${externalId} has no name`);const children=context.children??[],childCount=typeof raw.childCount==="number"?raw.childCount:0,hasOptions=children.length>0;let variants,optionNames=[];if(hasOptions){const axes=optionAxes(children);optionNames=axes.map((axis)=>axis.name);const rows=children.map((child)=>{const picked=axes.map((axis)=>optionFor(child,axis)),optionValues=picked.map((option)=>nameOf(option)??""),productNumber=optionalString(child.productNumber),label=`variant ${productNumber??child.id}`,prices=pricesOf(child,label,externalId,context,warnings);return{variant:{externalId:String(child.id),title:optionValues.filter(Boolean).join(" / ")||nameOf(child)||productNumber||name,sku:productNumber,priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(child),inventory:stockOf(child),optionValues},sortKey:picked.map((option)=>option?.position??0),productNumber:productNumber??""}});rows.sort((a,b)=>{for(let i=0;i<a.sortKey.length;i++){const delta=(a.sortKey[i]??0)-(b.sortKey[i]??0);if(delta!==0)return delta}return a.productNumber.localeCompare(b.productNumber)});variants=rows.map((row)=>row.variant)}else if(childCount>0){warnings.push({externalId,message:`"${name}" has ${childCount} variant${childCount===1?"":"s"} in Shopware, none visible to this sales channel; imported without a price`});variants=[]}else{const prices=pricesOf(raw,`"${name}"`,externalId,context,warnings);variants=[{externalId,title:name,sku:optionalString(raw.productNumber),priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(raw),inventory:stockOf(raw),optionValues:[]}]}return{product:{source:"shopware",externalId,name,handle:slugify(name),descriptionHtml:translated(raw,"description"),vendor:nameOf(raw.manufacturer),categories:categoriesOf(raw),tags:(raw.tags??[]).map((tag)=>optionalString(tag?.name)).filter((tag)=>tag!==null),currency:context.currency,images:imagesOf(raw),optionNames,variants,hasOptions,url:`${context.storeUrl}/detail/${externalId}`},warnings}}export function shopwareBaseUrl(storeUrl){return normalizeStoreUrl(storeUrl).replace(/\/store-api$/i,"")}export function missingAccessKeyMessage(){return`Shopware's Store API needs the sales channel access key: pass --access-key <key> or set SHOPWARE_ACCESS_KEY. It is public by design and not a secret. ${SHOPWARE_ACCESS_KEY_HELP}`}function shopwareErrorDetail(body){if(!body)return null;try{const first=JSON.parse(body)?.errors?.[0];return optionalString(first?.detail)??optionalString(first?.title)}catch{return null}}function explain(error){const detail=shopwareErrorDetail(error.body),said=detail?` Shopware said: "${detail}"`:"";if(error.status===401||error.status===403||error.status===412)return new CatalogFetchError(error.url,`${error.message}${said} The sales channel access key looks wrong: check --access-key or SHOPWARE_ACCESS_KEY. ${SHOPWARE_ACCESS_KEY_HELP}`,error.status,error.body);return new CatalogFetchError(error.url,`${error.message}${said} ${HINT}`,error.status,error.body)}async function storeApi(url,accessKey,fetcher,json){try{const{data}=await fetchJson(url,fetcher,"",{headers:{"sw-access-key":accessKey},json});return data}catch(error){if(!(error instanceof CatalogFetchError))throw error;throw explain(error)}}function isKeyError(error){return error instanceof CatalogFetchError&&(error.status===401||error.status===403||error.status===412)}export async function detectShopwareCurrency(base,accessKey,fetcher=fetch){try{const data=await storeApi(`${base}${API_PREFIX}/context`,accessKey,fetcher),code=typeof data?.currency?.isoCode==="string"?data.currency.isoCode.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}catch(error){if(isKeyError(error))throw error;return null}}const INCLUDES={product:["id","parentId","productNumber","name","description","translated","calculatedPrice","stock","availableStock","available","active","childCount","manufacturer","seoCategory","categories","tags","cover","media","options"],calculated_price:["unitPrice","quantity","listPrice"],cart_list_price:["price"],product_media:["position","media"],media:["url","alt","title","translated"],product_manufacturer:["name","translated"],category:["name","translated"],tag:["name"],property_group_option:["id","name","translated","position","groupId","group"],property_group:["id","name","translated","position"]};export function shopwareProductCriteria(page,limit){return{page,limit,"total-count-mode":"exact",filter:[{type:"equals",field:"parentId",value:null}],sort:[{field:"id",order:"ASC"}],associations:{cover:{},media:{associations:{media:{}}},manufacturer:{},categories:{},tags:{}},includes:INCLUDES}}export function shopwareVariantCriteria(parentIds,page){return{page,limit:PAGE_SIZE,"total-count-mode":"exact",filter:[{type:"equalsAny",field:"parentId",value:parentIds}],sort:[{field:"id",order:"ASC"}],associations:{options:{associations:{group:{}}}},includes:INCLUDES}}function isLastPage(data,page,pageSize,received){if(received<pageSize)return!0;const total=typeof data.total==="number"?data.total:Number.NaN;return Number.isInteger(total)&&page*pageSize>=total}async function fetchChildren(base,accessKey,parentIds,fetcher){const found=new Map;if(parentIds.length===0)return found;const url=`${base}${API_PREFIX}/product`;for(let page=1;page<=MAX_PAGES;page++){const data=await storeApi(url,accessKey,fetcher,shopwareVariantCriteria(parentIds,page));if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${url} did not return a product list for the variants. ${HINT}`);const elements=data.elements;for(const child of elements){const parent=optionalString(child?.parentId);if(!parent)continue;const list=found.get(parent)??[];list.push(child);found.set(parent,list)}if(elements.length===0||isLastPage(data,page,PAGE_SIZE,elements.length))return found}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of variants from ${url} without reaching the end.`)}async function*shopwarePages(storeUrl,options={}){const accessKey=options.accessKey?.trim();if(!accessKey)throw TypeError(missingAccessKeyMessage());const base=shopwareBaseUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,url=`${base}${API_PREFIX}/product`,setupWarnings=[],override=options.currency?.trim().toUpperCase()||null,detected=await detectShopwareCurrency(base,accessKey,fetcher),currency=detected??override;if(detected&&override&&detected!==override)setupWarnings.push({message:`--currency ${override} ignored: this sales channel prices in ${detected}, and the Store API does not convert.`});if(!currency)setupWarnings.push({message:`${base}${API_PREFIX}/context did not report a currency; prices read as 2-decimal amounts. Pass --currency to be sure.`});const seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const label=`${url} (page ${page})`;let data;try{data=await storeApi(url,accessKey,fetcher,shopwareProductCriteria(page,pageSize))}catch(error){if(error instanceof CatalogFetchError)throw new CatalogFetchError(error.url,error.message.replace(error.url,label),error.status,error.body);throw error}if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${label} did not return a product list. ${HINT}`);const elements=data.elements;if(elements.length===0)return;const ids=elements.map((element)=>String(element?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${label} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const take=limit?elements.slice(0,Math.max(0,limit-emitted)):elements,warnings=page===1?setupWarnings:[],parents=take.filter((item)=>typeof item?.childCount==="number"&&item.childCount>0).map((item)=>String(item.id)),children=await fetchChildren(base,accessKey,parents,fetcher),products=[];for(const entry of take)try{const mapped=mapShopwareProduct(entry,{storeUrl:base,currency,children:children.get(String(entry?.id))});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}yield{url:label,products,warnings};if(limit&&emitted>=limit)return;if(isLastPage(data,page,pageSize,elements.length))return}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of ${url} without reaching the end.`)}export const shopwareAdapter={name:"shopware",pages:shopwarePages};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{slugify}from"@stacksjs/slug";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,PriceFormatError,rescaleMinor}from"./money";import{cleanName,optionalString}from"./text";const PAGE_SIZE=100,MAX_PAGES=1000,API_PATH="/wp-json/wc/store/v1/products",HINT="Is WooCommerce 6+ installed with the Store API reachable at /wp-json?";export function wooPrices(prices){const currency=optionalString(prices?.currency_code)?.toUpperCase()??null,declared=Number(prices?.currency_minor_unit),fromExponent=Number.isInteger(declared)&&declared>=0?declared:2,toExponent=currencyExponent(currency),priceMinor=rescaleMinor(prices?.price,fromExponent,toExponent),regularMinor=rescaleMinor(prices?.regular_price,fromExponent,toExponent);return{currency,priceMinor,compareAtMinor:regularMinor!==null&&priceMinor!==null&®ularMinor>priceMinor?regularMinor:null}}function safePrices(payload,externalId,warnings){try{return wooPrices(payload.prices)}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${error.message}; left unpriced`});return{currency:optionalString(payload.prices?.currency_code)?.toUpperCase()??null,priceMinor:null,compareAtMinor:null}}}function terms(list){return(list??[]).map((term)=>{const name=cleanName(term?.name);return{name,slug:optionalString(term?.slug)??slugify(name)}}).filter((term)=>term.name&&term.slug)}export function wooSlug(value){const raw=optionalString(value);if(!raw||!raw.includes("%"))return raw;try{return decodeURIComponent(raw)}catch{return raw}}function inventoryOf(payload){return typeof payload.low_stock_remaining==="number"?Math.max(0,Math.trunc(payload.low_stock_remaining)):null}function availableOf(payload){return payload.is_in_stock!==!1&&payload.is_purchasable!==!1}export function mapWooProduct(raw,context={}){if(raw?.id===void 0||raw.id===null||raw.id==="")throw TypeError("WooCommerce product has no id");const externalId=String(raw.id),warnings=[],name=cleanName(raw.name);if(!name)throw TypeError(`WooCommerce product ${externalId} has no name`);const own=safePrices(raw,externalId,warnings),variable=raw.type==="variable"&&(raw.variations?.length??0)>0,optionAttributes=variable?(raw.attributes??[]).filter((attribute)=>attribute.has_variations!==!1):[],optionNames=optionAttributes.map((attribute)=>cleanName(attribute.name)).filter(Boolean);let variants,currency=own.currency;if(variable)variants=(raw.variations??[]).map((entry)=>{const variationId=String(entry.id),optionValues=optionAttributes.map((attribute)=>{const label=cleanName(attribute.name).toLowerCase(),taxonomy=(attribute.taxonomy??"").toLowerCase(),picked=(entry.attributes??[]).find((value)=>{const key=cleanName(value.name).toLowerCase();return key===label||taxonomy!==""&&key===taxonomy}),value=optionalString(picked?.value);if(!value)return"Any";const term=(attribute.terms??[]).find((candidate)=>candidate.slug===value);return term?cleanName(term.name):cleanName(value)}),detail=context.variations?.get(variationId);if(!detail){warnings.push({externalId,message:`variation ${variationId} could not be fetched; imported without a price`});return{externalId:variationId,title:optionValues.join(" / ")||name,sku:null,priceMinor:null,compareAtMinor:null,available:!1,inventory:null,optionValues}}const prices=safePrices(detail,variationId,warnings);currency??=prices.currency;return{externalId:variationId,title:optionValues.join(" / ")||cleanName(detail.name)||name,sku:optionalString(detail.sku),priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(detail),inventory:inventoryOf(detail),optionValues}});else variants=[{externalId,title:name,sku:optionalString(raw.sku),priceMinor:own.priceMinor,compareAtMinor:own.compareAtMinor,available:availableOf(raw),inventory:inventoryOf(raw),optionValues:[]}];const brand=terms(raw.brands)[0];return{product:{source:"woocommerce",externalId,name,handle:wooSlug(raw.slug)??slugify(name),descriptionHtml:optionalString(raw.description)??optionalString(raw.short_description),vendor:brand?brand.name:null,categories:terms(raw.categories),tags:terms(raw.tags).map((tag)=>tag.name),currency,images:(raw.images??[]).filter((image)=>optionalString(image?.src)).map((image)=>({src:optionalString(image.src),alt:optionalString(image.alt)})),optionNames:variable?optionNames:[],variants,hasOptions:variable,url:optionalString(raw.permalink)},warnings}}async function listPage(url,fetcher){const{data,headers}=await fetchJson(url,fetcher,HINT);if(!Array.isArray(data))throw new CatalogFetchError(url,`${url} did not return a product list. ${HINT}`);const header=headers.get("x-wp-totalpages")?.trim(),total=header?Number(header):Number.NaN;return{url,items:data,totalPages:Number.isInteger(total)&&total>=0?total:null}}async function fetchVariations(base,parentIds,fetcher){const found=new Map;if(parentIds.length===0)return found;for(let page=1;page<=MAX_PAGES;page++){const url=`${base}${API_PATH}?type=variation&parent=${parentIds.join(",")}&per_page=${PAGE_SIZE}&page=${page}`,result=await listPage(url,fetcher);for(const item of result.items)found.set(String(item.id),item);if(result.items.length===0||result.totalPages!==null&&page>=result.totalPages)break}return found}async function*wooPages(storeUrl,options={}){const base=normalizeStoreUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const url=`${base}${API_PATH}?per_page=${pageSize}&page=${page}`,result=await listPage(url,fetcher);if(result.items.length===0)return;const ids=result.items.map((item)=>String(item?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${url} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const take=limit?result.items.slice(0,Math.max(0,limit-emitted)):result.items,warnings=[],parents=take.filter((item)=>item?.type==="variable"&&(item.variations?.length??0)>0).map((item)=>String(item.id));let variations=new Map;try{variations=await fetchVariations(base,parents,fetcher)}catch(error){if(!(error instanceof CatalogFetchError))throw error;warnings.push({message:`variations could not be fetched (${error.message}); variable products are imported without variant prices`})}const products=[];for(const entry of take)try{const mapped=mapWooProduct(entry,{variations});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}yield{url,products,warnings};if(limit&&emitted>=limit)return;if(result.totalPages!==null&&page>=result.totalPages)return}throw new CatalogFetchError(base,`Stopped after ${MAX_PAGES} pages of ${base}${API_PATH} without reaching the end.`)}export const wooCommerceAdapter={name:"woocommerce",pages:wooPages};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{
|
|
1
|
+
import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{slugify}from"@stacksjs/slug";import{fetchById}from"./fetch";export async function store(data){try{const parentCategoryId=data.parent_category_id?Number(data.parent_category_id):void 0;if(parentCategoryId!==void 0&&(!Number.isSafeInteger(parentCategoryId)||parentCategoryId<=0))throw new HttpError(422,"Parent category ID must be a positive integer");if(parentCategoryId&&!await fetchById(parentCategoryId))throw new HttpError(422,`Parent category with ID ${data.parent_category_id} not found`);const categoryData={...data,uuid:randomUUIDv7(),is_active:data.is_active??!0,parent_category_id:parentCategoryId??null},result=await db.insertInto("categories").values(categoryData).returningAll().executeTakeFirst();if(!result)throw Error("Failed to create category");return result}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A category with this name or slug already exists");if(error instanceof Error)throw Error(`Failed to create category: ${error.message}`);throw error}}export async function findOrCreateByName(data){if(!data.name)throw Error("Name is required");const existingCategory=await db.selectFrom("categories").selectAll().where("name","=",data.name).executeTakeFirst();if(existingCategory)return existingCategory;const categoryData={name:data.name,slug:slugify(data.name),is_active:data.is_active??!0,display_order:0};return await store(categoryData)}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/commerce",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.75.
|
|
4
|
+
"version": "0.75.432",
|
|
5
5
|
"description": "Stacks commerce utilities.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -57,19 +57,19 @@
|
|
|
57
57
|
"prepublishOnly": "bun run build"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@stacksjs/cache": "0.75.
|
|
61
|
-
"@stacksjs/config": "0.75.
|
|
62
|
-
"@stacksjs/database": "0.75.
|
|
63
|
-
"@stacksjs/env": "0.75.
|
|
64
|
-
"@stacksjs/error-handling": "0.75.
|
|
65
|
-
"@stacksjs/events": "0.75.
|
|
66
|
-
"@stacksjs/logging": "0.75.
|
|
67
|
-
"@stacksjs/orm": "0.75.
|
|
68
|
-
"@stacksjs/payments": "0.75.
|
|
69
|
-
"@stacksjs/realtime": "0.75.
|
|
70
|
-
"@stacksjs/slug": "0.75.
|
|
71
|
-
"@stacksjs/strings": "0.75.
|
|
72
|
-
"@stacksjs/validation": "0.75.
|
|
60
|
+
"@stacksjs/cache": "0.75.432",
|
|
61
|
+
"@stacksjs/config": "0.75.432",
|
|
62
|
+
"@stacksjs/database": "0.75.432",
|
|
63
|
+
"@stacksjs/env": "0.75.432",
|
|
64
|
+
"@stacksjs/error-handling": "0.75.432",
|
|
65
|
+
"@stacksjs/events": "0.75.432",
|
|
66
|
+
"@stacksjs/logging": "0.75.432",
|
|
67
|
+
"@stacksjs/orm": "0.75.432",
|
|
68
|
+
"@stacksjs/payments": "0.75.432",
|
|
69
|
+
"@stacksjs/realtime": "0.75.432",
|
|
70
|
+
"@stacksjs/slug": "0.75.432",
|
|
71
|
+
"@stacksjs/strings": "0.75.432",
|
|
72
|
+
"@stacksjs/validation": "0.75.432",
|
|
73
73
|
"ts-spreadsheets": "^0.2.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|