diff --git a/scripts/adapters/generic-adapter.mjs b/scripts/adapters/generic-adapter.mjs index 4d6df50..6fa7672 100644 --- a/scripts/adapters/generic-adapter.mjs +++ b/scripts/adapters/generic-adapter.mjs @@ -150,6 +150,9 @@ export function getNestedValue(obj, path) { * Reads field.foundry_collection off the actor, optionally filters by * foundry_item_type, projects each entry per foundry_item_fields (or a default * {id,name,type}), and returns a JSON string (type json/string) or a raw array. + * When field.foundry_item_single is set, collapses to the FIRST matching item's + * name (or first projected value) as a plain string — for "exactly one X item" + * fields like a hero's class/ancestry/kit. * Defensive — a malformed actor/collection yields an empty result, never throws. * * @param {object} actor @@ -176,6 +179,19 @@ export function extractCollectionField(actor, field) { ? field.foundry_item_fields : null; + // Single-item collapse: "exactly one X item" fields (class/ancestry/kit) + // want the item's NAME as a plain string, not a one-element JSON array. + // Uses the first projected path when a projection is given, else item.name. + if (field.foundry_item_single) { + const first = contents[0]; + if (!first) return ''; + if (proj) { + const firstPath = Object.values(proj)[0]; + return getNestedValue(first, firstPath) ?? ''; + } + return first.name ?? ''; + } + const items = contents.map((it) => { if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null }; const out = {}; diff --git a/scripts/capability-inspector.mjs b/scripts/capability-inspector.mjs index a7ae9b7..867db62 100644 --- a/scripts/capability-inspector.mjs +++ b/scripts/capability-inspector.mjs @@ -132,19 +132,27 @@ function _summarizeCollection(coll, withSampleSchema) { } result.types = [...typeSet]; if (withSampleSchema && contents.length > 0) { + const readSys = (it) => { + try { + const raw = it.system; + return raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); + } catch (err) { + return {}; + } + }; const first = contents[0]; - let sys = {}; - try { - const raw = first.system; - sys = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); - } catch (err) { - sys = {}; - } result.sample = { name: first.name ?? '(unnamed)', type: first.type ?? null, - schema: walkSchema(sys, 'system', 3), + schema: walkSchema(readSys(first), 'system', 3), }; + // One representative item PER distinct type, so the report exposes the + // projectable fields for each (ability vs class vs feature vs kit…) — what + // a future collection mapping needs to know to project the right paths. + result.samples = result.types.map((t) => { + const it = contents.find((c) => c && c.type === t) || {}; + return { type: t, name: it.name ?? '(unnamed)', schema: walkSchema(readSys(it), 'system', 2) }; + }); } return result; } @@ -161,6 +169,7 @@ export const CAP_STATUS = Object.freeze({ SYNCED: 'synced', MAPPED_MISSING: 'mapped-missing', DECLARED_UNMAPPED: 'declared-unmapped', + COLLECTION_MAPPED: 'collection-mapped', AVAILABLE_UNMAPPED: 'available-unmapped', COLLECTION: 'collection', }); @@ -190,6 +199,22 @@ export function buildCapabilityReport(snapshot, fieldDefs) { // 1. Walk the manifest: classify each declared field. for (const f of defs) { if (!f.foundry_path) { + // A field with no foundry_path may still be MAPPED via a collection + // (e.g. abilities_json → actor.items[type=ability], or class → the one + // class item). Only a field with neither is genuinely unmapped. + if (f.foundry_collection) { + const types = Array.isArray(f.foundry_item_type) ? f.foundry_item_type.join(',') : (f.foundry_item_type || ''); + const desc = `${f.foundry_collection}[${types}]${f.foundry_item_single ? ' (single)' : ''}`; + rows.push({ + status: CAP_STATUS.COLLECTION_MAPPED, + key: f.key, + foundry_path: desc, + type: f.type || null, + writable: f.foundry_writable !== false, + sample: undefined, + }); + continue; + } rows.push({ status: CAP_STATUS.DECLARED_UNMAPPED, key: f.key, @@ -233,8 +258,9 @@ export function buildCapabilityReport(snapshot, fieldDefs) { source: 'actor.items', count: snapshot.items.count, types: snapshot.items.types, - note: 'Embedded items (abilities/inventory) — not pullable by the dot-path adapter yet (WS-3).', + note: 'Embedded items. Pullable via a collection mapping (foundry_collection + foundry_item_type[, foundry_item_single]); per-type schemas below show the projectable fields.', sample: snapshot.items.sample, + samples: snapshot.items.samples || [], }); } if (snapshot.effects && snapshot.effects.available) { @@ -267,6 +293,7 @@ function _summarize(rows, collections) { synced: 0, mappedMissing: 0, declaredUnmapped: 0, + collectionMapped: 0, availableUnmapped: 0, collectionsAvailable: collections.length, totalDeclared: 0, @@ -275,9 +302,10 @@ function _summarize(rows, collections) { if (r.status === CAP_STATUS.SYNCED) c.synced++; else if (r.status === CAP_STATUS.MAPPED_MISSING) c.mappedMissing++; else if (r.status === CAP_STATUS.DECLARED_UNMAPPED) c.declaredUnmapped++; + else if (r.status === CAP_STATUS.COLLECTION_MAPPED) c.collectionMapped++; else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) c.availableUnmapped++; } - c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped; + c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped + c.collectionMapped; return c; } @@ -296,7 +324,8 @@ export function renderCapabilityMarkdown(report) { lines.push(`Sampled actor: **${report.source.actorName}** (type \`${report.source.actorType}\`)`); lines.push(''); lines.push(`- Synced: **${s.synced}** / ${s.totalDeclared} declared`); - lines.push(`- Declared but unmapped (no foundry_path): **${s.declaredUnmapped}**`); + lines.push(`- Collection-mapped (items, e.g. abilities/class): **${s.collectionMapped}**`); + lines.push(`- Declared but unmapped (no foundry_path/collection): **${s.declaredUnmapped}**`); lines.push(`- Mapped but missing on this actor: **${s.mappedMissing}**`); lines.push(`- Available but not mapped (candidates): **${s.availableUnmapped}**`); lines.push(`- Collections available (items/effects): **${s.collectionsAvailable}**`); @@ -308,9 +337,15 @@ export function renderCapabilityMarkdown(report) { } if (report.collections.length) { lines.push(''); - lines.push('## Collections (not yet pullable)'); + lines.push('## Collections'); for (const c of report.collections) { lines.push(`- \`${c.source}\` — ${c.count} item(s), types: ${c.types.join(', ') || '—'} — ${c.note}`); + // Per-type item schemas — the projectable fields for each item type, so a + // collection mapping can target the right foundry_item_fields paths. + for (const sample of (c.samples || [])) { + const paths = (sample.schema || []).map((r) => `\`${r.path}\` (${r.type})`).join(', '); + lines.push(` - **${sample.type}** — e.g. "${sample.name}": ${paths || '(no system fields)'}`); + } } } return lines.join('\n') + '\n'; diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 72e428f..808dea8 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -1097,6 +1097,7 @@