Changeset 0.37.15 (#227)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
224
packages/team-activity-log/css/main.css
Normal file
224
packages/team-activity-log/css/main.css
Normal 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;
|
||||
}
|
||||
221
packages/team-activity-log/js/main.js
Normal file
221
packages/team-activity-log/js/main.js
Normal 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');
|
||||
})();
|
||||
48
packages/team-activity-log/manifest.json
Normal file
48
packages/team-activity-log/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
packages/team-activity-log/script.star
Normal file
186
packages/team-activity-log/script.star
Normal 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
|
||||
Reference in New Issue
Block a user