diff --git a/main.html b/main.html
index d5d572b..cf9febb 100644
--- a/main.html
+++ b/main.html
@@ -12,6 +12,7 @@
+
diff --git a/src/app.js b/src/app.js
index c79c4c9..29fd6c6 100644
--- a/src/app.js
+++ b/src/app.js
@@ -133,6 +133,13 @@ db.version(3).stores({
// noop migration for now; existing installs will get empty prompts/codex
});
+// Add compendium table (v4)
+db.version(4).stores({
+ compendium: 'id, projectId, category, title, modified, tags'
+}).upgrade(async tx => {
+ // noop migration; new installs will get empty compendium
+});
+
// Alpine.js App
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
diff --git a/src/compendium.js b/src/compendium.js
new file mode 100644
index 0000000..bece283
--- /dev/null
+++ b/src/compendium.js
@@ -0,0 +1,90 @@
+// Simple Compendium module backed by Dexie `db` defined in app.js
+(function () {
+ try {
+ const Compendium = {
+ async createEntry(projectId, { category = 'lore', title = '', body = '', tags = [] } = {}) {
+ const id = Date.now().toString() + '-' + Math.random().toString(36).slice(2, 8);
+ const now = new Date();
+ const entry = { id, projectId, category, title, body, summary: '', tags: (tags || []).slice(0, 10), created: now, modified: now, order: 0 };
+ await db.compendium.add(entry);
+ return entry;
+ },
+
+ async updateEntry(id, updates) {
+ updates.modified = new Date();
+ await db.compendium.update(id, updates);
+ return db.compendium.get(id);
+ },
+
+ async deleteEntry(id) {
+ await db.compendium.delete(id);
+ },
+
+ async getEntry(id) {
+ return db.compendium.get(id);
+ },
+
+ async listByCategory(projectId, category) {
+ return db.compendium.where({ projectId, category }).sortBy('order');
+ },
+
+ async search(projectId, q, options = {}) {
+ q = (q || '').trim().toLowerCase();
+ const limit = options.limit || 20;
+ if (!q) {
+ return (await db.compendium.where('projectId').equals(projectId).limit(limit).toArray()) || [];
+ }
+ const all = await db.compendium.where('projectId').equals(projectId).toArray();
+ const results = all.filter(e => {
+ const hay = ((e.title || '') + '\n' + (e.tags || []).join(' ') + '\n' + (e.body || '')).toLowerCase();
+ return hay.indexOf(q) !== -1;
+ }).slice(0, limit);
+ return results;
+ },
+
+ async summaries(projectId, ids = []) {
+ const out = {};
+ for (const id of ids) {
+ const e = await db.compendium.get(id);
+ if (!e) continue;
+ if (e.summary && e.summary.length > 10) out[id] = e.summary;
+ else out[id] = (e.body || '').slice(0, 300) + ((e.body || '').length > 300 ? '…' : '');
+ }
+ return out;
+ },
+
+ async export(projectId) {
+ return db.compendium.where('projectId').equals(projectId).toArray();
+ },
+
+ async import(projectId, items = [], opts = { merge: true }) {
+ const added = [];
+ for (const it of items) {
+ const entry = Object.assign({}, it);
+ entry.projectId = projectId;
+ entry.id = entry.id || (Date.now().toString() + '-' + Math.random().toString(36).slice(2, 6));
+ entry.created = entry.created ? new Date(entry.created) : new Date();
+ entry.modified = new Date();
+ await db.compendium.put(entry);
+ added.push(entry);
+ }
+ return added;
+ }
+ };
+
+ window.Compendium = Compendium;
+
+ // Test helper
+ window.__test = window.__test || {};
+ window.__test.seedCompendium = async function (projectId, entries) {
+ const created = [];
+ for (const e of (entries || [])) {
+ const en = await Compendium.createEntry(projectId, e);
+ created.push(en);
+ }
+ return created;
+ };
+ } catch (e) {
+ console.warn('Failed to attach Compendium module:', e && e.message ? e.message : e);
+ }
+})();
diff --git a/tests/unit-compendium.js b/tests/unit-compendium.js
new file mode 100644
index 0000000..d87cf11
--- /dev/null
+++ b/tests/unit-compendium.js
@@ -0,0 +1,64 @@
+const { chromium } = require('playwright');
+const path = require('path');
+
+(async () => {
+ const projectRoot = path.resolve(__dirname, '..');
+ const fileUrl = process.env.APP_URL || ('file:///' + path.join(projectRoot, 'main.html').replace(/\\/g, '/'));
+
+ console.log('Opening:', fileUrl);
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ page.on('console', msg => { try { console.log(`PAGE LOG [${msg.type()}] ${msg.text()}`); } catch (e) { } });
+ page.on('pageerror', err => { console.error('PAGE ERROR', err && err.message ? err.message : err); });
+
+ try {
+ await page.goto(fileUrl, { waitUntil: 'load', timeout: 15000 });
+ await page.waitForSelector('.app-container, .welcome-screen', { timeout: 10000 });
+
+ // Seed project
+ const proj = await page.evaluate(async () => await window.__test.seedProject('CompTest'));
+
+ // Seed compendium entries via API
+ const entries = await page.evaluate(async (pid) => {
+ return await window.__test.seedCompendium(pid, [
+ { category: 'character', title: 'Alice', body: 'Alice is a cautious detective.' },
+ { category: 'location', title: 'Old Harbor', body: 'The Old Harbor smells of salt and diesel.' }
+ ]);
+ }, proj.id);
+
+ if (!Array.isArray(entries) || entries.length < 2) {
+ console.error('Compendium seed failed:', entries);
+ await browser.close();
+ process.exit(2);
+ }
+
+ // Test search
+ const search = await page.evaluate(async (pid) => {
+ return await window.Compendium.search(pid, 'Alice');
+ }, proj.id);
+
+ if (!(Array.isArray(search) && search.length >= 1 && search[0].title === 'Alice')) {
+ console.error('Compendium search failed:', search);
+ await browser.close();
+ process.exit(3);
+ }
+
+ // Test getEntry
+ const fetched = await page.evaluate(async (id) => await window.Compendium.getEntry(id), entries[0].id);
+ if (!fetched || fetched.title !== 'Alice') {
+ console.error('Compendium getEntry failed:', fetched);
+ await browser.close();
+ process.exit(4);
+ }
+
+ console.log('unit-compendium test passed.');
+ await browser.close();
+ process.exit(0);
+ } catch (err) {
+ console.error('unit-compendium test failed:', err && err.message ? err.message : err);
+ await browser.close();
+ process.exit(1);
+ }
+})();