Add SQLite persistence + official Minecraft item icons
Database (better-sqlite3): - Persist items, furnaces, alerts, recipes, settings to SQLite - Auto-restore last known state when server restarts or bridge disconnects - Item count history tracking (5-min snapshots, 7-day retention) - /api/history/:itemName endpoint for item count history - Docker volume for database file persistence - Graceful shutdown with DB connection cleanup Icons: - Replace mc-heads.net with official Minecraft game textures via CDN - Cascading fallback: item texture -> block texture -> emoji - In-memory URL cache to avoid redundant network requests - Block texture suffix mapping (furnace_front, barrel_top, etc.) - Crisp pixel-art rendering with image-rendering: pixelated
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
.item-icon-img {
|
.item-icon-img {
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
display: block;
|
display: block;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
/* Slight border to separate from dark backgrounds */
|
||||||
|
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon-emoji {
|
.item-icon-emoji {
|
||||||
@@ -8,4 +14,5 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,180 @@ import React, { useState } from 'react';
|
|||||||
import { getItemEmoji } from '../utils/itemUtils';
|
import { getItemEmoji } from '../utils/itemUtils';
|
||||||
import './ItemIcon.css';
|
import './ItemIcon.css';
|
||||||
|
|
||||||
|
// Minecraft assets CDN - actual game textures (16x16 pixel art)
|
||||||
|
const MC_ASSETS_BASE = 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures';
|
||||||
|
|
||||||
|
// Some items have texture names that differ from their registry name
|
||||||
|
const TEXTURE_ALIASES = {
|
||||||
|
// Crops / seeds
|
||||||
|
wheat_seeds: 'wheat_seeds',
|
||||||
|
melon_seeds: 'melon_seeds',
|
||||||
|
pumpkin_seeds: 'pumpkin_seeds',
|
||||||
|
beetroot_seeds: 'beetroot_seeds',
|
||||||
|
// Potions and such
|
||||||
|
experience_bottle: 'experience_bottle',
|
||||||
|
// Renamed textures
|
||||||
|
golden_apple: 'golden_apple',
|
||||||
|
enchanted_golden_apple: 'enchanted_golden_apple',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Items whose texture lives in the block/ folder instead of item/
|
||||||
|
const BLOCK_TEXTURES = new Set([
|
||||||
|
'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite',
|
||||||
|
'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks',
|
||||||
|
'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks',
|
||||||
|
'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log',
|
||||||
|
'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block',
|
||||||
|
'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log',
|
||||||
|
'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log',
|
||||||
|
'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud',
|
||||||
|
'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian',
|
||||||
|
'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt',
|
||||||
|
'glowstone', 'glass', 'tinted_glass',
|
||||||
|
'oak_slab', 'spruce_slab', 'birch_slab', 'jungle_slab', 'acacia_slab', 'dark_oak_slab',
|
||||||
|
'stone_slab', 'cobblestone_slab', 'brick_slab', 'stone_brick_slab',
|
||||||
|
'oak_stairs', 'spruce_stairs', 'birch_stairs', 'jungle_stairs', 'acacia_stairs',
|
||||||
|
'stone_stairs', 'cobblestone_stairs', 'brick_stairs', 'stone_brick_stairs',
|
||||||
|
'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks',
|
||||||
|
'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks',
|
||||||
|
'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon',
|
||||||
|
'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine',
|
||||||
|
'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice',
|
||||||
|
'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block',
|
||||||
|
'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block',
|
||||||
|
'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore',
|
||||||
|
'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris',
|
||||||
|
'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore',
|
||||||
|
'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore',
|
||||||
|
'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block',
|
||||||
|
'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block',
|
||||||
|
'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block',
|
||||||
|
'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar',
|
||||||
|
'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block',
|
||||||
|
'slime_block', 'honey_block', 'note_block', 'jukebox',
|
||||||
|
'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool',
|
||||||
|
'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool',
|
||||||
|
'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool',
|
||||||
|
'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete',
|
||||||
|
'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete',
|
||||||
|
'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete',
|
||||||
|
'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete',
|
||||||
|
'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta',
|
||||||
|
'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta',
|
||||||
|
'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta',
|
||||||
|
'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta',
|
||||||
|
'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta',
|
||||||
|
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
|
||||||
|
'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table',
|
||||||
|
'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone',
|
||||||
|
'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table',
|
||||||
|
'brewing_stand', 'cauldron', 'composter', 'barrel', 'chest', 'trapped_chest',
|
||||||
|
'ender_chest', 'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
|
||||||
|
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
|
||||||
|
'beacon', 'conduit', 'bell', 'lodestone', 'respawn_anchor',
|
||||||
|
'cactus', 'sugar_cane', 'bamboo',
|
||||||
|
'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block',
|
||||||
|
'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves',
|
||||||
|
'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
|
||||||
|
// We use _front, _top, or _side variants for recognizable look
|
||||||
|
const BLOCK_TEXTURE_SUFFIXES = {
|
||||||
|
furnace: '_front',
|
||||||
|
blast_furnace: '_front',
|
||||||
|
smoker: '_front',
|
||||||
|
dispenser: '_front',
|
||||||
|
dropper: '_front',
|
||||||
|
observer: '_front',
|
||||||
|
piston: '_front',
|
||||||
|
sticky_piston: '_front',
|
||||||
|
barrel: '_top',
|
||||||
|
crafting_table: '_top',
|
||||||
|
cartography_table: '_top',
|
||||||
|
fletching_table: '_top',
|
||||||
|
smithing_table: '_top',
|
||||||
|
grass_block: '_top',
|
||||||
|
mycelium: '_top',
|
||||||
|
podzol: '_top',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a Minecraft item icon.
|
* Attempt multiple texture URLs in order.
|
||||||
* First tries to load the actual item sprite from mc-heads.net.
|
* 1. item/{name}.png
|
||||||
* Falls back to an emoji representation if the image fails.
|
* 2. block/{name}.png (with optional suffix)
|
||||||
|
* 3. emoji fallback
|
||||||
|
*/
|
||||||
|
function getTextureUrls(shortName) {
|
||||||
|
const alias = TEXTURE_ALIASES[shortName] || shortName;
|
||||||
|
const urls = [];
|
||||||
|
|
||||||
|
if (BLOCK_TEXTURES.has(shortName)) {
|
||||||
|
// Known block — try block texture first
|
||||||
|
const suffix = BLOCK_TEXTURE_SUFFIXES[shortName] || '';
|
||||||
|
urls.push(`${MC_ASSETS_BASE}/block/${alias}${suffix}.png`);
|
||||||
|
// Also try without suffix
|
||||||
|
if (suffix) urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always try item texture
|
||||||
|
urls.push(`${MC_ASSETS_BASE}/item/${alias}.png`);
|
||||||
|
|
||||||
|
// If not a known block, also try block as fallback
|
||||||
|
if (!BLOCK_TEXTURES.has(shortName)) {
|
||||||
|
urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache of resolved icon URLs (avoid re-fetching on every render)
|
||||||
|
const iconCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a Minecraft item icon using the official game textures.
|
||||||
|
* Cascading fallback: item texture → block texture → emoji
|
||||||
*/
|
*/
|
||||||
function ItemIcon({ itemName, size = 32 }) {
|
function ItemIcon({ itemName, size = 32 }) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [urlIndex, setUrlIndex] = useState(0);
|
||||||
|
const [allFailed, setAllFailed] = useState(false);
|
||||||
|
|
||||||
if (!itemName) {
|
if (!itemName) {
|
||||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip namespace for the icon URL
|
// Strip namespace (minecraft:diamond → diamond)
|
||||||
const shortName = itemName.replace(/^[a-z0-9_]+:/, '');
|
const shortName = itemName.replace(/^[a-z0-9_.-]+:/, '');
|
||||||
|
|
||||||
if (imgError) {
|
// Check if we already know this item has no texture
|
||||||
|
if (iconCache.get(shortName) === 'none' || allFailed) {
|
||||||
|
return (
|
||||||
|
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||||
|
{getItemEmoji(itemName)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a cached URL, use it directly
|
||||||
|
const cachedUrl = iconCache.get(shortName);
|
||||||
|
if (cachedUrl && cachedUrl !== 'none') {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="item-icon-img"
|
||||||
|
src={cachedUrl}
|
||||||
|
alt={shortName}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = getTextureUrls(shortName);
|
||||||
|
const currentUrl = urls[urlIndex];
|
||||||
|
|
||||||
|
if (!currentUrl) {
|
||||||
|
iconCache.set(shortName, 'none');
|
||||||
return (
|
return (
|
||||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||||
{getItemEmoji(itemName)}
|
{getItemEmoji(itemName)}
|
||||||
@@ -28,13 +186,22 @@ function ItemIcon({ itemName, size = 32 }) {
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="item-icon-img"
|
className="item-icon-img"
|
||||||
src={`https://mc-heads.net/item/${shortName}/${size}`}
|
src={currentUrl}
|
||||||
alt={shortName}
|
alt={shortName}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setImgError(true)}
|
onLoad={() => {
|
||||||
style={{ imageRendering: 'pixelated' }}
|
iconCache.set(shortName, currentUrl);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
if (urlIndex + 1 < urls.length) {
|
||||||
|
setUrlIndex(urlIndex + 1);
|
||||||
|
} else {
|
||||||
|
iconCache.set(shortName, 'none');
|
||||||
|
setAllFailed(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Minecraft item icon utility
|
* Minecraft item utility functions
|
||||||
* Uses mc-heads.net item renders for actual Minecraft item icons
|
* Icons use official game textures via ItemIcon component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Format an item name for display
|
// Format an item name for display
|
||||||
export function formatItemName(name) {
|
export function formatItemName(name) {
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
return name
|
return name
|
||||||
.replace(/^[a-z0-9_]+:/, '') // Strip mod prefix
|
.replace(/^[a-z0-9_.-]+:/, '') // Strip mod prefix
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get icon URL for a Minecraft item
|
|
||||||
// Uses mc-heads.net which provides 2D item renders
|
|
||||||
export function getItemIconUrl(itemName, size = 32) {
|
|
||||||
if (!itemName) return null;
|
|
||||||
// mc-heads.net format: https://mc-heads.net/item/{item_name}
|
|
||||||
// Alternatively we can use crafatar or other MC icon APIs
|
|
||||||
// For items, we use a simple mapping approach
|
|
||||||
return `https://mc-heads.net/item/${itemName}/${size}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback emoji mapping for common item categories
|
// Fallback emoji mapping for common item categories
|
||||||
const CATEGORY_EMOJI = {
|
const CATEGORY_EMOJI = {
|
||||||
ingot: '🪙',
|
ingot: '🪙',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
build: ./server
|
build: ./server
|
||||||
networks:
|
networks:
|
||||||
- inventory-network
|
- inventory-network
|
||||||
|
volumes:
|
||||||
|
- server-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
||||||
@@ -25,3 +27,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
inventory-network:
|
inventory-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
server-data:
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
# Node.js backend
|
# Node.js backend
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Build tools needed for better-sqlite3 native compilation
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Remove build tools after install to keep image small
|
||||||
|
RUN apk del python3 make g++
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
|||||||
388
web/server/db.js
Normal file
388
web/server/db.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
const dbDir = dirname(DB_PATH);
|
||||||
|
if (!existsSync(dbDir)) {
|
||||||
|
mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// Performance pragmas
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// ========== Schema ==========
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS furnaces (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'minecraft:furnace',
|
||||||
|
active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input TEXT,
|
||||||
|
fuel TEXT,
|
||||||
|
output TEXT,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
item TEXT PRIMARY KEY,
|
||||||
|
triggered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current INTEGER NOT NULL DEFAULT 0,
|
||||||
|
threshold INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
recorded_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_history_name ON item_history(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_history_time ON item_history(recorded_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ========== Prepared Statements ==========
|
||||||
|
|
||||||
|
const upsertItem = db.prepare(`
|
||||||
|
INSERT INTO items (name, count, display_name, updated_at)
|
||||||
|
VALUES (@name, @count, @displayName, @updatedAt)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
count = @count,
|
||||||
|
display_name = @displayName,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertFurnace = db.prepare(`
|
||||||
|
INSERT INTO furnaces (name, type, active, input, fuel, output, updated_at)
|
||||||
|
VALUES (@name, @type, @active, @input, @fuel, @output, @updatedAt)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
type = @type,
|
||||||
|
active = @active,
|
||||||
|
input = @input,
|
||||||
|
fuel = @fuel,
|
||||||
|
output = @output,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertAlert = db.prepare(`
|
||||||
|
INSERT INTO alerts (item, triggered, current, threshold, updated_at)
|
||||||
|
VALUES (@item, @triggered, @current, @threshold, @updatedAt)
|
||||||
|
ON CONFLICT(item) DO UPDATE SET
|
||||||
|
triggered = @triggered,
|
||||||
|
current = @current,
|
||||||
|
threshold = @threshold,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
`);
|
||||||
|
|
||||||
|
const upsertState = db.prepare(`
|
||||||
|
INSERT INTO state (key, value, updated_at)
|
||||||
|
VALUES (@key, @value, @updatedAt)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = @value,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertHistory = db.prepare(`
|
||||||
|
INSERT INTO item_history (name, count, recorded_at)
|
||||||
|
VALUES (@name, @count, @recordedAt)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getState = db.prepare(`SELECT value FROM state WHERE key = ?`);
|
||||||
|
const getAllItems = db.prepare(`SELECT name, count, display_name as displayName FROM items ORDER BY count DESC`);
|
||||||
|
const getAllFurnaces = db.prepare(`SELECT * FROM furnaces`);
|
||||||
|
const getAllAlerts = db.prepare(`SELECT item, triggered, current, threshold FROM alerts WHERE triggered = 1`);
|
||||||
|
|
||||||
|
const getItemHistory = db.prepare(`
|
||||||
|
SELECT count, recorded_at as recordedAt
|
||||||
|
FROM item_history
|
||||||
|
WHERE name = ?
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Cleanup old history (keep last 7 days)
|
||||||
|
const cleanupHistory = db.prepare(`
|
||||||
|
DELETE FROM item_history WHERE recorded_at < ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ========== Batch Operations ==========
|
||||||
|
|
||||||
|
const saveItemsBatch = db.transaction((items, now) => {
|
||||||
|
for (const item of items) {
|
||||||
|
upsertItem.run({
|
||||||
|
name: item.name || '',
|
||||||
|
count: item.count || 0,
|
||||||
|
displayName: item.displayName || item.name || '',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveHistoryBatch = db.transaction((items, now) => {
|
||||||
|
for (const item of items) {
|
||||||
|
insertHistory.run({
|
||||||
|
name: item.name || '',
|
||||||
|
count: item.count || 0,
|
||||||
|
recordedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveFurnacesBatch = db.transaction((furnaces, now) => {
|
||||||
|
for (const [name, f] of Object.entries(furnaces)) {
|
||||||
|
upsertFurnace.run({
|
||||||
|
name,
|
||||||
|
type: f.type || 'minecraft:furnace',
|
||||||
|
active: f.active ? 1 : 0,
|
||||||
|
input: f.input || null,
|
||||||
|
fuel: f.fuel || null,
|
||||||
|
output: f.output || null,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAlertsBatch = db.transaction((alerts, now) => {
|
||||||
|
// Clear old triggered status
|
||||||
|
db.prepare('UPDATE alerts SET triggered = 0').run();
|
||||||
|
for (const alert of alerts) {
|
||||||
|
upsertAlert.run({
|
||||||
|
item: alert.item || '',
|
||||||
|
triggered: alert.triggered ? 1 : 0,
|
||||||
|
current: alert.current || 0,
|
||||||
|
threshold: alert.threshold || 0,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Public API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save inventory items to the database
|
||||||
|
*/
|
||||||
|
export function saveItems(items) {
|
||||||
|
const now = Date.now();
|
||||||
|
saveItemsBatch(items, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a snapshot of item counts for history tracking
|
||||||
|
*/
|
||||||
|
let lastHistoryRecord = 0;
|
||||||
|
const HISTORY_INTERVAL = 5 * 60 * 1000; // Record history every 5 minutes
|
||||||
|
|
||||||
|
export function recordItemHistory(items) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastHistoryRecord < HISTORY_INTERVAL) return;
|
||||||
|
lastHistoryRecord = now;
|
||||||
|
saveHistoryBatch(items, now);
|
||||||
|
|
||||||
|
// Cleanup entries older than 7 days
|
||||||
|
cleanupHistory.run(now - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save furnace status to the database
|
||||||
|
*/
|
||||||
|
export function saveFurnaces(furnaceStatus) {
|
||||||
|
const now = Date.now();
|
||||||
|
saveFurnacesBatch(furnaceStatus, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save alerts to the database
|
||||||
|
*/
|
||||||
|
export function saveAlerts(alerts) {
|
||||||
|
const now = Date.now();
|
||||||
|
saveAlertsBatch(alerts, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a key-value state entry (JSON serialized)
|
||||||
|
*/
|
||||||
|
export function saveState(key, value) {
|
||||||
|
upsertState.run({
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a key-value state entry
|
||||||
|
*/
|
||||||
|
export function loadState(key, defaultValue = null) {
|
||||||
|
const row = getState.get(key);
|
||||||
|
if (!row) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all persisted items
|
||||||
|
*/
|
||||||
|
export function loadItems() {
|
||||||
|
return getAllItems.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all persisted furnace statuses
|
||||||
|
*/
|
||||||
|
export function loadFurnaces() {
|
||||||
|
const rows = getAllFurnaces.all();
|
||||||
|
const result = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.name] = {
|
||||||
|
active: !!row.active,
|
||||||
|
type: row.type,
|
||||||
|
input: row.input,
|
||||||
|
fuel: row.fuel,
|
||||||
|
output: row.output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all triggered alerts
|
||||||
|
*/
|
||||||
|
export function loadAlerts() {
|
||||||
|
return getAllAlerts.all().map(row => ({
|
||||||
|
...row,
|
||||||
|
triggered: !!row.triggered,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item count history for a specific item
|
||||||
|
*/
|
||||||
|
export function getHistory(itemName, limit = 100) {
|
||||||
|
return getItemHistory.all(itemName, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the full last-known inventory state from DB
|
||||||
|
*/
|
||||||
|
export function loadFullState() {
|
||||||
|
const items = loadItems();
|
||||||
|
const furnaces = loadFurnaces();
|
||||||
|
const alerts = loadAlerts();
|
||||||
|
const smeltingPaused = loadState('smeltingPaused', false);
|
||||||
|
const disabledRecipes = loadState('disabledRecipes', {});
|
||||||
|
const smeltableRecipes = loadState('smeltableRecipes', {});
|
||||||
|
const craftableRecipes = loadState('craftableRecipes', []);
|
||||||
|
const craftTurtleOk = loadState('craftTurtleOk', false);
|
||||||
|
const activity = loadState('activity', {});
|
||||||
|
const lastUpdate = loadState('lastUpdate', 0);
|
||||||
|
|
||||||
|
// Reconstruct inventoryState
|
||||||
|
const inventoryMeta = loadState('inventoryMeta', {});
|
||||||
|
const inventoryState = {
|
||||||
|
itemList: items,
|
||||||
|
grandTotal: inventoryMeta.grandTotal || 0,
|
||||||
|
chestCount: inventoryMeta.chestCount || 0,
|
||||||
|
totalSlots: inventoryMeta.totalSlots || 0,
|
||||||
|
usedSlots: inventoryMeta.usedSlots || 0,
|
||||||
|
freeSlots: inventoryMeta.freeSlots || 0,
|
||||||
|
usedRatio: inventoryMeta.usedRatio || 0,
|
||||||
|
dropperOk: inventoryMeta.dropperOk || false,
|
||||||
|
barrelOk: inventoryMeta.barrelOk || false,
|
||||||
|
furnaceCount: inventoryMeta.furnaceCount || 0,
|
||||||
|
furnaceStatus: furnaces,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
inventoryState,
|
||||||
|
activityState: activity,
|
||||||
|
alertsState: alerts,
|
||||||
|
smeltingPaused,
|
||||||
|
disabledRecipes,
|
||||||
|
smeltableRecipes,
|
||||||
|
craftableRecipes,
|
||||||
|
craftTurtleOk,
|
||||||
|
lastUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the full state to DB in one transaction
|
||||||
|
*/
|
||||||
|
export const saveFullState = db.transaction((state) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Items
|
||||||
|
if (state.inventoryState?.itemList?.length) {
|
||||||
|
saveItemsBatch(state.inventoryState.itemList, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory metadata (totals, slot info, etc.)
|
||||||
|
if (state.inventoryState) {
|
||||||
|
const { itemList, furnaceStatus, ...meta } = state.inventoryState;
|
||||||
|
upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Furnaces
|
||||||
|
if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') {
|
||||||
|
saveFurnacesBatch(state.inventoryState.furnaceStatus, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
if (Array.isArray(state.alertsState)) {
|
||||||
|
saveAlertsBatch(state.alertsState, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key-value state
|
||||||
|
if (state.activityState !== undefined) {
|
||||||
|
upsertState.run({ key: 'activity', value: JSON.stringify(state.activityState), updatedAt: now });
|
||||||
|
}
|
||||||
|
if (state.smeltingPaused !== undefined) {
|
||||||
|
upsertState.run({ key: 'smeltingPaused', value: JSON.stringify(state.smeltingPaused), updatedAt: now });
|
||||||
|
}
|
||||||
|
if (state.disabledRecipes !== undefined) {
|
||||||
|
upsertState.run({ key: 'disabledRecipes', value: JSON.stringify(state.disabledRecipes), updatedAt: now });
|
||||||
|
}
|
||||||
|
if (state.smeltableRecipes !== undefined) {
|
||||||
|
upsertState.run({ key: 'smeltableRecipes', value: JSON.stringify(state.smeltableRecipes), updatedAt: now });
|
||||||
|
}
|
||||||
|
if (state.craftableRecipes !== undefined) {
|
||||||
|
upsertState.run({ key: 'craftableRecipes', value: JSON.stringify(state.craftableRecipes), updatedAt: now });
|
||||||
|
}
|
||||||
|
if (state.craftTurtleOk !== undefined) {
|
||||||
|
upsertState.run({ key: 'craftTurtleOk', value: JSON.stringify(state.craftTurtleOk), updatedAt: now });
|
||||||
|
}
|
||||||
|
upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the database connection gracefully
|
||||||
|
*/
|
||||||
|
export function closeDb() {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import express from 'express';
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
|
import {
|
||||||
|
loadFullState, saveFullState, recordItemHistory,
|
||||||
|
saveItems, saveFurnaces, saveAlerts, saveState,
|
||||||
|
getHistory, closeDb,
|
||||||
|
} from './db.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -14,29 +19,26 @@ app.use(express.json({ limit: '5mb' }));
|
|||||||
const webClients = new Set();
|
const webClients = new Set();
|
||||||
const bridgeClients = new Set();
|
const bridgeClients = new Set();
|
||||||
|
|
||||||
// Latest inventory state from the CC:Tweaked bridge
|
// Load persisted state from SQLite on startup
|
||||||
let inventoryState = {
|
console.log('💾 Loading persisted state from database...');
|
||||||
itemList: [],
|
const persisted = loadFullState();
|
||||||
grandTotal: 0,
|
|
||||||
chestCount: 0,
|
|
||||||
totalSlots: 0,
|
|
||||||
usedSlots: 0,
|
|
||||||
freeSlots: 0,
|
|
||||||
usedRatio: 0,
|
|
||||||
dropperOk: false,
|
|
||||||
barrelOk: false,
|
|
||||||
furnaceCount: 0,
|
|
||||||
furnaceStatus: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
let activityState = {};
|
let inventoryState = persisted.inventoryState;
|
||||||
let alertsState = [];
|
let activityState = persisted.activityState;
|
||||||
let smeltingPaused = false;
|
let alertsState = persisted.alertsState;
|
||||||
let disabledRecipes = {};
|
let smeltingPaused = persisted.smeltingPaused;
|
||||||
let smeltableRecipes = {};
|
let disabledRecipes = persisted.disabledRecipes;
|
||||||
let craftableRecipes = [];
|
let smeltableRecipes = persisted.smeltableRecipes;
|
||||||
let craftTurtleOk = false;
|
let craftableRecipes = persisted.craftableRecipes;
|
||||||
let lastUpdate = 0;
|
let craftTurtleOk = persisted.craftTurtleOk;
|
||||||
|
let lastUpdate = persisted.lastUpdate;
|
||||||
|
|
||||||
|
if (lastUpdate > 0) {
|
||||||
|
const ago = Math.round((Date.now() - lastUpdate) / 1000);
|
||||||
|
console.log(`💾 Restored state from ${ago}s ago (${inventoryState.itemList.length} items)`);
|
||||||
|
} else {
|
||||||
|
console.log('💾 No previous state found, starting fresh');
|
||||||
|
}
|
||||||
|
|
||||||
// Pending commands for bridge HTTP polling (fallback)
|
// Pending commands for bridge HTTP polling (fallback)
|
||||||
let pendingCommands = [];
|
let pendingCommands = [];
|
||||||
@@ -109,6 +111,13 @@ app.get('/api/alerts', (req, res) => {
|
|||||||
res.json({ alerts: alertsState });
|
res.json({ alerts: alertsState });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get item count history
|
||||||
|
app.get('/api/history/:itemName', (req, res) => {
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const history = getHistory(req.params.itemName, Math.min(limit, 1000));
|
||||||
|
res.json({ item: req.params.itemName, history });
|
||||||
|
});
|
||||||
|
|
||||||
// Get recipes (smeltable + craftable)
|
// Get recipes (smeltable + craftable)
|
||||||
app.get('/api/recipes', (req, res) => {
|
app.get('/api/recipes', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -368,7 +377,27 @@ function updateStateFromBridge(data) {
|
|||||||
if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk;
|
if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk;
|
||||||
|
|
||||||
lastUpdate = Date.now();
|
lastUpdate = Date.now();
|
||||||
|
|
||||||
|
// Persist to SQLite
|
||||||
|
try {
|
||||||
|
saveFullState({
|
||||||
|
inventoryState,
|
||||||
|
activityState,
|
||||||
|
alertsState,
|
||||||
|
smeltingPaused,
|
||||||
|
disabledRecipes,
|
||||||
|
smeltableRecipes,
|
||||||
|
craftableRecipes,
|
||||||
|
craftTurtleOk,
|
||||||
|
});
|
||||||
|
// Record history snapshot (throttled to every 5 min internally)
|
||||||
|
if (inventoryState.itemList?.length) {
|
||||||
|
recordItemHistory(inventoryState.itemList);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ DB save error:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast to all web clients
|
// Broadcast to all web clients
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
type: 'state_update',
|
type: 'state_update',
|
||||||
@@ -490,7 +519,16 @@ server.listen(PORT, HOST, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGINT', () => {
|
function shutdown() {
|
||||||
console.log('\n🛑 Shutting down server...');
|
console.log('\n🛑 Shutting down server...');
|
||||||
|
try {
|
||||||
|
closeDb();
|
||||||
|
console.log('💾 Database closed');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error closing database:', err.message);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|||||||
Reference in New Issue
Block a user