Changeset 0.37.15 (#227)

This commit is contained in:
2026-03-23 19:58:17 +00:00
parent b7746c3004
commit d005e8a30f
23 changed files with 1764 additions and 170 deletions

View File

@@ -32,11 +32,13 @@ build_package() {
local out="$DIST_DIR/${name}.pkg"
rm -f "$out"
# Zip whichever standard dirs exist alongside manifest.json
# Zip whichever standard dirs/files exist alongside manifest.json
local dirs=""
[ -d "$dir/js" ] && dirs="$dirs js/"
[ -d "$dir/css" ] && dirs="$dirs css/"
[ -d "$dir/assets" ] && dirs="$dirs assets/"
[ -d "$dir/js" ] && dirs="$dirs js/"
[ -d "$dir/css" ] && dirs="$dirs css/"
[ -d "$dir/assets" ] && dirs="$dirs assets/"
[ -f "$dir/script.star" ] && dirs="$dirs script.star"
[ -d "$dir/migrations" ] && dirs="$dirs migrations/"
(cd "$dir" && zip -qr "$out" manifest.json $dirs)

View File

@@ -0,0 +1,224 @@
/* Team Activity Log — Surface Styles
* Uses platform CSS variables exclusively for theming.
*/
.tal-shell {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
/* ── Header ────────────────────────────────── */
.tal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tal-header__left {
display: flex;
align-items: baseline;
gap: 10px;
}
.tal-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.tal-subtitle {
font-size: 13px;
color: var(--text-3);
}
/* ── Entry Form ────────────────────────────── */
.tal-form {
display: flex;
gap: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.tal-form__select {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 6px 8px;
cursor: pointer;
}
.tal-form__input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 14px;
padding: 6px 10px;
outline: none;
transition: border-color var(--transition);
}
.tal-form__input:focus {
border-color: var(--accent);
}
.tal-form__input::placeholder {
color: var(--text-3);
}
.tal-form__btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-family: var(--font);
font-size: 13px;
font-weight: 500;
padding: 6px 16px;
cursor: pointer;
transition: background var(--transition);
}
.tal-form__btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.tal-form__btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Filters ───────────────────────────────── */
.tal-filters {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tal-filter {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-2);
font-family: var(--font);
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
transition: all var(--transition);
}
.tal-filter:hover {
background: var(--bg-hover);
color: var(--text);
}
.tal-filter.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.tal-count {
margin-left: auto;
font-size: 12px;
color: var(--text-3);
}
/* ── Entry List ────────────────────────────── */
.tal-entries {
display: flex;
flex-direction: column;
gap: 6px;
}
.tal-entry {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
transition: border-color var(--transition);
}
.tal-entry:hover {
border-color: var(--border-light);
}
.tal-entry__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.tal-entry__category {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.tal-entry__user {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.tal-entry__time {
font-size: 12px;
color: var(--text-3);
margin-left: auto;
}
.tal-entry__delete {
background: none;
border: none;
color: var(--text-3);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: all var(--transition);
}
.tal-entry:hover .tal-entry__delete {
opacity: 1;
}
.tal-entry__delete:hover {
color: var(--danger);
}
.tal-entry__message {
font-size: 14px;
color: var(--text);
line-height: 1.5;
}
/* ── Empty / Loading ───────────────────────── */
.tal-empty,
.tal-loading {
text-align: center;
padding: 40px 16px;
color: var(--text-3);
font-size: 14px;
}

View File

@@ -0,0 +1,221 @@
/**
* Team Activity Log — Surface Entry Point
*
* Demonstrates:
* - SDK boot + sw.* API access
* - Preact rendering into #extension-mount
* - Calling Starlark-backed ext API routes (/s/:slug/api/*)
* - sw.toast, sw.auth, sw.on (WS event subscription)
* - sw.userMenu for shell integration
* - Platform CSS variables for theming
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
// ── Boot SDK (extension surfaces don't auto-load it) ──
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
var slug = (window.__MANIFEST__ || {}).id || 'team-activity-log';
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks;
var { render } = preact;
// ── API helper: call Starlark backend ──
var API_BASE = base + '/s/' + slug + '/api';
async function api(method, path, body) {
var token = sw.auth._getToken();
var opts = {
method: method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
var resp = await fetch(API_BASE + path, opts);
var text = await resp.text();
try { return JSON.parse(text); } catch (_) { return { _raw: text, _status: resp.status }; }
}
// ── Components ─────────────────────────────
var CATEGORIES = ['note', 'update', 'decision', 'action-item'];
function App() {
var [entries, setEntries] = useState([]);
var [loading, setLoading] = useState(true);
var [filter, setFilter] = useState('');
var menuRef = useRef(null);
var load = useCallback(async function () {
var path = '/entries' + (filter ? '?category=' + encodeURIComponent(filter) : '');
var d = await api('GET', path);
setEntries(d.data || []);
setLoading(false);
}, [filter]);
useEffect(function () { load(); }, [load]);
// Mount user menu
useEffect(function () {
if (menuRef.current && sw.userMenu) {
sw.userMenu(menuRef.current, { placement: 'down-right' });
}
}, []);
async function addEntry(category, message) {
var user = sw.auth.user;
var result = await api('POST', '/entries', {
category: category,
message: message,
username: user ? (user.display_name || user.username) : '',
created_at: new Date().toISOString(),
});
if (result.error) {
sw.toast(result.error, 'error');
return;
}
sw.toast('Entry added', 'success');
load();
}
async function deleteEntry(id) {
await api('DELETE', '/entries/' + id);
sw.toast('Entry deleted', 'info');
load();
}
return html`
<div class="tal-shell">
<header class="tal-header">
<div class="tal-header__left">
<h1 class="tal-title">Activity Log</h1>
<span class="tal-subtitle">Team activity tracker</span>
</div>
<div class="tal-header__right">
<div ref=${menuRef}></div>
</div>
</header>
<${EntryForm} onSubmit=${addEntry} />
<div class="tal-filters">
<button class="tal-filter ${filter === '' ? 'active' : ''}"
onClick=${() => setFilter('')}>All</button>
${CATEGORIES.map(c => html`
<button key=${c}
class="tal-filter ${filter === c ? 'active' : ''}"
onClick=${() => setFilter(c)}>
${c}
</button>
`)}
<span class="tal-count">${entries.length} entries</span>
</div>
${loading
? html`<div class="tal-loading">Loading…</div>`
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
}
</div>
`;
}
function EntryForm({ onSubmit }) {
var [message, setMessage] = useState('');
var [category, setCategory] = useState('note');
var [submitting, setSubmitting] = useState(false);
async function submit(e) {
e.preventDefault();
if (!message.trim()) return;
setSubmitting(true);
await onSubmit(category, message.trim());
setMessage('');
setSubmitting(false);
}
return html`
<form class="tal-form" onSubmit=${submit}>
<select class="tal-form__select" value=${category}
onChange=${e => setCategory(e.target.value)}>
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
</select>
<input class="tal-form__input" type="text"
placeholder="What happened?"
value=${message}
onInput=${e => setMessage(e.target.value)}
disabled=${submitting} />
<button class="tal-form__btn" type="submit" disabled=${submitting || !message.trim()}>
${submitting ? '…' : 'Log'}
</button>
</form>
`;
}
function EntryList({ entries, onDelete }) {
if (!entries.length) {
return html`<div class="tal-empty">No entries yet. Log your first activity above.</div>`;
}
return html`
<div class="tal-entries">
${entries.map(e => html`
<div key=${e.id} class="tal-entry">
<div class="tal-entry__header">
<span class="tal-entry__category badge">${e.category}</span>
<span class="tal-entry__user">${esc(e.username || 'unknown')}</span>
<span class="tal-entry__time">${timeAgo(e.created_at)}</span>
<button class="tal-entry__delete" onClick=${() => onDelete(e.id)}
title="Delete">×</button>
</div>
<div class="tal-entry__message">${esc(e.message)}</div>
</div>
`)}
</div>
`;
}
// ── Utilities ──────────────────────────────
function esc(s) {
var el = document.createElement('span');
el.textContent = String(s || '');
return el.innerHTML;
}
function timeAgo(iso) {
if (!iso) return '';
var ms = Date.now() - new Date(iso).getTime();
var sec = Math.floor(ms / 1000);
if (sec < 60) return 'just now';
var min = Math.floor(sec / 60);
if (min < 60) return min + 'm ago';
var hr = Math.floor(min / 60);
if (hr < 24) return hr + 'h ago';
var d = Math.floor(hr / 24);
return d + 'd ago';
}
// ── Mount ──────────────────────────────────
render(html`<${App} />`, mount);
console.log('[team-activity-log] Surface mounted');
})();

View File

@@ -0,0 +1,48 @@
{
"id": "team-activity-log",
"title": "Team Activity Log",
"type": "full",
"tier": "starlark",
"route": "/s/team-activity-log",
"auth": "authenticated",
"layout": "single",
"version": "0.1.0",
"description": "Example surface demonstrating Starlark API routes, extension-owned DB tables, admin settings, and SDK integration.",
"author": "switchboard",
"permissions": ["api.http", "db.read", "db.write"],
"api_routes": [
{"method": "GET", "path": "/entries"},
{"method": "POST", "path": "/entries"},
{"method": "DELETE", "path": "/entries/*"}
],
"db_tables": [
{
"name": "entries",
"columns": {
"user_id": "text",
"username": "text",
"category": "text",
"message": "text",
"created_at": "text"
}
}
],
"settings": {
"max_entries": {
"type": "number",
"label": "Max Entries",
"description": "Maximum number of entries to retain (oldest are pruned).",
"default": 500
},
"categories": {
"type": "string",
"label": "Categories (comma-separated)",
"description": "Allowed activity categories. Leave blank for freeform.",
"default": "note,update,decision,action-item"
}
}
}

View File

@@ -0,0 +1,186 @@
# Team Activity Log — Starlark Backend
#
# Handles:
# GET /entries → list (optional ?category= filter)
# POST /entries → create
# DELETE /entries/:id → delete
#
# NOTE: The sandbox has no json module. Request body arrives as a raw
# string; response body must be a raw string. Helper functions build
# JSON manually. A json.encode/decode module would clean this up —
# tracked as a sandbox improvement.
def on_request(req):
method = req["method"]
path = req["path"]
if method == "GET" and path == "/entries":
return _list_entries(req)
elif method == "POST" and path == "/entries":
return _create_entry(req)
elif method == "DELETE" and path.startswith("/entries/"):
entry_id = path[len("/entries/"):]
return _delete_entry(req, entry_id)
return {"status": 404, "body": '{"error":"not found"}'}
def _list_entries(req):
category = ""
q = req.get("query", None)
if q != None:
cv = q.get("category", None)
if cv != None:
category = str(cv)
if category:
rows = db.query("entries", filters={"category": category}, order="-created_at", limit=200)
else:
rows = db.query("entries", order="-created_at", limit=200)
parts = []
for row in rows:
parts.append(_entry_json(row))
return {"status": 200, "body": '{"data":[' + ",".join(parts) + "]}"}
def _create_entry(req):
body = _parse_body(str(req.get("body", "")))
if not body:
return {"status": 400, "body": '{"error":"JSON body required"}'}
message = body.get("message", "")
if not message:
return {"status": 400, "body": '{"error":"message is required"}'}
category = body.get("category", "note")
username = body.get("username", "")
created_at = body.get("created_at", "")
user_id = str(req.get("user_id", ""))
# Validate category against admin settings
allowed = _allowed_categories()
if allowed and category not in allowed:
return {"status": 400, "body": '{"error":"invalid category: ' + _esc(category) + '"}'}
row_id = db.insert("entries", {
"user_id": user_id,
"username": username,
"category": category,
"message": message,
"created_at": created_at,
})
_prune()
return {"status": 201, "body": _entry_json({
"id": str(row_id), "user_id": user_id, "username": username,
"category": category, "message": message, "created_at": created_at,
})}
def _delete_entry(req, entry_id):
if not entry_id:
return {"status": 400, "body": '{"error":"entry id required"}'}
db.delete("entries", entry_id)
return {"status": 200, "body": '{"deleted":true,"id":"' + _esc(entry_id) + '"}'}
# ── Settings helpers ─────────────────────────
def _allowed_categories():
raw = settings.get("categories")
if not raw:
return []
return [p.strip() for p in raw.split(",") if p.strip()]
def _prune():
max_raw = settings.get("max_entries")
if not max_raw:
return
limit = int(max_raw)
if limit <= 0:
return
rows = db.query("entries", order="-created_at", limit=limit + 50)
if len(rows) > limit:
for row in rows[limit:]:
db.delete("entries", row["id"])
# ── JSON helpers (no json module in sandbox) ─
def _esc(s):
return str(s).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _entry_json(e):
return (
'{"id":"' + _esc(e.get("id", ""))
+ '","user_id":"' + _esc(e.get("user_id", ""))
+ '","username":"' + _esc(e.get("username", ""))
+ '","category":"' + _esc(e.get("category", ""))
+ '","message":"' + _esc(e.get("message", ""))
+ '","created_at":"' + _esc(e.get("created_at", ""))
+ '"}'
)
def _parse_body(raw):
"""Minimal flat-object JSON parser. Handles {"key":"value"} payloads."""
s = raw.strip()
if not s or s[0] != "{":
return None
s = s[1:]
if s and s[-1] == "}":
s = s[:-1]
result = {}
in_str = False
escape = False
current = ""
pairs = []
for ch in s.elems():
if escape:
current += ch
escape = False
elif ch == "\\":
current += ch
escape = True
elif ch == '"':
current += ch
in_str = not in_str
elif ch == "," and not in_str:
pairs.append(current.strip())
current = ""
else:
current += ch
if current.strip():
pairs.append(current.strip())
for pair in pairs:
colon = -1
in_s = False
esc = False
for i in range(len(pair)):
c = pair[i]
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_s = not in_s
elif c == ":" and not in_s:
colon = i
break
if colon < 0:
continue
key = _unquote(pair[:colon].strip())
val = _unquote(pair[colon+1:].strip())
if key:
result[key] = val
return result
def _unquote(s):
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
return s[1:-1].replace('\\"', '"').replace("\\\\", "\\").replace("\\n", "\n")
return s