npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@scaleflex/bulk-edit

v0.8.1

Published

Standalone bulk metadata editor for Scaleflex DAM — pick fields, apply SET/ADD/DELETE across many assets, preview diffs. Built on @scaleflex/dam-metadata; embeddable without the uploader.

Readme

@scaleflex/bulk-edit

The standalone bulk metadata editor for the Scaleflex DAM — pick a field, apply a SET / ADD / DELETE operation across many assets at once, preview the per-asset diff, and apply. It ships as a single custom element, <sfx-bulk-metadata-modal>, built on @scaleflex/dam-metadata and embeddable without the uploader.

The editor is stateless and event-driven: you hand it the assets to edit plus a metadata schema, and it emits *-save-batch CustomEvents describing the changes. It never calls an API itself — the host persists the emitted batches (e.g. via @scaleflex/dam-core's services). That makes it trivial to run fully offline against mock data (see Demo).

Install

npm i @scaleflex/bulk-edit @scaleflex/dam-metadata

Quick start

import '@scaleflex/bulk-edit';
// ^ side effect: registers <sfx-bulk-metadata-modal> AND the dam-metadata field components.

import type { BulkEditTarget } from '@scaleflex/bulk-edit';
import type { MetadataSchema } from '@scaleflex/dam-metadata';

// 1. The assets to edit. Read fields: id / name / type / meta / product? / taxonodes?,
//    plus previewUrl + size for the per-row thumbnail (see "Preview thumbnails" below).
const files: BulkEditTarget[] = [
  { id: 'a1', name: 'beach.jpg', type: 'image/jpeg', meta: { title: 'Beach', keywords: ['sea'] } },
  { id: 'a2', name: 'forest.jpg', type: 'image/jpeg', meta: {} },
  // ...
] as BulkEditTarget[]; // see the BulkEditTarget note below re: the structural superset

// 2. The metadata schema (parsed shape from @scaleflex/dam-metadata).
const schema: MetadataSchema = /* parseMetadataSchema(...) or build by hand */;

// 3. Mount. There is NO `open` flag — the modal is a full-screen overlay that renders
//    as soon as it's in the DOM with a valid .schema + .files. Set the props, then append.
const modal = document.createElement('sfx-bulk-metadata-modal');
modal.schema = schema;
modal.files = files;

// 4. Listen for the change batches and persist them yourself.
modal.addEventListener('metadata-save-batch', (e) => persistMeta(e.detail.changes));
modal.addEventListener('product-save-batch', (e) => persistProduct(e.detail.changes));
modal.addEventListener('taxonomy-save-batch', (e) => persistTaxonomy(e.detail.changes));

// 5. Tear down on close (Save / Cancel / Back / ✕ / Escape all emit this).
modal.addEventListener('metadata-close', () => modal.remove());

document.body.appendChild(modal);

How it opens / closes

<sfx-bulk-metadata-modal> has no open / visible property. It is a full-screen overlay that displays whenever it is attached to the DOM and schema.fields is non-empty. To "close" it, listen for metadata-close and remove the element (or hide it) yourself. There is no internal visibility state to toggle.

Properties

Set these as properties. The object/service props are attribute: false and have no attribute at all; the three string enums at the bottom of the table (operationMode, primaryAction, exitAction) do reflect a lowercased attribute, but setting them as properties works for all of them and is the only option for the rest:

| Property | Type | Required | Notes | | ----------------- | ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------- | | schema | MetadataSchema | ✅ | Parsed schema: { groups, fields, fieldsByKey, regionalVariantsGroups, language, productsEnabled }. | | files | BulkEditTarget[] | ✅ | Assets to edit (defaults to []). | | config | MetadataConfig \| null | — | Drives required-field enforcement + regional filters. | | dependencies | Dependency[] | — | Pre-upload dependency rules (require / show / hide). Empty disables. | | defaultLanguage | string | — | Default regional-variant key. | | initialFieldKey | string \| null | — | Open with this field active instead of the first navigable one. | | aiFilledFields | AiFilledFields | — | { [fieldKey]: tooltip \| true } — fields an AI post-process fills get a sparkles icon in the sidebar. See below. | | autocomplete | unknown | — | Autocomplete service, threaded to the field components. | | taxonomyService | unknown | — | Taxonomy picker service (for taxonomy-node fields). | | ultratags | unknown | — | Ultratags vocabulary service (for ultratags fields). | | pickAssets | PickAssetsFn | — | Opens your asset picker for attachments-assets fields. See Attachments below. | | operationMode | 'full' \| 'set-only' | — | 'set-only' hides the Operation picker and pins Apply to SET. Defaults to 'full'. See below. | | primaryAction | 'save' \| 'upload' | — | Label of the terminal button once nothing required is missing. Defaults to 'save'. | | exitAction | 'cancel' \| 'back' | — | Whether leaving without the terminal button discards or keeps the staged changes. Defaults to 'cancel'. See below. |

exitAction — discard on the way out, or hand it back?

'cancel' (default) suits editing assets that already exist: the modal is a transaction over live data, so the footer offers Cancel and it — along with the top-bar ✕, Escape and an overlay click — discards everything staged behind a confirmation.

'back' suits a host that is staging assets that don't exist yet (the uploader's pre-upload flow). There the values are filled field by field and the host keeps them on its own staging files, so there is no transaction to cancel. The footer offers ← Back to files instead: it emits the usual *-save-batch events and closes with saved: false, letting the user go add more files or change the batch and come back to a modal that still has everything they filled. ✕, Escape and an overlay click do the same, and nothing prompts to discard — no exit can silently drop work.

A live, un-applied op-bar value is still confirmed (offering Apply), since committing would otherwise drop it.

Keep it separate from primaryAction: a host can be mid-pre-upload while the terminal button still reads "Save", because the user opened the editor voluntarily with nothing required outstanding.

operationMode — when to drop the Operation picker

By default the op-bar offers Set / Add to / Remove from (arrays), Set / Append / Remove (text) or Set / Clear (scalars). That only earns its place when the assets already carry values.

For hosts filling metadata on assets that don't exist yet — the uploader's pre-upload flow — every current value is empty, so ADD collapses to SET and DELETE is a no-op: three choices, one outcome. Pass operationMode="set-only" and the picker disappears, the value input takes the full width, and the button reads "Apply to selected".

Trade-off: a value already staged on individual rows can no longer be bulk-cleared or appended to in set-only — the per-row editor is the way to change it.

aiFilledFields — mark the fields an AI post-process fills

The Hub marks metadata fields that an AI post-process auto-fills (AI enrichment via the field's ai_prompt, alt text generation, Describe with AI) with a gradient sparkles icon in Settings, asset details and the filters. aiFilledFields puts the same mark in the bulk editor's sidebar, right after the field name, with a tooltip naming the post-process:

modal.aiFilledFields = {
  description: 'Filled by AI · Describe with AI post-process',
  keywords: 'Filled by AI · AI enrichment post-process',
  alt_text: true, // built-in tooltip: "Filled by AI"
};

Keys are MetadataField.key. A string value is used as the tooltip, true falls back to the built-in "Filled by AI", and false / a missing key shows nothing. It is display only — the field is still edited like any other; flagging it just tells the user that an automation also writes to it.

The editor cannot derive the flag itself: whether a field is AI-filled depends on project-level settings the host owns (the field's prompt and whether the matching post-process is enabled), so compute the rule host-side and pass the result. The map is a plain property — assign a new object whenever the rule's inputs change (post-processes toggled, language switched) and the sidebar re-renders.

Event contract

All events bubble and are composed: true, so you can also listen on a parent container. Detail shapes below are read straight from bulk-metadata-modal.ts.

metadata-save-batch

Emitted on Save. Carries only generic metadata fields that changed, per file (unchanged fields and files are omitted; the meta object is a partial patch, not the full metadata).

e.detail = {
  changes: Array<{
    fileId: string;
    meta: Record<string, unknown>; // only the changed keys, in backend value format
  }>;
};

product-save-batch

Emitted on Save only when schema.productsEnabled === true and a product field (ref / position) changed. Clearing a value is represented as undefined ("drop the field"), not as a sent empty value.

e.detail = {
  changes: Array<{
    fileId: string;
    product: { ref?: string; position?: number }; // Partial<Product>, changed keys only
  }>;
};

taxonomy-save-batch

Emitted on Save only when a taxonomy-node field's picked entry changed. null means the entry was cleared.

e.detail = {
  changes: Array<{
    fileId: string;
    taxonodes: Record<string, TaxonodeEntry | null>; // changed field keys only
  }>;
};

metadata-close

e.detail = { saved: boolean };

Emitted when the modal should close; the host removes/hides the element. saved is true only for the terminal Save — every other exit reports false, so a host can tell "the user committed and is done" from "the user stepped out".

| Exit | exitAction: 'cancel' (default) | exitAction: 'back' | | ---------------------------------- | ----------------------------------------- | ----------------------------------------------------------- | | Footer primary (Save/Upload) | { saved: true }, after the batches | { saved: true }, after the batches | | Footer secondary | Cancel → discards, { saved: false } | ← Back to files → emits the batches, { saved: false } | | Top-bar ✕ / Escape / overlay click | discards, { saved: false } | same as Back — emits the batches, { saved: false } |

Under 'cancel' every discarding exit asks for confirmation first; under 'back' nothing is discarded, so nothing prompts (bar a live, un-applied op-bar value — see exitAction above).

A regional-settings change inside the modal is handled by the nested <sfx-regional-settings> element and does not surface as a modal-level event.

BulkEditTarget shape

type BulkEditTarget = {
  id: string;
  name: string;
  type: string; // MIME, e.g. 'image/jpeg'
  meta: Record<string, unknown>; // current metadata (backend value format)
  product?: { ref?: string; position?: number }; // when productsEnabled
  taxonodes?: Record<string, TaxonodeEntry | null>;
  previewUrl?: string | null; // per-row thumbnail — see "Preview thumbnails"
  size?: number; // bytes, for the row's size cell (omit/0 → "—")
};

Note (tracked refinement, plan/06): BulkEditTarget currently aliases dam-metadata's UploadFile — a structural superset that still carries the upload-status field the required-field gate reads. So the uploader's UploadFile satisfies it directly, and a standalone consumer constructs an object of the same shape (most extra fields are inert defaults — see the demo's makeTarget factory). Narrowing it to the UI-neutral { id, name, type, meta, product?, taxonodes? } above (by hoisting the status filter out of the engine) is the remaining decoupling step.

Preview thumbnails

Each row shows a thumbnail. The editor renders BulkEditTarget.previewUrl as-is (it's just an <img src>) and falls back to a file-type icon when previewUrl is null/absent — it never builds or fetches a preview itself. That keeps the editor stateless and lets it serve two host scenarios from the same prop:

| Scenario | What previewUrl should be | | ------------------------------- | ------------------------------------------------------------------------------ | | New uploads (blob) | a local object URL — URL.createObjectURL(file) (this is the uploader's path) | | Already-existing DAM assets | a sized, transform-capable CDN thumbnail — built exactly like asset-picker |

The uploader already does the blob case for you: it sets previewUrl on each UploadFile (object URL for local files, transformed remote thumbnail for connector/URL imports), and passes those files straight through. No change is needed to bulk-edit, and no new prop — you control the preview entirely by what you put in previewUrl.

Existing assets — match asset-picker exactly

To get the same thumbnails asset-picker shows, build the URL with the shared helper getThumbnailUrl() from @scaleflex/dam-core (it's the asset-picker logic, ported verbatim: rewrite {token}.filerobot.com → the transform-capable assets.filerobot.com/{token}/ host, size via CDN params, render PDFs' first page, use the video poster, and fall back to "" for non-thumbnailable types so the icon shows):

import '@scaleflex/bulk-edit';
import type { BulkEditTarget } from '@scaleflex/bulk-edit';
import { getThumbnailUrl } from '@scaleflex/dam-core';
import type { Asset } from '@scaleflex/dam-core';

// `assets` are your already-existing DAM assets (e.g. the asset-picker selection).
const files: BulkEditTarget[] = assets.map(
  (a: Asset): BulkEditTarget =>
    ({
      id: a.uuid,
      name: a.name,
      type: a.fileType ?? '',
      meta: currentMetaFor(a.uuid), // the asset's current backend metadata (your source)
      size: a.size,
      // Same thumbnail asset-picker renders; '' → editor shows the file-type icon.
      previewUrl: getThumbnailUrl(a, 96) || null,
      // No `status` needed — existing assets are treated as editable by default,
      // so the required-field gate applies whenever you pass an enforcing `config`
      // (see the note below). `status` is an upload-only field; you can omit it.
    }) as BulkEditTarget,
);

const modal = document.createElement('sfx-bulk-metadata-modal');
modal.schema = schema;
modal.files = files;
// …wire the *-save-batch events and persist them (see Quick start).

Required-field enforcement & status. config is the single switch: pass a config that enables required metadata (shouldEnforceRequiredMetadata → true) and the modal's "Next required" gate is active; don't, and there's no gate. The gate skips files in a non-modifiable upload status (anything other than idle / queued / rejected) — that's how the uploader excludes files already mid-upload. Existing assets carry no status, and the modal treats a missing status as modifiable, so the gate applies to them automatically — you do not need to set status: 'idle' (it's an upload-only field; omit it).

  • Want the required-field gate on existing assets? Just pass an enforcing config. ✅ (default)
  • Want pure free-form bulk editing (no gating)? Don't pass an enforcing config.

Saving itself is never status-gated — *-save-batch is emitted for every edited file regardless of status. Only the required-field block depends on the gate.

getThumbnailUrl expects a normalized Asset, not a raw Filerobot API object. This is the #1 cause of "no previews": it reads asset.thumbnailUrl, asset.cdnUrl, and a classified asset.type ('image' / 'video' / 'document' / …) — not the raw info.image_thumbnail / url.cdn / MIME-string fields. Fed a raw object, every field it reads is undefined and it returns '' → the editor shows the file-type icon for everything. Get normalized Assets from dam-core's getFiles(client, …) / getFilesByUuids(client, uuids) (they map the raw fields with the same source priority js-admin uses), or map the raw fields yourself first:

previewUrl: getThumbnailUrl(
  {
    uuid: raw.uuid,
    name: raw.name,
    folder: raw.folder ?? '/',
    type: classify(raw), // 'image' | 'video' | 'document' | … — NOT the MIME string
    fileType: raw.file_type ?? raw.type,
    cdnUrl: raw.cdn_permalink ?? raw.cdn_url,
    thumbnailUrl:
      raw.info?.video_thumbnail ??
      raw.info?.image_thumbnail ??
      raw.info?.thumbnail ??
      raw.info?.preview,
    size: typeof raw.size === 'object' ? raw.size?.bytes : raw.size,
  },
  100,
) || null;

bulk-edit itself does not depend on @scaleflex/dam-core (it stays offline/stateless) — the getThumbnailUrl call lives in your host code, alongside wherever you already fetch the assets. If you don't want the dam-core dependency, set previewUrl to any image URL you can build yourself; a plain CDN URL works, it just won't be auto-sized. A live, runnable version of this is the Existing assets example in the demo site.

Already have a thumbnail builder? Reuse it (don't pull in dam-core)

previewUrl accepts any image URL — getThumbnailUrl is a convenience, not a requirement. If your host app already builds asset thumbnails, reuse that function and skip the dam-core route entirely. For example, js-admin-react-filerobot-v5 has getItemIcon(), which the app's own list rows already use; an adapter that maps the host's file shape (HubFile) to BulkEditTarget[] should just call it:

import { getItemIcon } from '@/utils/get-item-icon';
import { LAYOUTS_IDS } from '@/.../layouts.constants';

export function adaptHubFilesToBulkEditTargets(
  hubFiles: HubFile[],
  opts: { container?: string | null; isDevEnv?: boolean } = {},
): BulkEditTarget[] {
  return hubFiles.map(
    (f) =>
      ({
        id: f.uuid || f.id,
        name: f.name ?? '',
        type: f.type ?? '',
        meta: f.meta ?? {},
        size: f.size?.bytes ?? 0,
        // ⛔ The #1 "no previews" bug is hardcoding `previewUrl: null` here.
        // ✅ Reuse the host's own thumbnail builder instead:
        previewUrl: getItemIcon({
          fileOrFolder: f,
          viewLayout: LAYOUTS_IDS.LIST, // small row-sized thumbnail
          container: opts.container, // REQUIRED for the assets.filerobot.com host rewrite
          isDevEnv: opts.isDevEnv,
        }),
        // No `status` — existing assets default to "editable", so the required-field gate
        // applies when you pass an enforcing `config`. (Setting a non-modifiable status like
        // `'complete'` would disable that gate — only do so for deliberately free-form editing.)
      }) as BulkEditTarget,
  );
}

Two things to know with this path:

  • Pass the container/token (container above). Without it the host rewrite to assets.filerobot.com doesn't fire and private-asset thumbnails won't load with transform params.
  • getItemIcon never returns null — for audio / 3D / generic types it returns a file-type-icon URL, so the editor renders that icon directly as the row thumbnail (instead of via its own fallback). Same result visually; just don't expect null.

Headless bulk-operations API

The package also exports the framework-agnostic operation logic from bulk-operations — the same SET/ADD/DELETE semantics the modal uses, usable without the UI:

| Export | Purpose | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | getAvailableOperations(type) | The operations a field type exposes. Arrays → Set / Add to / Remove from; text → Set / Append / Remove; scalars → Set / Clear; unsupported types → []. | | applyBulkOperation(op, current, value, type) | Core merge: SET overwrites; ADD merges+dedups arrays / concatenates text; DELETE removes array items, substring-removes text, or clears scalars. | | computeBulkResult(field, currentBackend, frontendValue, op, language?) | Full per-file result: frontend→backend conversion + regional-variant unwrap/rewrap, then applyBulkOperation. | | clampBackendValueToAllowed(field, value, allowed, language?, prevValue?) | Restrict a select-one / multi-select result to a file's allow_values set. Returns CLAMP_DROP to skip the file. | | isValueRequiredForPreview(op, type) | Whether a non-empty value is needed (scalar DELETE/"Clear" needs none). | | ARRAY_TYPES, TEXT_TYPES, CLAMP_DROP | Field-type sets + the skip sentinel. | | types BulkOperation, BulkOperationDef, PendingOp | 'SET' \| 'ADD' \| 'DELETE' and friends. |

import { getAvailableOperations, applyBulkOperation } from '@scaleflex/bulk-edit';

getAvailableOperations('tags'); // [{ key:'SET',… }, { key:'ADD',… }, { key:'DELETE',… }]
applyBulkOperation('ADD', ['a', 'b'], ['b', 'c'], 'tags'); // → ['a', 'b', 'c']  (dedup)
applyBulkOperation('SET', 'old', 'new', 'text'); // → 'new'

Demo

A runnable, fully offline demo site (mock schema + mock assets, no credentials) lives in demo/ — a multi-page docs + examples app (topbar, sidebar nav, live launchers, code blocks), mirroring the asset-picker / uploader demos:

pnpm --filter @scaleflex/bulk-edit dev          # serve the docs + examples site
pnpm --filter @scaleflex/bulk-edit build:demo   # static build → demo-dist/

It documents the props/events/types, walks through the SET/ADD/DELETE operations, and lets you launch the editor over mock data and pretty-prints every emitted batch so you can see the exact result. A minimal single-page playground also lives in dev/ for quick manual testing.

Standalone / CDN

For a one-<script>-tag embed, build the self-contained IIFE bundle (lit + @scaleflex/dam-metadata inlined):

pnpm --filter @scaleflex/bulk-edit build:cdn   # → dist-cdn/bulk-edit.min.js

Then load it directly — see examples/standalone.html for a complete, self-contained page that registers <sfx-bulk-metadata-modal> and runs the same mock-data flow:

<script src="https://your-cdn/bulk-edit.min.js"></script>
<script type="module">
  const modal = document.createElement('sfx-bulk-metadata-modal');
  modal.schema = schema;
  modal.files = files;
  modal.addEventListener('metadata-save-batch', (e) => persist(e.detail.changes));
  document.body.appendChild(modal);
</script>

Attachments (attachments-assets)

attachments-assets fields (a list of up to 5 references to other assets) can be bulk edited. The only operation is Overwrite: it removes every existing attachment from the selected assets before adding the chosen ones, so the Operation picker is shown muted. Apply asks for confirmation first.

The editor never opens an asset picker itself — the host does, through the pickAssets property:

modal.pickAssets = async ({ max, excludeIds, field }) => {
  // Open your picker (e.g. <sfx-asset-picker>) and resolve with the chosen assets, or null on cancel.
  const assets = await openPicker({
    multiSelect: true,
    maxSelections: max, // how many more attachments fit
    maxSelectionsBehavior: 'lock', // mute the rest once the cap is hit
    disabledAssetIds: excludeIds, // the edited assets and their existing attachments
    hideUpload: true, // only existing assets can be attached
  });
  return assets?.map((a) => ({ uuid: a.uuid, name: a.name, thumbnailUrl: a.url?.cdn }));
};
  • Without pickAssets the field is treated as unsupported (the lock notice, no editor) — attachments point at existing assets, so hosts that edit assets before they exist (the uploader's pre-upload flow) get no editor.
  • Values read from file.meta[fieldKey] may be uuid strings or objects with a uuid (and ideally name / thumbnailUrl for the chips); the saved metadata-save-batch changes[].meta[fieldKey] is always an array of uuids.
  • The per-asset preview shows what Apply will do: new attachments in green, removed ones in red.