Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions src/ui/resource-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, ParsedItem>();
const children = new Map<string, ParsedItem[]>();
const loose = new Map<string, ParsedItem>();

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 {
Expand Down
153 changes: 149 additions & 4 deletions src/utils/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface ParsedItem {
mentions: number[];
firstMention: number;
lastMention: number;
slotParent?: string;
isContainer: boolean;
}

export interface ParsedWealth {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -459,14 +463,155 @@ export class NotationParser {
properties,
mentions: [lineNum],
firstMention: lineNum,
lastMention: lineNum
lastMention: lineNum,
isContainer: false
});
}
}

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<string, ParsedItem>,
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<string, ParsedItem>,
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<string, ParsedItem>,
targetName: string,
Expand Down
39 changes: 39 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading