diff --git a/src/ui/resource-view.ts b/src/ui/resource-view.ts index a07d1aa..1aff1bf 100644 --- a/src/ui/resource-view.ts +++ b/src/ui/resource-view.ts @@ -4,7 +4,7 @@ */ import { ItemView, WorkspaceLeaf, TFile } from "obsidian"; -import { NotationParser, ParsedElements } from "../utils/parser"; +import { NotationParser, ParsedElements, ParsedItem } from "../utils/parser"; import { t } from "../i18n/i18n"; export const RESOURCE_VIEW_TYPE = "lonelog-resource-view"; @@ -79,25 +79,61 @@ export class ResourceStatusView extends ItemView { const grid = container.createEl("div", { cls: "ll-resource-grid", attr: { style: "padding: 0 10px;" } }); + const slots = new Map(); + const children = new Map(); + const loose = new Map(); + this.elements.inventory.forEach(item => { - const card = grid.createEl("div", { cls: "ll-resource-card" }); - - const main = card.createEl("div", { cls: "ll-resource-main" }); - const info = main.createEl("div", { cls: "ll-resource-info" }); - const nameBtn = info.createEl("div", { text: item.name, cls: "ll-resource-name" }); - nameBtn.addClass("is-clickable"); - nameBtn.addEventListener("click", () => this.jumpToLine(item.lastMention)); - - if (item.properties.length > 0) { - const propsEl = info.createEl("div", { cls: "ll-resource-props" }); - item.properties.forEach(p => propsEl.createEl("span", { text: p })); - } - - if (item.quantity) { - const qtyEl = main.createEl("div", { cls: "ll-resource-qty" }); - qtyEl.setText(item.quantity); - } + if (item.isContainer) { + slots.set(item.name, item); + } else if (item.slotParent) { + const list = children.get(item.slotParent) ?? []; + list.push(item); + children.set(item.slotParent, list); + } else { + loose.set(item.name, item); + } + }); + + loose.forEach(item => this.renderItemCard(grid, item)); + + slots.forEach((slot, slotName) => { + const kids = children.get(slotName) ?? []; + const slotSection = grid.createEl("div", { cls: "ll-slot-section" }); + const slotHeader = slotSection.createEl("div", { cls: "ll-slot-header" }); + slotHeader.createEl("span", { text: slotName, cls: "ll-slot-name" }); + slotHeader.addEventListener("click", () => this.jumpToLine(slot.lastMention)); + if (kids.length === 0) { + slotSection.createEl("div", { text: "—", cls: "ll-slot-empty" }); + } else { + kids.forEach(child => this.renderItemCard(slotSection, child, true)); + } }); + + // Sub-items whose slot parent didn't appear as a container tag (defensive) + children.forEach((kids, parentName) => { + if (slots.has(parentName)) return; + kids.forEach(child => this.renderItemCard(grid, child)); + }); + } + + private renderItemCard(container: HTMLElement, item: ParsedItem, isChild = false): void { + const card = container.createEl("div", { + cls: isChild ? "ll-resource-card ll-resource-child" : "ll-resource-card" + }); + const main = card.createEl("div", { cls: "ll-resource-main" }); + const info = main.createEl("div", { cls: "ll-resource-info" }); + const nameBtn = info.createEl("div", { text: item.name, cls: "ll-resource-name" }); + nameBtn.addClass("is-clickable"); + nameBtn.addEventListener("click", () => this.jumpToLine(item.lastMention)); + if (item.properties.length > 0) { + const propsEl = info.createEl("div", { cls: "ll-resource-props" }); + item.properties.forEach(p => propsEl.createEl("span", { text: p })); + } + if (item.quantity) { + const qtyEl = main.createEl("div", { cls: "ll-resource-qty" }); + qtyEl.setText(item.quantity); + } } private jumpToLine(line: number): void { diff --git a/src/utils/parser.ts b/src/utils/parser.ts index 6615a87..29c7062 100644 --- a/src/utils/parser.ts +++ b/src/utils/parser.ts @@ -51,6 +51,8 @@ export interface ParsedItem { mentions: number[]; firstMention: number; lastMention: number; + slotParent?: string; + isContainer: boolean; } export interface ParsedWealth { @@ -403,13 +405,15 @@ export class NotationParser { } const parts = detailsPart.split("|").map(p => p.trim()); + const lineNum = this.getLineNumber(content, match.index); + + if (this.parseInventorySlot(inventory, name, parts, lineNum)) continue; + if (parts[0] && !deltaMatch) { - quantity = parts[0]; + quantity = parts[0].replace(/→/g, "->");; } const properties = parts.slice(1).filter(p => p); - const lineNum = this.getLineNumber(content, match.index); - if (quantity.match(/^[+-]\d+$/)) { const handled = this.applyInventoryDelta(inventory, name, parseInt(quantity), lineNum); if (handled) { @@ -459,7 +463,8 @@ export class NotationParser { properties, mentions: [lineNum], firstMention: lineNum, - lastMention: lineNum + lastMention: lineNum, + isContainer: false }); } } @@ -467,6 +472,146 @@ export class NotationParser { return inventory; } + /** + * Handle slot-based inventory notation. + * Returns true if the tag was handled as a slot, false otherwise. + * + * Supports: + * [Inv:Backpack 1|Torch×6] — container with multiplier sub-items + * [Inv:Slot 1|Short Sword] — container with unique item + * [Inv:Slot 4|empty] — explicit empty container + * [Inv:Backpack 1|+Pickaxe] — add item to container + * [Inv:Backpack 1|-Pickaxe] — remove item from container + * [Inv:Backpack 1|Pickaxe->Shovel] — replace item in container + */ + private static parseInventorySlot( + inventory: Map, + name: string, + parts: string[], + lineNum: number + ): boolean { + const SLOT_CONTENT_SKIP = new Set(["empty", "depleted"]); + const isSlotName = /^.+\s+\d+$/i.test(name); + + if (!isSlotName || !parts[0]) return false; + + const slotMultiplierRe = /^[A-Za-z].+?\s*×\s*\d+/; + const isSlotMultiplier = slotMultiplierRe.test(parts[0]); + + const isSlotUnique = + !isSlotMultiplier && + !parts[0].match(/^\d/) && + !parts[0].startsWith("+") && + !parts[0].startsWith("-") && + !parts[0].includes("->") && + !parts[0].includes("→") && + !SLOT_CONTENT_SKIP.has(parts[0].toLowerCase()); + + const isSlotMutation = + !isSlotMultiplier && + !isSlotUnique && + !parts[0].match(/^\d/) && + !SLOT_CONTENT_SKIP.has(parts[0].toLowerCase()); + + if (isSlotMultiplier) { + this.upsertSlotContainer(inventory, name, parts.slice(1), lineNum); + const subItemRe = /([^,×]+?)\s*×\s*(\d+)/g; + let sub; + while ((sub = subItemRe.exec(parts[0])) !== null) { + if (!sub[1] || !sub[2]) continue; + const subName = sub[1].trim(); + const subQty = sub[2]; + if (inventory.has(subName)) { + const existing = inventory.get(subName)!; + existing.mentions.push(lineNum); + existing.lastMention = lineNum; + existing.quantity = subQty; + } else { + inventory.set(subName, { + name: subName, quantity: subQty, properties: parts.slice(1).filter(p => p), + mentions: [lineNum], firstMention: lineNum, lastMention: lineNum, + slotParent: name, isContainer: false, + }); + } + } + return true; + } + + if (isSlotUnique) { + this.upsertSlotContainer(inventory, name, [], lineNum); + const itemName = parts[0]; + const itemProps = parts.slice(1).filter(p => p); + if (!inventory.has(itemName)) { + inventory.set(itemName, { + name: itemName, quantity: "1", properties: itemProps, + mentions: [lineNum], firstMention: lineNum, lastMention: lineNum, + slotParent: name, isContainer: false, + }); + } else { + const existing = inventory.get(itemName)!; + existing.mentions.push(lineNum); + existing.lastMention = lineNum; + } + return true; + } + + if (SLOT_CONTENT_SKIP.has(parts[0].toLowerCase())) { + this.upsertSlotContainer(inventory, name, [], lineNum); + return true; + } + + if (isSlotMutation) { + const mutation = parts[0]; + if (mutation.startsWith("+")) { + const itemName = mutation.slice(1).trim(); + if (!inventory.has(itemName)) { + inventory.set(itemName, { + name: itemName, quantity: "1", properties: parts.slice(1).filter(p => p), + mentions: [lineNum], firstMention: lineNum, lastMention: lineNum, + slotParent: name, isContainer: false, + }); + } + } else if (mutation.startsWith("-")) { + const itemName = mutation.slice(1).trim(); + inventory.delete(itemName); + } else if (mutation.includes("->") || mutation.includes("→")) { + const [from, to] = mutation.replace(/→/g, "->").split("->").map(s => s.trim()); + if (from && to && inventory.has(from)) { + const existing = inventory.get(from)!; + inventory.delete(from); + inventory.set(to, { + ...existing, + name: to, + mentions: [...existing.mentions, lineNum], + lastMention: lineNum, + }); + } + } + return true; + } + + return false; + } + + private static upsertSlotContainer( + inventory: Map, + name: string, + properties: string[], + lineNum: number + ): void { + if (!inventory.has(name)) { + inventory.set(name, { + name, quantity: "", properties, + mentions: [lineNum], firstMention: lineNum, lastMention: lineNum, + isContainer: true, + }); + } else { + const existing = inventory.get(name)!; + existing.mentions.push(lineNum); + existing.lastMention = lineNum; + } + } + private static applyInventoryDelta( inventory: Map, targetName: string, diff --git a/styles.css b/styles.css index 04e8a4f..9d8fc77 100644 --- a/styles.css +++ b/styles.css @@ -1666,6 +1666,45 @@ If your plugin does not need CSS, delete this file. font-family: var(--font-monospace); } +.ll-slot-section { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ll-slot-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--background-modifier-border); + cursor: pointer; +} + +.ll-slot-header:hover .ll-slot-name { + color: var(--text-accent); +} + +.ll-slot-name { + font-size: 0.75em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + transition: color 0.15s ease; +} + +.ll-slot-empty { + font-size: 0.85em; + color: var(--text-faint); + font-style: italic; + padding: 4px 0; +} + /* Compound selectors override .ll-room-status and .ll-thread-status base styles — no !important needed */ .ll-room-status.ll-status-cleared, .ll-thread-status.ll-status-cleared { diff --git a/tests/parser.test.ts b/tests/parser.test.ts index a960c4e..88bf40f 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -187,4 +187,221 @@ describe('NotationParser', () => { const dawn = result.progress.find(p => p.name === 'Dawn'); expect(dawn?.current).toBe(3); }); + + test('[Inv:Torch|3→2] with unicode arrow updates quantity', () => { + const content = '[Inv:Torch|3→2]'; + const result = NotationParser.parse(content); + expect(result.inventory.get('Torch')?.quantity).toBe('2'); + }); + + test('[Inv:Torch|3→1] shorthand using unicode arrow', () => { + const content = ` + [Inv:Torch|3] + [Inv:Torch|3→1] + `; + const result = NotationParser.parse(content); + expect(result.inventory.get('Torch')?.quantity).toBe('1'); + }); + + // ── Slot/Container-based inventory ────────────────────────────────────────────────── + test('slot container registers without quantity', () => { + const content = '[Inv:Backpack 1|Torch×6]'; + const result = NotationParser.parse(content); + const slot = result.inventory.get('Backpack 1'); + expect(slot).toBeDefined(); + expect(slot?.quantity).toBe(''); + expect(slot?.slotParent).toBeUndefined(); + }); + + test('slot content registers sub-items with quantity and slotParent', () => { + const content = '[Inv:Backpack 1|Torch×6]'; + const result = NotationParser.parse(content); + const torch = result.inventory.get('Torch'); + expect(torch).toBeDefined(); + expect(torch?.quantity).toBe('6'); + expect(torch?.slotParent).toBe('Backpack 1'); + }); + + test('delta on slot sub-item resolves correctly', () => { + const content = ` + [Inv:Backpack 1|Torch×6] + [Inv:Torch-1] + `; + const result = NotationParser.parse(content); + expect(result.inventory.get('Torch')?.quantity).toBe('5'); + }); + + test('multiple slots with multiple sub-items', () => { + const content = ` + [Inv:Backpack 1|Torch×6] + [Inv:Backpack 2|Iron Spike×12] + [Inv:Backpack 3|Iron Rations×7] + [Inv:Torch-1] + `; + const result = NotationParser.parse(content); + expect(result.inventory.get('Torch')?.quantity).toBe('5'); + expect(result.inventory.get('Iron Spike')?.quantity).toBe('12'); + expect(result.inventory.get('Iron Rations')?.quantity).toBe('7'); + expect(result.inventory.get('Iron Spike')?.slotParent).toBe('Backpack 2'); + }); + + test('slot multiplier only accepts ×', () => { + const content = ` + [Inv:Slot 1|Arrow x 20] + [Inv:Slot 2|Ration X3] + `; + const result = NotationParser.parse(content); + expect(result.inventory.get('Arrow')).toBeUndefined(); + expect(result.inventory.get('Ration')).toBeUndefined(); + }); + + test('plain quantity tag is not treated as slot content', () => { + const content = '[Inv:Adventuring Kit|1|contains: rope]'; + const result = NotationParser.parse(content); + const kit = result.inventory.get('Adventuring Kit'); + expect(kit?.quantity).toBe('1'); + expect(kit?.properties).toContain('contains: rope'); + // Must not create a spurious sub-item entry + expect(result.inventory.has('1')).toBe(false); + }); + + test('[Inv:Slot 1|Short Sword] adds Short Sword with slotParent "Slot 1" and quantity 1', () => { + const content = '[Inv:Slot 1|Short Sword]'; + const result = NotationParser.parse(content); + const item = result.inventory.get('Short Sword'); + expect(item).toBeDefined(); + expect(item?.quantity).toBe('1'); + expect(item?.slotParent).toBe('Slot 1'); + }); + + test('[Inv:Skeleton Key|unique] — unique is quantity, not an slot item', () => { + const content = '[Inv:Skeleton Key|unique]'; + const result = NotationParser.parse(content); + const item = result.inventory.get('Skeleton Key'); + expect(item).toBeDefined(); + expect(item?.quantity).toBe('unique'); + expect(result.inventory.has('unique')).toBe(false); + }); + + test('[Inv:Map to the Ruins] appears in inventory Map', () => { + const content = '[Inv:Map to the Ruins]'; + const result = NotationParser.parse(content); + expect(result.inventory.has('Map to the Ruins')).toBe(true); + }); + + test('isSlotName only applies to names like "Word Number"', () => { + const content = ` + [Inv:Skeleton Key|unique] + [Inv:Father's Compass|quest] + [Inv:Slot 1|Short Sword] + `; + const result = NotationParser.parse(content); + // Skeleton Key & Father's Compass: quantity, not slot containers + expect(result.inventory.get('Skeleton Key')?.quantity).toBe('unique'); + expect(result.inventory.get("Father's Compass")?.quantity).toBe('quest'); + expect(result.inventory.has('unique')).toBe(false); + expect(result.inventory.has('quest')).toBe(false); + // Slot 1 is a slot container + expect(result.inventory.get('Short Sword')?.slotParent).toBe('Slot 1'); + }); + + test('isContainer is true for slots, false for normal items', () => { + const content = ` + [Inv:Slot 1|Short Sword] + [Inv:Backpack 1|Torch×6] + [Inv:Map to the Ruins] + [Inv:Skeleton Key|unique] + `; + + const result = NotationParser.parse(content); + expect(result.inventory.get('Slot 1')?.isContainer).toBe(true); + expect(result.inventory.get('Backpack 1')?.isContainer).toBe(true); + expect(result.inventory.get('Short Sword')?.isContainer).toBe(false); + expect(result.inventory.get('Torch')?.isContainer).toBe(false); + expect(result.inventory.get('Map to the Ruins')?.isContainer).toBe(false); + expect(result.inventory.get('Skeleton Key')?.isContainer).toBe(false); + }); + + test('[Inv:Slot 4|empty] is a container without sub-items', () => { + const content = '[Inv:Slot 4|empty]'; + const result = NotationParser.parse(content); + const slot = result.inventory.get('Slot 4'); + expect(slot?.isContainer).toBe(true); + expect(result.inventory.has('empty')).toBe(false); + }); + + // ── Slot/Container item mutation ───────────────────────────────────────── + test('[Inv:Backpack 1|+Pickaxe] adds Pickaxe to container without prefix', () => { + const content = ` + [Inv:Backpack 1|Torch×6] + [Inv:Backpack 1|+Pickaxe] + `; + const result = NotationParser.parse(content); + expect(result.inventory.has('Pickaxe')).toBe(true); + expect(result.inventory.get('Pickaxe')?.slotParent).toBe('Backpack 1'); + expect(result.inventory.get('Pickaxe')?.quantity).toBe('1'); + expect(result.inventory.has('+Pickaxe')).toBe(false); + }); + + test('[Inv:Backpack 1|-Pickaxe] removes Pickaxe from container', () => { + const content = ` + [Inv:Backpack 1|Torch×6] + [Inv:Backpack 1|Pickaxe] + [Inv:Backpack 1|-Pickaxe] + `; + const result = NotationParser.parse(content); + expect(result.inventory.has('Pickaxe')).toBe(false); + }); + + test('[Inv:Backpack 1|Pickaxe->Shovel] replaces Pickaxe with Shovel in container', () => { + const content = ` + [Inv:Backpack 1|Pickaxe] + [Inv:Backpack 1|Pickaxe->Shovel] + `; + const result = NotationParser.parse(content); + expect(result.inventory.has('Shovel')).toBe(true); + expect(result.inventory.get('Shovel')?.slotParent).toBe('Backpack 1'); + expect(result.inventory.has('Pickaxe')).toBe(false); + }); + + test('[Inv:Backpack 1|Shovel→Lantern] replaces Shovel with Lantern using unicode arrow', () => { + const content = ` + [Inv:Backpack 1|Shovel] + [Inv:Backpack 1|Shovel→Lantern] + `; + const result = NotationParser.parse(content); + expect(result.inventory.has('Lantern')).toBe(true); + expect(result.inventory.get('Lantern')?.slotParent).toBe('Backpack 1'); + expect(result.inventory.has('Shovel')).toBe(false); + }); + + test('[Inv:Slot 2|Potion×4|d4] registers Potion with quantity 4 and property d4', () => { + const content = '[Inv:Slot 2|Potion×4|d4]'; + const result = NotationParser.parse(content); + const item = result.inventory.get('Potion'); + expect(item?.quantity).toBe('4'); + expect(item?.properties).toContain('d4'); + expect(item?.slotParent).toBe('Slot 2'); + }); + + test('[Inv:Slot 2|Potion×4|d4|great|damages undead] registers Potion with all properties', () => { + const content = '[Inv:Slot 2|Potion×4|d4|great|damages undead]'; + const result = NotationParser.parse(content); + const item = result.inventory.get('Potion'); + expect(item?.quantity).toBe('4'); + expect(item?.properties).toContain('d4'); + expect(item?.properties).toContain('great'); + expect(item?.properties).toContain('damages undead'); + expect(item?.slotParent).toBe('Slot 2'); + }); + + test('[Inv:Slot 2|Shield|cracked] registers Shield with property cracked inside Slot 2', () => { + const content = '[Inv:Slot 2|Shield|cracked]'; + const result = NotationParser.parse(content); + const item = result.inventory.get('Shield'); + expect(item).toBeDefined(); + expect(item?.quantity).toBe('1'); + expect(item?.slotParent).toBe('Slot 2'); + expect(item?.properties).toContain('cracked'); + }); });