Skip to content

api tags

github-actions[bot] edited this page Aug 25, 2026 · 5 revisions

Tags API Documentation

Audience: Developers integrating with Blacksmith and using the Tags system from another module.

Feature summary:

  • Central storage: All tag assignments (record → tags) are stored in a Blacksmith world setting. Consuming modules do not store tags in their own record data.
  • Context keys: Every tag operation is scoped to a {moduleId}.{dataType} context key (e.g. "coffee-pub-librarian.quest"). This allows the same tag name to exist independently in different data types.
  • Taxonomy: resources/tag-taxonomy.json defines the suggested tag set for each context. Modules contribute by adding their context entries to this file. Protected tags (those that drive module code) are marked protected: true and cannot be renamed or deleted.
  • Global tags: Tags like "todo" and "revisit" are offered as suggestions in every context.
  • Visibility: Per-tag-globally by default, with an optional per-context override. Visibility is client-scope and affects filtering only — it does not remove tags from data.
  • TagWidget: A reusable Blacksmith UI component for tag selection, search, add, and remove. Embed in any Application V2 window; requires the Blacksmith Window API.
  • GM-only mutations: Rename and delete operate across all records in all contexts and require GM privileges.

Overview

The Tags API provides a shared labeling infrastructure for all coffee-pub modules. Use it to attach classification labels to your records, offer a consistent UI for choosing and filtering tags, and let the GM manage the world's tag vocabulary in one place.

A tag is a normalized string: lowercase, hyphen-separated, no spaces (e.g. "main-quest", "tavern", "todo"). A context key scopes a tag taxonomy and its record assignments to one module + data type (e.g. "coffee-pub-librarian.quest").

When to use the Tags API

  • You have records (quests, codex entries, notes, items) that users want to classify and filter.
  • You want a consistent tag UI without building tag-input, chip rendering, or search from scratch.
  • You want the GM to be able to rename or delete a tag world-wide and have it propagate to all records automatically.

When not to use it

  • For boolean or typed metadata on a record (use your own data model). Tags are display labels, not typed configuration fields.
  • For system-level classification that drives rendering or behavior and never changes (use your type field or constants instead — and if you do define such values in the taxonomy, mark them protected: true).

Implementation structure

File Role
scripts/manager-tags.js TagManager — core logic, storage, normalization
scripts/api-tags.js TagsAPI — public wrapper (this document)
scripts/widget-tags.js TagWidget — embeddable UI component
templates/partials/tag-widget.hbs TagWidget Handlebars template
styles/widget-tags.css TagWidget styles
resources/tag-taxonomy.json Canonical taxonomy for all coffee-pub contexts

Getting started

Accessing the API

const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
if (!tags?.isAvailable()) return;

Availability check

Call isAvailable() before any API use. The Tags API is ready as soon as Blacksmith initializes — it does not require the canvas.

Add your context to the taxonomy

Add your context key entry to resources/tag-taxonomy.json before shipping:

{
  "version": 1,
  "globalTags": ["todo", "revisit", "avoid", "complete"],
  "contexts": {
    "coffee-pub-librarian.quest": {
      "label": "Quests",
      "tags": [
        { "key": "main", "protected": true },
        { "key": "side", "protected": true },
        { "key": "backstory" },
        { "key": "faction" }
      ]
    }
  }
}

Mark protected: true on any tag your code checks by value (e.g. if (tags.includes('main'))). Protected tags cannot be renamed or deleted by a GM.

Attach tags to a record

Hooks.on('blacksmithReady', async () => {
  const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
  if (!tags?.isAvailable()) return;

  // Set tags on a record (replaces any existing tags for this record)
  await tags.setTags('coffee-pub-librarian.quest', quest.id, ['main', 'faction']);
});

Clean up on record delete

await tags.deleteRecordTags('coffee-pub-librarian.quest', quest.id);

Embed the tag widget in a window

See TagWidget below for the full embed pattern.


API Reference

Availability

tags.isAvailable()

Returns true if the Tags API is loaded and ready. Safe to call at any time, including before ready.

Returns: boolean

const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
if (!tags?.isAvailable()) {
  console.warn('Blacksmith Tags API not available');
  return;
}

Taxonomy

tags.register(contextKey, taxonomy)

Merge a taxonomy entry into the in-memory registry at runtime. Useful for dev-time testing or dynamic contexts not known at ship time. For shipped modules, prefer adding entries directly to tag-taxonomy.json.

Runtime entries merge on top of the JSON entries — runtime wins on key collision.

Parameters:

Name Type Description
contextKey string Context identifier in {moduleId}.{dataType} format
taxonomy object { label: string, tags: Array<string | { key: string, protected?: boolean }> }

Use tags as the array key. flags is also accepted — the shipped tag-taxonomy.json uses it, and both readers take either key — but tags is the canonical shape. Entries may be plain strings or { key, protected } objects.

Returns: void

Hooks.on('blacksmithReady', () => {
  const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
  tags?.register('coffee-pub-librarian.quest', {
    label: 'Quests',
    tags: [
      { key: 'main', protected: true },
      { key: 'side', protected: true },
      { key: 'backstory' },
      { key: 'faction' }
    ]
  });
});

tags.getChoices(contextKey)

Get the full list of suggested tags for a context: the context's taxonomy tags plus global tags. Use this to populate a dropdown, chip input, or TagWidget's suggestion list.

Tags are returned in taxonomy order, with global tags appended. Protected tags are included; the caller can use the protected field to style or sort them differently.

Parameters:

Name Type Description
contextKey string Context identifier

Returns: Array<{ key: string, label: string, protected: boolean, tier: 'taxonomy' \| 'global' }>

const choices = tags.getChoices('coffee-pub-librarian.quest');
// [
//   { key: 'main',      label: 'Main',      protected: true,  tier: 'taxonomy' },
//   { key: 'side',      label: 'Side',      protected: true,  tier: 'taxonomy' },
//   { key: 'backstory', label: 'Backstory', protected: false, tier: 'taxonomy' },
//   { key: 'todo',      label: 'Todo',      protected: false, tier: 'global'   },
// ]

Record tag CRUD

All CRUD methods write to the central tagAssignments world setting. Non-GM users go through the GM proxy automatically — you do not need to handle that routing.

tags.setTags(contextKey, recordId, tagArray)

Replace the tag set for a record. Normalizes the input array before storing. Adds any new tags to the world registry.

Parameters:

Name Type Description
contextKey string Context identifier
recordId string Unique identifier of the record within this context
tagArray string[] The complete new tag set (replaces existing)

Returns: Promise<void>

// On quest save
await tags.setTags('coffee-pub-librarian.quest', quest.id, ['main', 'faction', 'todo']);

tags.getTags(contextKey, recordId)

Get the current tags for a record. Returns an empty array if the record has no tags.

Parameters:

Name Type Description
contextKey string Context identifier
recordId string Record identifier

Returns: string[]

const questFlags = tags.getTags('coffee-pub-librarian.quest', quest.id);
// ['main', 'faction', 'todo']

tags.addTags(contextKey, recordId, tagArray)

Add tags to a record without replacing its existing tags. Duplicates are deduplicated automatically. Adds any new tags to the world registry.

Parameters:

Name Type Description
contextKey string Context identifier
recordId string Record identifier
tagArray string[] Tags to add

Returns: Promise<void>

// Mark a quest complete without touching its other tags
await tags.addTags('coffee-pub-librarian.quest', quest.id, ['complete']);

tags.removeTags(contextKey, recordId, tagArray)

Remove specific tags from a record. Tags not present on the record are silently ignored.

Parameters:

Name Type Description
contextKey string Context identifier
recordId string Record identifier
tagArray string[] Tags to remove

Returns: Promise<void>

// Un-complete a quest
await tags.removeTags('coffee-pub-librarian.quest', quest.id, ['complete']);

tags.deleteRecordTags(contextKey, recordId)

Remove all tag data for a record. Call this when the record itself is deleted to avoid orphan entries in the assignment store.

Parameters:

Name Type Description
contextKey string Context identifier
recordId string Record identifier

Returns: Promise<void>

// In your module's record delete handler
await tags.deleteRecordTags('coffee-pub-librarian.quest', deletedQuest.id);

tags.getTagCounts(contextKey)

How many records in one context carry each tag. One pass over that context.

Parameter Type Description
contextKey string The context to count within

Returns Record<string, number> -- tag to record count. An unknown context returns {}.

Only tags in use appear. A taxonomy tag nobody has applied is absent, not present with 0: this reports assignments, while getChoices reports suggestions. Treating an unused suggestion as a stale entry is the mistake this distinction exists to prevent.

Use it to scope a vocabulary to your own records -- getRegistry() is world-wide and undifferentiated, so sizing a tag cloud from it means one getRecordsByTag call per registry entry.

Whether a registry entry is accounted for anywhere is a different question, needing every context key and the declared taxonomy across all of them. That is not on this surface: no consumer can assemble it without hardcoding keys it does not own. Blacksmith answers it in utilities/audit-tag-registry.js.

tags.getRecordsByTag(contextKey, tag)

Get all record IDs in a context that currently have a specific tag. Useful for building filter views.

Parameters:

Name Type Description
contextKey string Context identifier
tag string Tag to look up

Returns: string[] — array of record IDs

// Find all quests tagged 'faction'
const factionQuestIds = tags.getRecordsByTag('coffee-pub-librarian.quest', 'faction');

Registry management

These methods operate on the world-level tag registry. Rename and delete require GM privileges and propagate across all contexts and records automatically.

tags.getRegistry()

Get the full world tag list — every tag ever used in any context, including custom and orphan tags.

Returns: string[] — sorted array of normalized tag strings

const allFlags = tags.getRegistry();
// ['avoid', 'backstory', 'complete', 'faction', 'main', 'revisit', 'tavern', 'todo', ...]

tags.normalize(input)

Normalize an array of tag strings: lowercase, replace spaces with hyphens, deduplicate. Use this before storing tags you receive from user input.

Parameters:

Name Type Description
tagArray string[] Raw input tags

Returns: string[] — normalized, deduplicated

const clean = tags.normalize(['Main Quest', 'FACTION', 'main quest']);
// ['main-quest', 'faction']

tags.rename(oldTag, newTag)

Rename a tag globally. Updates every record in every context that uses oldTag, then updates the registry. Silently rejected if oldTag is protected.

GM only. Non-GM callers receive a warning and no change is made.

Parameters:

Name Type Description
oldTag string Existing tag key
newTag string Replacement tag key (will be normalized)

Returns: Promise<{ updated: number } \| null> — number of records updated, or null if rejected

// Rename 'backstory' → 'lore' across all contexts and records
const result = await tags.rename('backstory', 'lore');
// { updated: 14 }

tags.delete(tag)

Delete a tag globally. Removes it from every record in every context, then removes it from the registry. Silently rejected if the tag is protected.

GM only. Non-GM callers receive a warning and no change is made.

Parameters:

Name Type Description
tag string Tag key to delete

Returns: Promise<{ removed: number } \| null> — number of records affected, or null if rejected

const result = await tags.delete('old-unused-tag');
// { removed: 3 }

tags.seedRegistry(contextKey, existingTagArrays)

Populate the world registry from a flat list of tag arrays. Use this once during your module's first-run initialization to ensure the registry reflects all tags already present in your existing data — before any user interaction has created them through normal use.

If the registry already contains a tag it is not duplicated.

Parameters:

Name Type Description
contextKey string Context identifier (used for logging only)
existingTagArrays string[][] Array of tag arrays, one per record

Returns: Promise<void>

// On first load: seed registry from all existing quests
const allQuestFlags = myQuests.map(q => tags.getTags('coffee-pub-librarian.quest', q.id));
await tags.seedRegistry('coffee-pub-librarian.quest', allQuestFlags);

Visibility

Visibility is client-scope (per-user) and affects UI filtering only. It does not remove tags from stored data or prevent other users from seeing them.

Resolution order: Context override → global default → true (visible if no entry exists).

tags.setVisibility(tag, visible, contextKey?)

Set the visibility of a tag. If contextKey is provided, this sets a context-specific override. If omitted, it sets the global default for that tag across all contexts.

Parameters:

Name Type Description
tag string Tag key
visible boolean Whether the tag should be visible in filter UIs
contextKey string? Optional — scope override to one context

Returns: void

// Hide 'todo' globally (across all contexts for this user)
tags.setVisibility('todo', false);

// Hide 'backstory' only in the quests context
tags.setVisibility('backstory', false, 'coffee-pub-librarian.quest');

// Restore 'backstory' visibility in quests (removes the context override)
tags.setVisibility('backstory', true, 'coffee-pub-librarian.quest');

tags.getVisibility(tag, contextKey?)

Get the effective visibility of a tag for the current user. Applies context override → global default → true fallback.

Parameters:

Name Type Description
tag string Tag key
contextKey string? Optional — check effective visibility for a specific context

Returns: boolean

// Is 'todo' visible in the quests context?
const visible = tags.getVisibility('todo', 'coffee-pub-librarian.quest');

TagWidget

TagWidget is a Blacksmith UI component for selecting and managing tags. Embed it in any Application V2 window instead of building your own tag input.

Prerequisite: Your window must use the Blacksmith Window API (documentation/api/api-window.md). TagWidget follows the same lifecycle and CSS conventions.

Capabilities

Mode Supports
Full (default) Display existing tags as removable chips, input with live search against suggestions, add custom tags, remove tags
Filter Not implemented. prepareData({mode:'filter'}) and the template render the toggles, but nothing listens to them — there is no change handler, so toggling never calls setVisibility. Don't use it.

Embed pattern

Four steps, and step 3 is not optional — without activate() the widget renders and does nothing.

1. In prepareContext() — build widget data:

async prepareContext(options) {
  const context = await super.prepareContext(options);
  const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
  const currentTags = tags.getTags('coffee-pub-librarian.quest', this.questId);

  context.TagWidget = TagWidget.prepareData({
    contextKey: 'coffee-pub-librarian.quest',
    currentTags,
    mode: 'full'
  });

  return context;
}

2. In your Handlebars template — include the partial:

<div class="quest-tags-section">
  {{> blacksmith-tag-widget TagWidget}}
</div>

Pass the context positionally, not as a hash. {{> blacksmith-tag-widget tags=TagWidget}} does not work: a hash argument adds tags to your context rather than replacing it, and the partial reads contextKey / isFullMode / chips off the root context. You get a silent empty div.

3. After render — wire up interactivity:

_onRender(context, options) {
  super._onRender(context, options);
  TagWidget.activate(this.element, 'coffee-pub-librarian.quest');
}

Without this the widget is inert. activate() is the entire event layer — suggestion clicks, Enter-to-add, chip removal, live search. Rendering the partial alone gives you a display-only div.

4. On save — read the widget value and persist:

async _onSubmit(event, form, formData) {
  const tags = game.modules.get('coffee-pub-blacksmith')?.api?.tags;
  const newTags = TagWidget.readValue(this.element, 'coffee-pub-librarian.quest');
  await tags.setTags('coffee-pub-librarian.quest', this.questId, newTags);
  // ... rest of save
}

Accessing TagWidget

const { TagWidget } = game.modules.get('coffee-pub-blacksmith')?.api?.tags ?? {};

TagWidget.prepareData(options)

Prepare the template data object for the widget partial.

Parameters:

Name Type Description
options.contextKey string Context identifier
options.currentTags string[] Tags currently on the record
options.mode 'full' | 'filter' Widget mode (default: 'full'). 'filter' is not implemented — see Capabilities.
options.placeholder string? Input placeholder text (full mode only)

Returns: object — template data. Pass it positionally: {{> blacksmith-tag-widget TagWidget}}.


TagWidget.activate(element, contextKey, onChange?)

Required. Wires the widget's event handlers — suggestion clicks, Enter-to-add, chip removal, live search. Call it after every render (e.g. in _onRender). Without it the widget renders but does nothing.

Parameters:

Name Type Description
element HTMLElement The window's root element (e.g. this.element)
contextKey string Context identifier (must match prepareData)
onChange Function? Optional callback fired when the selection changes

TagWidget.readValue(element, contextKey)

Read the current tag selection from a rendered widget inside element. Call this in your _onSubmit or change handler.

Parameters:

Name Type Description
element HTMLElement The window's root element (e.g. this.element)
contextKey string Context identifier (must match the one used in prepareData)

Returns: string[] — normalized array of selected tags


Hooks

The Tags system fires the following Foundry hooks:

Hook When Payload
blacksmith.tags.registered A taxonomy is registered (JSON load or runtime) { contextKey, taxonomy }
blacksmith.tags.renamed A tag is renamed globally (GM only) { oldTag, newTag, updated: number }
blacksmith.tags.deleted A tag is deleted globally (GM only) { tag, removed: number }
blacksmith.tags.changed A record's tags actually changed, deleteRecordTags included -- it reports tags: []. A call that changes nothing is silent: addTags with a tag the record already carries, removeTags with one it does not, deleteRecordTags on an absent record. Fires on the calling client only -- no other client is notified. { contextKey, recordId, tags: string[] }

Integration checklist

When adding Tags support to a new module or data type:

  • Add your context key to resources/tag-taxonomy.json. Mark protected: true on any tags your code checks by value.
  • Call tags.setTags(contextKey, recordId, tagArray) when saving a record.
  • Call tags.deleteRecordTags(contextKey, recordId) when deleting a record.
  • Call tags.seedRegistry(contextKey, existingArrays) once on first load to populate the registry from pre-existing data.
  • Embed TagWidget in your record's edit window.
  • Use tags.getRecordsByTag(contextKey, tag) to power any filter UI.
  • Check tags.isAvailable() before all API calls from outside Blacksmith.

Troubleshooting

Tags are not appearing in suggestions

  • Verify your context key is in tag-taxonomy.json (or registered at runtime before the UI opens).
  • Check that tags.getChoices(contextKey) returns your expected tags in the console.

Rename / delete silently does nothing

  • Confirm the calling user is GM.
  • Check if the tag is marked protected: true in the taxonomy — protected tags cannot be renamed or deleted.

TagWidget is not rendering

  • Confirm your window extends the Blacksmith Window API (see documentation/api/api-window.md).
  • Confirm you are passing the TagWidget value from prepareData() to the template context, not constructing the object manually.

Record tags are empty after reload

  • Verify you are calling setTags (not just updating your own record data).
  • Check the tagAssignments world setting in the browser console: game.settings.get('coffee-pub-blacksmith', 'tagAssignments').

Clone this wiki locally