Skip to content
// Normalize products (API body or Shopify) and pick the ones that need copy, up to the batch size.
const s = $('Settings (edit me)').first().json;
const viaApi = $('Generate API').isExecuted;
const body = viaApi ? $('Generate API').first().json.body ?? {} : {};
const fromShopify = $('Get Shopify Products').isExecuted;

const strip = (html) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
const words = (t) => (t ? t.split(/\s+/).length : 0);
const toList = (t) => (Array.isArray(t) ? t : String(t ?? '').split(',')).map((x) => String(x).trim()).filter(Boolean);

let raw = [], source_error = null;
if (fromShopify) {
  const items = $('Get Shopify Products').all().map((i) => i.json);
  if (items.some((p) => p.id)) raw = items.filter((p) => p.id).map((p) => ({
    id: String(p.id), title: p.title, product_type: p.product_type, vendor: p.vendor, tags: p.tags, body_html: p.body_html,
    // Shopify keeps specs in options/variants; take what's there (Exercise 2 adds metafields).
    attributes: Object.fromEntries((p.options ?? []).filter((o) => o.name !== 'Title').map((o) => [o.name.toLowerCase(), (o.values ?? []).join(', ')])),
    price: p.variants?.[0]?.price,
  }));
  else source_error = String(items[0]?.error?.message ?? items[0]?.error ?? 'Shopify returned no products (check the Shopify credential)').slice(0, 300);
} else raw = body.products ?? [];

const all = raw.map((p) => {
  const existing = strip(p.body_html);
  return {
    product_id: String(p.id ?? p.product_id ?? ''),
    title: String(p.title ?? '').trim(),
    product_type: p.product_type ?? '',
    vendor: p.vendor ?? '',
    tags: toList(p.tags),
    attributes: p.attributes ?? {},
    price: p.price ?? null,
    existing_text: existing,
    existing_words: words(existing),
    draft: p.draft ?? null, // copy written elsewhere: skip the AI, just run the checks
  };
}).filter((p) => p.product_id && p.title);

const force = body.force === true;
const drafts = $getWorkflowStaticData('global').drafts ?? {};
const inReview = (p) => ['pending', 'rejected'].includes(drafts[p.product_id]?.state); // don't overwrite a reviewer's queue
const needs = all.filter((p) => force || p.draft || (!inReview(p) && (p.existing_words < s.min_existing_words || /\b(todo|tbd|lorem ipsum)\b/i.test(p.existing_text))));
const batchSize = Number(body.batch_size ?? s.batch_size);
const batch = needs.slice(0, batchSize);
const meta = {
  _run: {
    products_seen: all.length, needing_copy: needs.length, in_batch: batch.length, remaining: needs.length - batch.length,
    skipped_in_review: all.filter((p) => !needs.includes(p) && inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, state: drafts[p.product_id].state })),
    skipped_has_copy: all.filter((p) => !needs.includes(p) && !inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, words: p.existing_words })),
    source: fromShopify ? 'shopify' : 'api', source_error, dry_run: body.dry_run === true, via_api: viaApi,
    use_ai: s.use_ai && body.ai !== false,
  },
};
if (!batch.length) return [{ json: { nothing_to_do: true, ...meta } }];
return batch.map((p, i) => ({ json: { ...p, use_ai: meta._run.use_ai && !p.draft, ...(i === 0 ? meta : {}) } }));