// ITEM TAGS MODULE (STAK-126)
// =============================================================================
// Per-item tagging system: Numista tags (read-only, synced from API) and
// custom user tags (editable). Tags are stored separately from inventory
// items, keyed by UUID, in localStorage under ITEM_TAGS_KEY.
//
// Data shape in localStorage:
// { "uuid-abc": ["Bullion", "Commemorative"], "uuid-def": ["Proof"] }
// =============================================================================
/**
* Load item tags from localStorage into the global `itemTags` object.
*/
const loadItemTags = () => {
try {
itemTags = loadDataSync(ITEM_TAGS_KEY, {});
} catch (e) {
console.error('Failed to load item tags:', e);
itemTags = {};
}
};
/**
* Save the global `itemTags` object to localStorage.
*/
const saveItemTags = () => {
try {
saveDataSync(ITEM_TAGS_KEY, itemTags);
} catch (e) {
console.error('Failed to save item tags:', e);
}
};
/**
* Get all tags for an item.
* @param {string} uuid - Item UUID
* @returns {string[]} Array of tag strings (never null)
*/
const getItemTags = (uuid) => {
if (!uuid || !itemTags[uuid]) return [];
return [...itemTags[uuid]];
};
/**
* Add a tag to an item. Prevents duplicates and enforces limits.
* @param {string} uuid - Item UUID
* @param {string} tag - Tag name
* @param {boolean} [persist=true] - Whether to save to localStorage immediately
* @returns {boolean} True if tag was added
*/
const addItemTag = (uuid, tag, persist = true) => {
if (!uuid || !tag) return false;
const trimmed = tag.trim();
if (trimmed.length === 0 || trimmed.length > MAX_TAG_LENGTH) return false;
if (!itemTags[uuid]) itemTags[uuid] = [];
// Prevent duplicates (case-insensitive check)
const lowerTrimmed = trimmed.toLowerCase();
if (itemTags[uuid].some(t => t.toLowerCase() === lowerTrimmed)) return false;
// Enforce max tags per item
if (itemTags[uuid].length >= MAX_TAGS_PER_ITEM) return false;
itemTags[uuid].push(trimmed);
if (persist) saveItemTags();
return true;
};
/**
* Remove a tag from an item.
* @param {string} uuid - Item UUID
* @param {string} tag - Tag name
* @returns {boolean} True if tag was removed
*/
const removeItemTag = (uuid, tag) => {
if (!uuid || !itemTags[uuid]) return false;
const idx = itemTags[uuid].findIndex(t => t === tag);
if (idx === -1) return false;
itemTags[uuid].splice(idx, 1);
// Clean up empty arrays
if (itemTags[uuid].length === 0) {
delete itemTags[uuid];
}
saveItemTags();
return true;
};
/**
* Delete all tags for an item (called on item deletion).
* @param {string} uuid - Item UUID
*/
const deleteItemTags = (uuid) => {
if (!uuid || !itemTags[uuid]) return;
delete itemTags[uuid];
saveItemTags();
};
/**
* Get a sorted list of all unique tags across the entire inventory.
* @returns {string[]} Sorted array of unique tag strings
*/
const getAllUniqueTags = () => {
const tagSet = new Set();
for (const tags of Object.values(itemTags)) {
tags.forEach(t => tagSet.add(t));
}
return Array.from(tagSet).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
);
};
/**
* Rename a tag across all items.
* @param {string} oldName - Current tag name
* @param {string} newName - New tag name
* @returns {number} Number of items affected
*/
const renameTag = (oldName, newName) => {
if (!oldName || !newName) return 0;
const trimmed = newName.trim();
if (trimmed.length === 0 || trimmed.length > MAX_TAG_LENGTH) return 0;
let affected = 0;
for (const [uuid, tags] of Object.entries(itemTags)) {
const idx = tags.indexOf(oldName);
if (idx !== -1) {
// Avoid creating a duplicate
const lowerNew = trimmed.toLowerCase();
if (tags.some((t, i) => i !== idx && t.toLowerCase() === lowerNew)) {
// Already has the new tag name — just remove the old one
tags.splice(idx, 1);
} else {
tags[idx] = trimmed;
}
affected++;
if (tags.length === 0) delete itemTags[uuid];
}
}
if (affected > 0) saveItemTags();
return affected;
};
/**
* Delete a tag from all items.
* @param {string} tag - Tag name to remove globally
* @returns {number} Number of items affected
*/
const deleteTagGlobal = (tag) => {
if (!tag) return 0;
let affected = 0;
for (const [uuid, tags] of Object.entries(itemTags)) {
const idx = tags.indexOf(tag);
if (idx !== -1) {
tags.splice(idx, 1);
affected++;
if (tags.length === 0) delete itemTags[uuid];
}
}
if (affected > 0) saveItemTags();
return affected;
};
/**
* Apply Numista tags to an item from an API result.
* Capitalizes the first letter of each tag. Skips duplicates.
* @param {string} uuid - Item UUID
* @param {string[]} numistaTags - Array of tag strings from Numista API
* @param {boolean} [persist=true] - Whether to call saveItemTags() after applying.
* Pass false when calling in a loop; caller is responsible for a single saveItemTags() after.
* @returns {number} Number of tags added
*/
const applyNumistaTags = (uuid, numistaTags, persist = true) => {
if (!uuid || !Array.isArray(numistaTags) || numistaTags.length === 0) return 0;
let added = 0;
for (const raw of numistaTags) {
const tag = String(raw).trim();
if (!tag) continue;
// Capitalize first letter
const capitalized = tag.charAt(0).toUpperCase() + tag.slice(1);
if (addItemTag(uuid, capitalized, false)) {
added++;
}
}
if (persist && added > 0) saveItemTags();
return added;
};
/**
* Build the tag display section for the view modal.
* Returns a DOM fragment with Numista tags (read-only) and custom tags (editable).
* @param {string} uuid - Item UUID
* @param {string[]} numistaTags - Numista API tags (may be empty)
* @param {Function} [onChanged] - Callback when tags change (for re-render)
* @returns {HTMLElement|null} Tag section element, or null if no tags and no add capability
*/
const buildTagSection = (uuid, numistaTags, onChanged) => {
const existingTags = getItemTags(uuid);
const hasNumista = Array.isArray(numistaTags) && numistaTags.length > 0;
// Always show section so user can add custom tags
const section = document.createElement('div');
section.className = 'view-detail-section';
section.id = 'viewTagsSection';
const heading = document.createElement('div');
heading.className = 'view-section-title';
heading.textContent = 'Tags';
section.appendChild(heading);
const container = document.createElement('div');
container.className = 'view-tags-container';
// Render existing tags
const renderTags = () => {
container.textContent = '';
const currentTags = getItemTags(uuid);
// Build a set of Numista tag names (lowercased) for visual distinction
const numistaSet = new Set((numistaTags || []).map(t => String(t).trim().toLowerCase()));
currentTags.forEach(tag => {
const chip = document.createElement('span');
const isNumista = numistaSet.has(tag.toLowerCase());
chip.className = isNumista ? 'tag-chip tag-chip-numista' : 'tag-chip tag-chip-custom';
chip.textContent = tag;
chip.title = isNumista ? `Numista tag: ${tag}` : `Custom tag: ${tag} (click × to remove)`;
if (!isNumista) {
const removeBtn = document.createElement('span');
removeBtn.className = 'tag-chip-remove';
removeBtn.textContent = '\u00d7';
removeBtn.setAttribute('role', 'button');
removeBtn.setAttribute('tabindex', '0');
removeBtn.setAttribute('aria-label', `Remove tag ${tag}`);
removeBtn.onclick = (e) => {
e.stopPropagation();
removeItemTag(uuid, tag);
renderTags();
if (typeof onChanged === 'function') onChanged();
};
removeBtn.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
removeBtn.onclick(e);
}
};
chip.appendChild(removeBtn);
}
container.appendChild(chip);
});
// Add tag button
const addBtn = document.createElement('button');
addBtn.className = 'tag-add-btn';
addBtn.type = 'button';
addBtn.textContent = '+ Tag';
addBtn.title = 'Add a custom tag';
addBtn.onclick = () => {
showTagInput(container, uuid, numistaTags, renderTags, onChanged);
};
container.appendChild(addBtn);
};
renderTags();
section.appendChild(container);
return section;
};
/**
* Show an inline input for adding a new tag.
* @param {HTMLElement} container - Parent container
* @param {string} uuid - Item UUID
* @param {string[]} numistaTags - Numista tags for autocomplete
* @param {Function} renderTags - Re-render callback
* @param {Function} [onChanged] - External change callback
*/
const showTagInput = (container, uuid, numistaTags, renderTags, onChanged) => {
// Remove existing input if any
const existing = container.querySelector('.tag-input-wrapper');
if (existing) existing.remove();
const wrapper = document.createElement('span');
wrapper.className = 'tag-input-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.className = 'tag-input';
input.placeholder = 'New tag...';
input.maxLength = MAX_TAG_LENGTH;
input.setAttribute('aria-label', 'Enter tag name');
// Autocomplete dropdown
const dropdown = document.createElement('div');
dropdown.className = 'tag-autocomplete-dropdown';
dropdown.style.display = 'none';
const allTags = getAllUniqueTags();
const updateDropdown = () => {
const val = input.value.trim().toLowerCase();
dropdown.textContent = '';
if (val.length === 0) {
dropdown.style.display = 'none';
return;
}
const currentItemTags = getItemTags(uuid).map(t => t.toLowerCase());
const matches = allTags.filter(t =>
t.toLowerCase().includes(val) && !currentItemTags.includes(t.toLowerCase())
).slice(0, 8);
if (matches.length === 0) {
dropdown.style.display = 'none';
return;
}
matches.forEach(tag => {
const opt = document.createElement('div');
opt.className = 'tag-autocomplete-option';
opt.textContent = tag;
opt.onmousedown = (e) => {
e.preventDefault();
addItemTag(uuid, tag);
renderTags();
if (typeof onChanged === 'function') onChanged();
};
dropdown.appendChild(opt);
});
dropdown.style.display = '';
};
input.addEventListener('input', updateDropdown);
const commitTag = () => {
const val = input.value.trim();
if (val) {
addItemTag(uuid, val);
}
renderTags();
if (typeof onChanged === 'function') onChanged();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitTag();
} else if (e.key === 'Escape') {
renderTags();
}
});
input.addEventListener('blur', () => {
// Short delay to allow dropdown click to fire
setTimeout(() => {
commitTag();
}, 150);
});
wrapper.appendChild(input);
wrapper.appendChild(dropdown);
// Insert before the add button
const addBtn = container.querySelector('.tag-add-btn');
if (addBtn) {
container.insertBefore(wrapper, addBtn);
addBtn.style.display = 'none';
} else {
container.appendChild(wrapper);
}
input.focus();
};
// Expose globally
window.loadItemTags = loadItemTags;
window.saveItemTags = saveItemTags;
window.getItemTags = getItemTags;
window.addItemTag = addItemTag;
window.removeItemTag = removeItemTag;
window.deleteItemTags = deleteItemTags;
window.getAllUniqueTags = getAllUniqueTags;
window.renameTag = renameTag;
window.deleteTagGlobal = deleteTagGlobal;
window.applyNumistaTags = applyNumistaTags;
window.buildTagSection = buildTagSection;
// =============================================================================