Compare commits
10
Commits
aa50246aeb
...
9769702585
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9769702585 | ||
|
|
f6de7845ad | ||
|
|
85783c360b | ||
|
|
10acfe76e6 | ||
|
|
647f8ceb9b | ||
|
|
272346d8ad | ||
|
|
79f88fdd51 | ||
|
|
527799cbc9 | ||
|
|
e18f42a4d0 | ||
|
|
357b0635f5 |
No files matched your search
@@ -46,7 +46,7 @@ configMapGenerator:
|
||||
- UI_ALLOWED_ORIGINS=https://memory.cloud.traunseenet.com
|
||||
- OIDC_ISSUER=https://pocketid.cloud.traunseenet.com
|
||||
- OIDC_SCOPE=openid email profile groups
|
||||
- OIDC_CLIENT_ID=REPLACE-WITH-THE-POCKETID-CLIENT-UUID
|
||||
- OIDC_CLIENT_ID=dc2aa40e-4386-46cc-bae6-0259a2e7490e
|
||||
- OIDC_GROUPS_CLAIM=groups
|
||||
- OIDC_REQUIRED_GROUP=mdmem_admins
|
||||
- SESSION_TTL_HOURS=12
|
||||
|
||||
@@ -51,6 +51,15 @@ CREATE TABLE IF NOT EXISTS links(
|
||||
PRIMARY KEY(src, dst, rel));
|
||||
CREATE INDEX IF NOT EXISTS links_dst ON links(dst);
|
||||
|
||||
-- Every title a note has ever carried, so a rename does not silently break the
|
||||
-- inbound links that still spell the old one (§5). Resolution consults this only
|
||||
-- after the live title and slug miss, so a new note claiming a freed title wins.
|
||||
CREATE TABLE IF NOT EXISTS note_titles(
|
||||
note_id TEXT NOT NULL, title TEXT NOT NULL, slug TEXT NOT NULL, seen TEXT NOT NULL,
|
||||
PRIMARY KEY(note_id, title));
|
||||
CREATE INDEX IF NOT EXISTS note_titles_title ON note_titles(title);
|
||||
CREATE INDEX IF NOT EXISTS note_titles_slug ON note_titles(slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flags(
|
||||
id INTEGER PRIMARY KEY, note_id TEXT NOT NULL, reason TEXT, source TEXT,
|
||||
created TEXT, resolved INTEGER NOT NULL DEFAULT 0);
|
||||
@@ -140,6 +149,7 @@ class Database:
|
||||
chunk_columns = {r[1] for r in self._write.execute("PRAGMA table_info(chunks)")}
|
||||
if "tri_pending" not in chunk_columns:
|
||||
self._write.execute("ALTER TABLE chunks ADD COLUMN tri_pending INTEGER NOT NULL DEFAULT 0")
|
||||
self._backfill_note_titles()
|
||||
self._ensure_trigram_table()
|
||||
self._write.commit()
|
||||
rebuilt = self._ensure_vec_table()
|
||||
@@ -149,6 +159,29 @@ class Database:
|
||||
self._write.commit()
|
||||
return rebuilt
|
||||
|
||||
def _backfill_note_titles(self) -> None:
|
||||
"""Seed the alias table from the titles already in the index.
|
||||
|
||||
Without this, an upgraded index only learns a note's title the next time
|
||||
that note is reindexed — and a note that never changes is never
|
||||
reindexed, so the *first* rename after the upgrade would still break its
|
||||
inbound links, which is exactly the case the table exists to prevent.
|
||||
"""
|
||||
from mdmem.ids import slugify
|
||||
|
||||
assert self._write is not None
|
||||
rows = self._write.execute(
|
||||
"SELECT n.id, n.title FROM notes n "
|
||||
"LEFT JOIN note_titles t ON t.note_id = n.id AND t.title = n.title "
|
||||
"WHERE n.title IS NOT NULL AND n.title <> '' AND t.note_id IS NULL"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
self._write.execute(
|
||||
"INSERT OR IGNORE INTO note_titles(note_id,title,slug,seen) "
|
||||
"VALUES(?,?,?,strftime('%Y-%m-%dT%H:%M:%SZ','now'))",
|
||||
(row["id"], row["title"], slugify(row["title"])),
|
||||
)
|
||||
|
||||
def _ensure_trigram_table(self) -> None:
|
||||
"""Create/drop `chunks_tri` to match `self.trigram`.
|
||||
|
||||
|
||||
+35
-1
@@ -128,6 +128,7 @@ class Indexer:
|
||||
|
||||
self._write_links(w, note)
|
||||
self._mirror_flags(w, note)
|
||||
self._record_title(w, note)
|
||||
self._resolve_links_for(note)
|
||||
return pending
|
||||
|
||||
@@ -178,6 +179,7 @@ class Indexer:
|
||||
w.execute("DELETE FROM links WHERE src=?", (note_id,))
|
||||
w.execute("DELETE FROM links WHERE dst=?", (note_id,))
|
||||
w.execute("DELETE FROM flags WHERE note_id=?", (note_id,))
|
||||
w.execute("DELETE FROM note_titles WHERE note_id=?", (note_id,))
|
||||
w.execute("DELETE FROM notes WHERE id=?", (note_id,))
|
||||
|
||||
def inbound_sources(self, note_id: str) -> list[str]:
|
||||
@@ -210,9 +212,32 @@ class Indexer:
|
||||
seen.add(key)
|
||||
w.execute("INSERT OR REPLACE INTO links(src,dst,rel,origin) VALUES(?,?,?,?)", r)
|
||||
|
||||
@staticmethod
|
||||
def _record_title(w, note: Note) -> None:
|
||||
"""Remember this title for the note, so a later rename does not orphan
|
||||
the inbound links that still spell it (§5)."""
|
||||
if not note.title:
|
||||
return
|
||||
w.execute(
|
||||
"INSERT INTO note_titles(note_id,title,slug,seen) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(note_id, title) DO UPDATE SET seen=excluded.seen",
|
||||
(note.id, note.title, slugify(note.title), utcnow()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve(w, target: str) -> str:
|
||||
"""ULID, then exact title, then slug; otherwise 'unresolved:<text>' (§5)."""
|
||||
"""ULID, then exact title, then slug, then a former title; otherwise
|
||||
'unresolved:<text>' (§5).
|
||||
|
||||
The former-title lookup runs last on purpose. Resolution re-reads the
|
||||
link text on every reindex, so renaming a note used to turn every
|
||||
inbound `[[Old Title]]` into an unresolved link the next time its source
|
||||
was indexed — silently, and long after the rename. Consulting past
|
||||
titles fixes that without rewriting anyone else's body. Keeping it below
|
||||
the live lookups means a *new* note that claims the freed title still
|
||||
wins, which is the behaviour you want: the link text names whatever
|
||||
holds that title now.
|
||||
"""
|
||||
target = target.strip()
|
||||
if not target:
|
||||
return f"{UNRESOLVED}"
|
||||
@@ -224,6 +249,15 @@ class Indexer:
|
||||
return row[0]
|
||||
slug = slugify(target)
|
||||
row = w.execute("SELECT id, title FROM notes WHERE path LIKE ? LIMIT 1", (f"%/{slug}--%",)).fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
# A title may have been held by several notes over time; the most recent
|
||||
# holder is the least surprising answer, and it is deterministic.
|
||||
row = w.execute(
|
||||
"SELECT t.note_id FROM note_titles t JOIN notes n ON n.id = t.note_id "
|
||||
"WHERE t.title=? OR t.slug=? ORDER BY t.seen DESC, t.note_id DESC LIMIT 1",
|
||||
(target, slug),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
return f"{UNRESOLVED}{target}"
|
||||
|
||||
+75
-7
@@ -142,6 +142,26 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
|
||||
# itself degrade to "no Origin, so allow".
|
||||
candidate = request.headers.get("origin") or _origin_from_referer(request)
|
||||
if candidate is None or not _origin_allowed(candidate, settings, request):
|
||||
# A silent 403 is only diagnosable from the browser: uvicorn's
|
||||
# access log is off, so a refused write left no trace at all on
|
||||
# the server. Log what was compared — origin, host and the
|
||||
# configured allow-list are all non-secret, and without them a
|
||||
# proxy that spells the authority differently looks like a bug
|
||||
# in the UI rather than a header mismatch.
|
||||
log.warning(
|
||||
"CSRF check refused a write",
|
||||
extra={
|
||||
"fields": {
|
||||
"path": path,
|
||||
"method": request.method,
|
||||
"reason": _refusal_reason(candidate),
|
||||
"origin": candidate,
|
||||
"host": request.headers.get("host"),
|
||||
"x_forwarded_host": request.headers.get("x-forwarded-host"),
|
||||
"allowed": sorted(_allowed_origins(settings)),
|
||||
}
|
||||
},
|
||||
)
|
||||
return JSONResponse({"error": "bad origin"}, status_code=403)
|
||||
|
||||
if not is_open:
|
||||
@@ -158,7 +178,14 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = CSP
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
# NOT "no-referrer", however tempting: per the Fetch standard a referrer
|
||||
# policy of `no-referrer` makes the browser send `Origin: null` on HTML
|
||||
# form submissions — and suppress the Referer that the CSRF guard falls
|
||||
# back to. Every write in this UI is a form POST, so that header refused
|
||||
# every one of them with "bad origin". `same-origin` nulls the Origin
|
||||
# only on cross-origin requests, which is precisely the case the guard
|
||||
# is meant to refuse, and still sends no referrer information off-site.
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
@@ -307,7 +334,7 @@ def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many
|
||||
@app.post("/ui/n/{note_id}/approve")
|
||||
async def approve(request: Request, note_id: str) -> Response:
|
||||
# The plain "Übernehmen" form submits no `body` field at all — approve
|
||||
# as-is. The "Bearbeiten & übernehmen" form always submits one, even
|
||||
# as-is. The "Edit & approve" form always submits one, even
|
||||
# when the textarea was cleared, and an intentionally emptied body
|
||||
# must be saved as empty rather than silently falling back to the old
|
||||
# body (lower-severity plan drift, §10). `Form(default="")` can't tell
|
||||
@@ -637,22 +664,63 @@ def _origin_allowed(origin: str, settings: Any, request: Request) -> bool:
|
||||
parsed_origin = urlparse(origin)
|
||||
if not parsed_origin.scheme or not parsed_origin.netloc:
|
||||
return False
|
||||
host = _request_host(request)
|
||||
if host != parsed_origin.netloc:
|
||||
if not _same_authority(_request_host(request), parsed_origin):
|
||||
return False
|
||||
allowed = set(settings.ui_allowed_origins)
|
||||
if settings.ui_base_url:
|
||||
allowed.add(settings.ui_base_url)
|
||||
allowed = _allowed_origins(settings)
|
||||
if not allowed: # not configured: fall back to same-host
|
||||
return True
|
||||
return origin.rstrip("/") in {a.rstrip("/") for a in allowed}
|
||||
|
||||
|
||||
def _refusal_reason(candidate: str | None) -> str:
|
||||
if candidate is None:
|
||||
return "no Origin or Referer"
|
||||
if candidate.strip().lower() == "null":
|
||||
# Worth calling out by name: an opaque origin means something upstream
|
||||
# stripped it — a referrer policy of no-referrer, a sandboxed iframe, or
|
||||
# a privacy extension — not that the caller is on the wrong host.
|
||||
return "opaque origin (Origin: null) — check Referrer-Policy"
|
||||
return "origin not allowed"
|
||||
|
||||
|
||||
def _allowed_origins(settings: Any) -> set[str]:
|
||||
allowed = set(settings.ui_allowed_origins)
|
||||
if settings.ui_base_url:
|
||||
allowed.add(settings.ui_base_url)
|
||||
return allowed
|
||||
|
||||
|
||||
def _request_host(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-host", "").split(",", 1)[0].strip()
|
||||
return forwarded or request.headers.get("host", "")
|
||||
|
||||
|
||||
def _split_authority(value: str, scheme: str) -> tuple[str, str]:
|
||||
"""(host, port) with the scheme's default port filled in, lowercased."""
|
||||
v = value.strip().lower()
|
||||
if v.startswith("["): # IPv6 literal: [::1]:8081
|
||||
host, _, rest = v.partition("]")
|
||||
host, port = host + "]", rest[1:] if rest.startswith(":") else ""
|
||||
else:
|
||||
host, _, port = v.partition(":")
|
||||
return host, port or ("443" if scheme == "https" else "80")
|
||||
|
||||
|
||||
def _same_authority(host_header: str, origin: Any) -> bool:
|
||||
"""Does the Host header name the same authority as this Origin?
|
||||
|
||||
A plain string comparison does not survive a reverse proxy: browsers omit the
|
||||
default port from Origin while Traefik forwards Host with it, so
|
||||
`memory.example.com` and `memory.example.com:443` are the same authority
|
||||
spelled two ways — comparing them raw refused every state-changing POST in
|
||||
the deployed UI. Normalising the default port and case still refuses the case
|
||||
this check exists for, an allowed Origin paired with a foreign Host (M-10).
|
||||
"""
|
||||
if not host_header:
|
||||
return False
|
||||
return _split_authority(host_header, origin.scheme) == _split_authority(origin.netloc, origin.scheme)
|
||||
|
||||
|
||||
def _diff(old: str, new: str, label: str) -> str:
|
||||
return "\n".join(
|
||||
difflib.unified_diff(
|
||||
|
||||
+484
-59
@@ -1,72 +1,497 @@
|
||||
/* mdmem UI — a dense review tool, so: calm surfaces, strong hierarchy, no chrome
|
||||
that competes with note text. Tokens first, components after. */
|
||||
|
||||
:root {
|
||||
--bg: #fbfaf7; --fg: #1d1c1a; --muted: #6b675f; --line: #e2ded5;
|
||||
--card: #ffffff; --accent: #2f5d50; --warn: #8a5a00; --danger: #8c2f2f;
|
||||
--add: #1f6f43; --del: #8c2f2f;
|
||||
color-scheme: light dark;
|
||||
|
||||
--bg: #f7f7f8;
|
||||
--bg-sunken: #efeff1;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafafb;
|
||||
--fg: #17181c;
|
||||
--fg-soft: #4a4d57;
|
||||
--muted: #70747f;
|
||||
--line: #e3e4e8;
|
||||
--line-strong: #cfd1d7;
|
||||
|
||||
--accent: #10715c;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-soft: #e6f2ee;
|
||||
|
||||
--ok: #15704a;
|
||||
--warn: #8a5a00;
|
||||
--danger: #a32f2f;
|
||||
--add: #15704a;
|
||||
--del: #a32f2f;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgb(16 18 24 / 6%);
|
||||
--shadow: 0 1px 2px rgb(16 18 24 / 5%), 0 4px 12px rgb(16 18 24 / 6%);
|
||||
--shadow-lg: 0 2px 4px rgb(16 18 24 / 6%), 0 12px 28px rgb(16 18 24 / 10%);
|
||||
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--step: 0.25rem;
|
||||
--shell: 68rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16171a; --fg: #e8e6e1; --muted: #9b968c; --line: #2c2e33;
|
||||
--card: #1d1f23; --accent: #7fbfab; --warn: #d0a24c; --danger: #e08585;
|
||||
--add: #7fc7a0; --del: #e08585;
|
||||
--bg: #0f1113;
|
||||
--bg-sunken: #0a0c0d;
|
||||
--surface: #17191c;
|
||||
--surface-2: #1c1f23;
|
||||
--fg: #e9eaec;
|
||||
--fg-soft: #b9bcc2;
|
||||
--muted: #8b8f98;
|
||||
--line: #282b31;
|
||||
--line-strong: #3a3e46;
|
||||
|
||||
--accent: #4fd1ae;
|
||||
--accent-fg: #06231c;
|
||||
--accent-soft: #142e28;
|
||||
|
||||
--ok: #52c48c;
|
||||
--warn: #e0ad5a;
|
||||
--danger: #f08585;
|
||||
--add: #52c48c;
|
||||
--del: #f08585;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 35%), 0 4px 12px rgb(0 0 0 / 35%);
|
||||
--shadow-lg: 0 2px 4px rgb(0 0 0 / 35%), 0 12px 28px rgb(0 0 0 / 45%);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 16px/1.55 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
:where(a) { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
|
||||
:where(a):hover { text-decoration-thickness: 2px; }
|
||||
|
||||
:where(a, button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* -- shell ---------------------------------------------------------------- */
|
||||
header {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
|
||||
padding: .6rem 1.2rem; border-bottom: 1px solid var(--line); position: sticky; top: 0;
|
||||
background: var(--bg); z-index: 5;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
backdrop-filter: saturate(1.4) blur(10px);
|
||||
}
|
||||
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
nav a { color: var(--fg); text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
.brand { font-weight: 700; letter-spacing: .02em; color: var(--accent); }
|
||||
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1.2rem 5rem; }
|
||||
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; }
|
||||
h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||
h1 small { color: var(--muted); font-weight: 400; font-size: .8rem; margin-left: .5rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; margin: 0 0 1.2rem; }
|
||||
.meta, .hint { color: var(--muted); font-size: .82rem; }
|
||||
.summary { font-style: italic; color: var(--muted); }
|
||||
.snippet { white-space: pre-wrap; }
|
||||
.pill { font-size: .72rem; padding: .1rem .45rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); vertical-align: middle; }
|
||||
.pill.ok { color: var(--add); border-color: var(--add); }
|
||||
.pill.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.badge { background: var(--line); border-radius: 999px; padding: 0 .4rem; font-size: .75rem; }
|
||||
.tag { display: inline-block; font-size: .78rem; background: var(--line); border-radius: 4px; padding: .05rem .4rem; margin: 0 .25rem .25rem 0; color: var(--fg); text-decoration: none; }
|
||||
.unresolved { color: var(--warn); text-decoration: underline dotted; }
|
||||
.rel { color: var(--muted); font-size: .75rem; }
|
||||
.empty { color: var(--muted); }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
|
||||
.cols ul, .history { list-style: none; padding: 0; margin: .2rem 0; }
|
||||
form { display: inline; }
|
||||
.filters, .editor { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.editor { flex-direction: column; align-items: stretch; }
|
||||
.editor label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; color: var(--muted); }
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
nav::-webkit-scrollbar { display: none; }
|
||||
nav a { flex: 0 0 auto; }
|
||||
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.34rem 0.66rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg-soft);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
nav a:hover { background: var(--bg-sunken); color: var(--fg); }
|
||||
nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.brand {
|
||||
margin-right: 0.5rem;
|
||||
padding-left: 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.02rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.brand:hover { background: none; }
|
||||
|
||||
.who { display: flex; align-items: center; gap: 0.7rem; font-size: 0.84rem; color: var(--muted); }
|
||||
.who a { color: var(--muted); text-decoration: none; }
|
||||
.who a:hover { color: var(--fg); text-decoration: underline; }
|
||||
|
||||
main {
|
||||
/* Same gutter formula as the header so both content columns align. No
|
||||
max-width here: with border-box it would include the gutter and squeeze
|
||||
the column to a fraction of the header's width. */
|
||||
padding-block: 1.75rem 6rem;
|
||||
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
|
||||
}
|
||||
|
||||
/* -- type ----------------------------------------------------------------- */
|
||||
h1 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.55rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h2 { margin: 0 0 0.5rem; font-size: 1.08rem; font-weight: 600; letter-spacing: -0.01em; }
|
||||
h3 {
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
h1 small {
|
||||
margin-left: 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.meta, .hint { color: var(--muted); font-size: 0.8rem; }
|
||||
.meta { margin: 0.3rem 0; font-variant-numeric: tabular-nums; }
|
||||
.summary { margin: 0.4rem 0; color: var(--fg-soft); font-style: italic; }
|
||||
.snippet { margin: 0.5rem 0; white-space: pre-wrap; color: var(--fg-soft); }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* -- card ----------------------------------------------------------------- */
|
||||
.card {
|
||||
margin: 0 0 1rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.card:hover { box-shadow: var(--shadow); border-color: var(--line-strong); }
|
||||
.card h2, .card > h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
|
||||
.card h2 .pill, .card > h1 .pill { margin-left: 0; max-width: 100%; }
|
||||
.card h2 a { color: inherit; text-decoration: none; }
|
||||
.card h2 a:hover { color: var(--accent); }
|
||||
|
||||
/* -- labels --------------------------------------------------------------- */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.08rem 0.5rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--muted);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); background: color-mix(in srgb, var(--ok) 8%, transparent); }
|
||||
.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 8%, transparent); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
min-width: 1.3rem;
|
||||
padding: 0 0.38rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
nav a[aria-current="page"] .badge { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
margin: 0 0.3rem 0.3rem 0;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--fg-soft);
|
||||
font-size: 0.76rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.tag:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.kw { margin: 0.6rem 0 0; }
|
||||
|
||||
.unresolved { color: var(--warn); text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
.rel { color: var(--muted); font-size: 0.74rem; }
|
||||
|
||||
/* -- controls ------------------------------------------------------------- */
|
||||
input, select, textarea, button, .button {
|
||||
font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--fg);
|
||||
font: inherit;
|
||||
padding: 0.42rem 0.7rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88rem; }
|
||||
button, .button { cursor: pointer; text-decoration: none; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger, summary.danger { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: 1rem; }
|
||||
.actions details { width: 100%; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; display: block; overflow-x: auto; }
|
||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
|
||||
th a { color: var(--muted); text-decoration: none; }
|
||||
tr.superseded td { opacity: .55; text-decoration: line-through; }
|
||||
pre.diff { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem; overflow-x: auto; font-size: .82rem; }
|
||||
pre.diff .add { color: var(--add); } pre.diff .del { color: var(--del); } pre.diff .hunk { color: var(--muted); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--muted); }
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 550;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover, .button:hover { background: var(--bg-sunken); border-color: var(--muted); }
|
||||
button:active, .button:active { transform: translateY(0.5px); }
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
button.primary:hover { background: color-mix(in srgb, var(--accent) 88%, black); border-color: transparent; }
|
||||
|
||||
button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--line-strong)); }
|
||||
button.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); border-color: var(--danger); }
|
||||
summary.danger { color: var(--danger); }
|
||||
|
||||
form { display: inline; }
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.filters label { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.84rem; color: var(--fg-soft); }
|
||||
.filters input[type="search"] { flex: 1 1 16rem; }
|
||||
|
||||
.editor { display: flex; flex-direction: column; gap: 0.85rem; align-items: stretch; margin-bottom: 1.25rem; }
|
||||
.editor label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
|
||||
.editor button, .editor .button { align-self: flex-start; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.actions.bare { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.actions form { display: inline-flex; gap: 0.4rem; align-items: center; }
|
||||
.actions form input[type="text"] { flex: 1 1 10rem; min-width: 0; }
|
||||
.actions details { width: 100%; margin-top: 0.3rem; }
|
||||
.actions details summary { cursor: pointer; font-size: 0.86rem; padding: 0.2rem 0; }
|
||||
.actions details[open] summary { margin-bottom: 0.5rem; }
|
||||
.actions details form { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; }
|
||||
.actions details form input[type="text"] { width: min(100%, 24rem); }
|
||||
|
||||
.history-compare { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: flex-end; margin: 0.5rem 0 1rem; }
|
||||
.history-compare label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
|
||||
/* -- layout helpers ------------------------------------------------------- */
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
|
||||
.cols ul, .history { list-style: none; padding: 0; margin: 0.2rem 0; }
|
||||
.cols li, .history li { padding: 0.18rem 0; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem;
|
||||
min-height: 4.6rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat dt { margin: 0; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
.stat dd { margin: 0; font-size: 1.65rem; line-height: 1.1; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.03em; }
|
||||
.stat.attention dd { color: var(--warn); }
|
||||
.stat.zero dd { color: var(--muted); }
|
||||
|
||||
/* key/value rows for mixed-type facts that do not belong in a number tile */
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 1rem; margin: 0; font-size: 0.88rem; }
|
||||
.kv dt { color: var(--muted); white-space: nowrap; }
|
||||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
|
||||
/* -- notices -------------------------------------------------------------- */
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.notice p { margin: 0; }
|
||||
.notice h3 { margin-top: 0; }
|
||||
.notice.ok { border-left-color: var(--ok); }
|
||||
.notice.warn { border-left-color: var(--warn); }
|
||||
.notice.danger { border-left-color: var(--danger); }
|
||||
.conflict {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--line));
|
||||
border-left: 3px solid var(--warn);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--warn) 5%, var(--surface));
|
||||
}
|
||||
.conflict h2, .conflict h3 { margin-top: 0; }
|
||||
|
||||
/* -- table ---------------------------------------------------------------- */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
td.wrap, th.wrap { white-space: normal; min-width: 11rem; max-width: 26rem; }
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-2);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
th a { color: inherit; text-decoration: none; }
|
||||
th a:hover { color: var(--accent); }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr { transition: background-color 0.1s ease; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
tr.superseded td { opacity: 0.5; text-decoration: line-through; }
|
||||
|
||||
/* -- markdown body -------------------------------------------------------- */
|
||||
.body { overflow-wrap: anywhere; }
|
||||
.body pre, .body code { background: var(--line); border-radius: 4px; padding: .05rem .25rem; }
|
||||
.body pre { padding: .6rem; overflow-x: auto; }
|
||||
.body.small { font-size: .85rem; }
|
||||
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .8rem; }
|
||||
.dupe { border: 1px dashed var(--line); border-radius: 8px; padding: .6rem; }
|
||||
.conflict { border: 1px solid var(--warn); border-radius: 8px; padding: .8rem; margin-bottom: 1rem; }
|
||||
.cloud { line-height: 2; }
|
||||
.body > :first-child { margin-top: 0; }
|
||||
.body > :last-child { margin-bottom: 0; }
|
||||
.body pre, .body code { background: var(--bg-sunken); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 0.86em; }
|
||||
.body code { padding: 0.1rem 0.3rem; }
|
||||
.body pre { padding: 0.7rem 0.85rem; overflow-x: auto; border: 1px solid var(--line); }
|
||||
.body pre code { background: none; padding: 0; }
|
||||
.body blockquote { margin: 0.8rem 0; padding-left: 0.9rem; border-left: 3px solid var(--line-strong); color: var(--fg-soft); }
|
||||
.body.small { font-size: 0.86rem; }
|
||||
|
||||
/* -- diff ----------------------------------------------------------------- */
|
||||
pre.diff {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre.diff .add { color: var(--add); background: color-mix(in srgb, var(--add) 10%, transparent); display: inline-block; width: 100%; }
|
||||
pre.diff .del { color: var(--del); background: color-mix(in srgb, var(--del) 10%, transparent); display: inline-block; width: 100%; }
|
||||
pre.diff .hunk { color: var(--muted); font-weight: 600; }
|
||||
|
||||
/* -- duplicates ----------------------------------------------------------- */
|
||||
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.75rem; }
|
||||
.dupe {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.dupe > a { font-weight: 550; }
|
||||
|
||||
.cloud { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 1.25rem; }
|
||||
.cloud .tag { margin: 0; display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
body { font-size: 14.5px; }
|
||||
header { gap: 0.5rem; }
|
||||
.brand { margin-right: 0.2rem; }
|
||||
.who span { display: none; } /* the name costs a line; the link is the point */
|
||||
main { padding-block: 1.25rem 4rem; }
|
||||
h1 { font-size: 1.28rem; }
|
||||
h1 small { display: block; margin: 0.2rem 0 0; }
|
||||
.card { padding: 0.9rem 1rem; }
|
||||
|
||||
/* two tiles per row stays readable down to ~340px */
|
||||
.stats { grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: 0.5rem; }
|
||||
.stat { min-height: 4rem; padding: 0.6rem 0.7rem; }
|
||||
.stat dd { font-size: 1.35rem; }
|
||||
|
||||
/* full-width controls beat a ragged two-and-a-half-button row */
|
||||
.actions form { display: flex; width: 100%; }
|
||||
.actions > form > button, .actions > .button { flex: 1 1 auto; justify-content: center; }
|
||||
.actions form input[type="text"] { flex: 1 1 6rem; }
|
||||
|
||||
/* a long note title in a pill must wrap rather than widen the card */
|
||||
.pill { white-space: normal; }
|
||||
|
||||
/* the search filter row is eight controls: two columns halve its height,
|
||||
with the query and the submit button spanning the full width */
|
||||
.filters { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.6rem; }
|
||||
.filters > * { width: 100%; min-width: 0; }
|
||||
.filters input[type="search"], .filters button, .filters label { grid-column: 1 / -1; }
|
||||
|
||||
.history-compare { width: 100%; }
|
||||
.history-compare label, .history-compare select { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -7,17 +7,18 @@
|
||||
<link rel="stylesheet" href="/ui/static/style.css">
|
||||
<script src="/ui/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
{% set path = request.url.path if request else '' %}
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a class="brand" href="/ui/inbox">mdmem</a>
|
||||
<a href="/ui/inbox">Posteingang <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
|
||||
<a href="/ui/search">Suche</a>
|
||||
<a href="/ui/browse">Alle <span class="badge">{{ counts.get('active', 0) }}</span></a>
|
||||
<a href="/ui/health">Health</a>
|
||||
<a href="/ui/inbox" {% if path.startswith('/ui/inbox') %}aria-current="page"{% endif %}>Inbox <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
|
||||
<a href="/ui/search" {% if path.startswith('/ui/search') %}aria-current="page"{% endif %}>Search</a>
|
||||
<a href="/ui/browse" {% if path.startswith('/ui/browse') %}aria-current="page"{% endif %}>All <span class="badge">{{ counts.get('active', 0) }}</span></a>
|
||||
<a href="/ui/health" {% if path.startswith('/ui/health') %}aria-current="page"{% endif %}>Health</a>
|
||||
</nav>
|
||||
<div class="who">
|
||||
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">abmelden</a>{% endif %}
|
||||
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">Sign out</a>{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Notizen — mdmem{% endblock %}
|
||||
{% block title %}All notes — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Alle Notizen <small>{{ rows|length }}</small></h1>
|
||||
<h1>All notes <small>{{ rows|length }}</small></h1>
|
||||
{% if cloud %}
|
||||
<p class="cloud">{% for t, n in cloud %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }} <span class="badge">{{ n }}</span></a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
<form method="get" class="filters">
|
||||
<select name="state">
|
||||
<option value="">alle Zustände</option>
|
||||
<select name="state" aria-label="Lifecycle state">
|
||||
<option value="">All states</option>
|
||||
{% for s in ['inbox','active','archive'] %}<option value="{{ s }}" {% if state == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
|
||||
<button type="submit">Filtern</button>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag" aria-label="Tag">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Titel</a></th>
|
||||
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Typ</a></th>
|
||||
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">Zustand</a></th>
|
||||
<th class="wrap"><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Title</a></th>
|
||||
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Type</a></th>
|
||||
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">State</a></th>
|
||||
<th><a href="/ui/browse?order=tags&state={{ state }}&tag={{ tag }}">Tags</a></th>
|
||||
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Aktualisiert</a></th>
|
||||
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Updated</a></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'superseded' if r.superseded_by else '' }}">
|
||||
<td><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
|
||||
<td class="wrap"><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.state }}</td>
|
||||
<td>{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
|
||||
<td class="wrap">{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
|
||||
<td>{{ r.updated }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="empty">No notes match this filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ note.title }} bearbeiten — mdmem{% endblock %}
|
||||
{% block title %}Edit {{ note.title }} — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ note.title }} bearbeiten</h1>
|
||||
<h1>Edit <small>{{ note.title }}</small></h1>
|
||||
{% if conflict %}
|
||||
<div class="conflict">
|
||||
<h2>Konflikt</h2>
|
||||
<h2>Conflict</h2>
|
||||
<p>{{ conflict.message }}</p>
|
||||
{% set diff = conflict.diff %}
|
||||
{% include "partial_diff.html" with context %}
|
||||
<p>Speichere erneut, um deine Fassung über die aktuelle zu legen — das Formular trägt jetzt die neue Version.</p>
|
||||
<p class="meta">Save again to put your version on top of the current one — the form now carries the new version.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/edit" class="editor">
|
||||
@@ -16,14 +16,16 @@
|
||||
<label>Frontmatter (YAML)
|
||||
<textarea name="frontmatter" rows="10" spellcheck="false">{{ frontmatter }}</textarea>
|
||||
</label>
|
||||
<label>Text
|
||||
<label>Body
|
||||
<textarea name="body" rows="18" spellcheck="false"
|
||||
hx-post="/ui/preview" hx-trigger="keyup changed delay:400ms"
|
||||
hx-target="#preview">{% if conflict %}{{ conflict.yours }}{% else %}{{ note.body }}{% endif %}</textarea>
|
||||
</label>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
<a class="button" href="/ui/n/{{ note.id }}">Abbrechen</a>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit">Save</button>
|
||||
<a class="button" href="/ui/n/{{ note.id }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<h3>Vorschau</h3>
|
||||
<h3>Preview</h3>
|
||||
<div id="preview" class="body">{{ render_markdown(note.body, {}) | safe }}</div>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Fehler — mdmem{% endblock %}
|
||||
{% block title %}Error — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ error.error }}</h1>
|
||||
<p>{{ error.message }}</p>
|
||||
{% if error.details %}<pre>{{ error.details }}</pre>{% endif %}
|
||||
<p><a href="/ui/inbox">zurück zum Posteingang</a></p>
|
||||
<div class="notice danger">
|
||||
<div>
|
||||
<h2>{{ error.error }}</h2>
|
||||
<p>{{ error.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if error.details %}<pre class="diff">{{ error.details }}</pre>{% endif %}
|
||||
<p><a href="/ui/inbox">Back to the inbox</a></p>
|
||||
{% endblock %}
|
||||
@@ -2,66 +2,77 @@
|
||||
{% block title %}Health — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Health</h1>
|
||||
|
||||
{% if not health.embed_ok %}
|
||||
<div class="notice warn">
|
||||
<p><strong>Lexical only.</strong> The embedding backend did not answer, so search is running
|
||||
without the dense channel. Notes are still written and indexed; vectors fill in once it recovers.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Uniform number tiles: one label, one number, nothing else. Anything that needs
|
||||
a unit, a status or a model name goes in the cards below instead — mixing the
|
||||
two is what made this read as a ragged list rather than a dashboard. #}
|
||||
<div class="stats">
|
||||
{% for s in ['inbox', 'active', 'archive'] %}
|
||||
<dl class="stat {{ 'zero' if not health.counts.get(s) }}"><dt>{{ s }}</dt><dd>{{ health.counts.get(s, 0) }}</dd></dl>
|
||||
{% endfor %}
|
||||
<dl class="stat {{ 'zero' if not health.chunks }}"><dt>Chunks</dt><dd>{{ health.chunks }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.vec_pending else 'zero' }}"><dt>No vector</dt><dd>{{ health.vec_pending }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.unresolved_links else 'zero' }}"><dt>Unresolved</dt><dd>{{ health.unresolved_links }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.orphans else 'zero' }}"><dt>Orphans</dt><dd>{{ health.orphans }}</dd></dl>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h3>Bestand</h3>
|
||||
<ul>
|
||||
{% for s, n in health.counts.items() %}<li>{{ s }}: {{ n }}</li>{% endfor %}
|
||||
<li>Chunks: {{ health.chunks }}</li>
|
||||
<li>ohne Vektor: {{ health.vec_pending }}</li>
|
||||
</ul>
|
||||
<section class="card">
|
||||
<h2>Embeddings</h2>
|
||||
<dl class="kv">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'lexical only' }}</span></dd>
|
||||
<dt>Model</dt><dd>{{ health.embed_model }}</dd>
|
||||
<dt>Dimension</dt><dd>{{ health.embed_dim }}</dd>
|
||||
<dt>Last call</dt><dd>{{ health.embed_last_ms or '–' }} ms</dd>
|
||||
<dt>Errors</dt><dd>{{ health.embed_errors }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Embeddings</h3>
|
||||
<ul>
|
||||
<li>Modell: {{ health.embed_model }}</li>
|
||||
<li>Dimension: {{ health.embed_dim }}</li>
|
||||
<li>Status: <span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'nur lexikalisch' }}</span></li>
|
||||
<li>letzte Latenz: {{ health.embed_last_ms or '–' }} ms</li>
|
||||
<li>Fehler: {{ health.embed_errors }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Verlinkung</h3>
|
||||
<ul>
|
||||
<li>unaufgelöste Links: {{ health.unresolved_links }}</li>
|
||||
<li>verwaiste Notizen: {{ health.orphans }}</li>
|
||||
</ul>
|
||||
|
||||
<section class="card">
|
||||
<h2>Maintenance</h2>
|
||||
<div class="actions bare">
|
||||
<form method="post" action="/ui/health/reindex"><button type="submit">Refresh index</button></form>
|
||||
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Rebuild index</button></form>
|
||||
<form method="post" action="/ui/health/reembed"><button type="submit">Re-embed everything</button></form>
|
||||
<form method="post" action="/ui/health/eval"><button type="submit">Run eval</button></form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if eval_status.startswith("started:") %}
|
||||
<div class="notice ok"><p>Eval started — report: {{ eval_status[8:] }}</p></div>
|
||||
{% elif eval_status.startswith("missing:") %}
|
||||
<div class="notice warn"><p>No query set at {{ eval_status[8:] }} — label 50–100 real queries first (§11).</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% if health.secrets_in_vault %}
|
||||
<div class="conflict">
|
||||
<h3>Mögliche Zugangsdaten im Vault</h3>
|
||||
<h3>Possible credentials in the vault</h3>
|
||||
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/health/reindex"><button type="submit">Index auffrischen</button></form>
|
||||
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Index neu bauen</button></form>
|
||||
<form method="post" action="/ui/health/reembed"><button type="submit">Alles neu einbetten</button></form>
|
||||
<form method="post" action="/ui/health/eval"><button type="submit">Eval starten</button></form>
|
||||
</div>
|
||||
<div>
|
||||
{% if eval_status.startswith("started:") %}
|
||||
<p class="ok">Eval gestartet — Bericht: {{ eval_status[8:] }}</p>
|
||||
{% elif eval_status.startswith("missing:") %}
|
||||
<p class="warn">Kein Query-Set unter {{ eval_status[8:] }} — erst 50–100 Queries labeln (§11).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3>Unresolved links</h3>
|
||||
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">none</li>{% endfor %}</ul>
|
||||
|
||||
<h3>Unaufgelöste Links</h3>
|
||||
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
|
||||
<h3>Letzte MCP-Aufrufe</h3>
|
||||
<h3>Recent MCP calls</h3>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Zeit</th><th>Tool</th><th>Quelle</th><th>ok</th><th>ms</th><th>ids</th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Tool</th><th>Source</th><th>ok</th><th>ms</th><th class="wrap">ids</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in calls %}
|
||||
<tr><td>{{ c.ts }}</td><td>{{ c.tool }}</td><td>{{ c.get('source', '') }}</td><td>{{ c.get('ok', '') }}</td><td>{{ c.get('ms', '') }}</td>
|
||||
<td>{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
|
||||
{% else %}<tr><td colspan="6" class="empty">keine</td></tr>{% endfor %}
|
||||
<td class="wrap">{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
|
||||
{% else %}<tr><td colspan="6" class="empty">none</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,33 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Posteingang — mdmem{% endblock %}
|
||||
{% block title %}Inbox — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Posteingang <small>{{ items|length }} unbestätigt</small></h1>
|
||||
{% if not items %}<p class="empty">Nichts zu prüfen.</p>{% endif %}
|
||||
<h1>Inbox <small>{{ items|length }} awaiting review</small></h1>
|
||||
{% if not items %}<p class="empty">Nothing to review.</p>{% endif %}
|
||||
{% for item in items %}
|
||||
{% set n = item.note %}
|
||||
<article class="card" id="n-{{ n.id }}">
|
||||
<h2><a href="/ui/n/{{ n.id }}">{{ n.title }}</a>
|
||||
<span class="pill">{{ n.type }}</span>
|
||||
{% if item.target %}<span class="pill warn">Revision von „{{ item.target.title }}“</span>{% endif %}
|
||||
{% if n.supersede_chain %}<span class="pill warn">ersetzt {{ n.supersede_chain|length }}</span>{% endif %}
|
||||
{% if item.target %}<span class="pill warn">Revision of “{{ item.target.title }}”</span>{% endif %}
|
||||
{% if n.supersede_chain %}<span class="pill warn">replaces {{ n.supersede_chain|length }}</span>{% endif %}
|
||||
</h2>
|
||||
<p class="meta">
|
||||
beobachtet {{ n.observed_at or '–' }} ·
|
||||
Quelle {{ n.source.get('harness', 'unbekannt') if n.source else 'unbekannt' }}
|
||||
<span class="hint" title="selbstberichtet, nicht verifiziert">(selbstberichtet)</span>
|
||||
observed {{ n.observed_at or '–' }} ·
|
||||
source {{ n.source.get('harness', 'unknown') if n.source else 'unknown' }}
|
||||
<span class="hint" title="self-reported by the agent, not verified">(self-reported)</span>
|
||||
</p>
|
||||
{% if n.summary %}<p class="summary">{{ n.summary }}</p>{% endif %}
|
||||
<div class="body">{{ render_markdown(n.body, {}) | safe }}</div>
|
||||
{% if n.keywords %}<p class="kw">{% for k in n.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
|
||||
|
||||
{% if item.diff %}
|
||||
<h3>Änderung gegenüber der aktiven Notiz</h3>
|
||||
<h3>Change against the active note</h3>
|
||||
{% set diff = item.diff %}
|
||||
{% include "partial_diff.html" with context %}
|
||||
{% endif %}
|
||||
|
||||
{% if item.duplicates %}
|
||||
<h3>Mögliche Duplikate</h3>
|
||||
<h3>Possible duplicates</h3>
|
||||
<div class="dupes">
|
||||
{% for d in item.duplicates %}
|
||||
<div class="dupe">
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="body small">{{ render_markdown(d.body, {}) | safe }}</div>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/merge">
|
||||
<input type="hidden" name="target" value="{{ d.id }}">
|
||||
<button type="submit">in diese Notiz zusammenführen</button>
|
||||
<button type="submit">Merge into this note</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -44,19 +44,19 @@
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/n/{{ n.id }}/approve">
|
||||
<button class="primary" type="submit">Übernehmen</button>
|
||||
<button class="primary" type="submit">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/reject">
|
||||
<input type="text" name="reason" placeholder="Reason (optional)">
|
||||
<button class="danger" type="submit">Reject</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Bearbeiten & übernehmen</summary>
|
||||
<summary>Edit & approve</summary>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/approve">
|
||||
<textarea name="body" rows="8">{{ n.body }}</textarea>
|
||||
<button class="primary" type="submit">Bearbeitet übernehmen</button>
|
||||
<button class="primary" type="submit">Approve edited</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/reject">
|
||||
<input type="text" name="reason" placeholder="Grund (optional)">
|
||||
<button class="danger" type="submit">Ablehnen</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="pill">{{ note.type }}</span>
|
||||
<span class="pill {% if note.state == 'active' %}ok{% elif note.state == 'inbox' %}warn{% endif %}">{{ note.state }}</span>
|
||||
</h1>
|
||||
<p class="meta">id {{ note.id }} · Version {{ note.version[:12] }} · aktualisiert {{ note.updated }}</p>
|
||||
<p class="meta">id {{ note.id }} · version {{ note.version[:12] }} · updated {{ note.updated }}</p>
|
||||
{% if note.summary %}<p class="summary">{{ note.summary }}</p>{% endif %}
|
||||
<div class="body">{{ render_markdown(note.body, resolve) | safe }}</div>
|
||||
{% if note.keywords %}<p class="kw">{% for k in note.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
|
||||
@@ -14,64 +14,76 @@
|
||||
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h3>Ausgehende Links</h3>
|
||||
<h3>Outgoing links</h3>
|
||||
<ul>{% for l in note.links %}
|
||||
<li>{% if l.resolved %}<a href="/ui/n/{{ l.id }}">{{ l.title }}</a>{% else %}<span class="unresolved">{{ l.title }}</span>{% endif %} <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
{% else %}<li class="empty">none</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Backlinks</h3>
|
||||
<ul>{% for l in note.backlinks %}
|
||||
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
{% else %}<li class="empty">none</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Ersetzungskette</h3>
|
||||
<h3>Supersede chain</h3>
|
||||
<ul>{% for l in note.supersede_chain %}
|
||||
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
{% else %}<li class="empty">none</li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if note.flags %}
|
||||
<h3>Meldungen</h3>
|
||||
<h3>Flags</h3>
|
||||
<ul>{% for f in note.flags %}<li>{{ f.created }} · {{ f.source }} · {{ f.reason }}</li>{% endfor %}</ul>
|
||||
{% endif %}
|
||||
|
||||
<h3>Verlauf</h3>
|
||||
<h3>History</h3>
|
||||
<ul class="history">
|
||||
{% for h in history|reverse %}
|
||||
<li><button hx-get="/ui/n/{{ note.id }}/history/{{ h }}" hx-target="#diff-{{ loop.index }}">{{ h }}</button>
|
||||
<div id="diff-{{ loop.index }}"></div></li>
|
||||
{% else %}<li class="empty">keine früheren Fassungen</li>{% endfor %}
|
||||
{% else %}<li class="empty">no earlier versions</li>{% endfor %}
|
||||
</ul>
|
||||
{% if history %}
|
||||
<form class="history-compare" hx-get="/ui/n/{{ note.id }}/history-diff" hx-target="#history-compare-diff">
|
||||
<label>von <select name="a">
|
||||
<label>From <select name="a">
|
||||
{% for h in history %}<option value="{{ h }}" {% if loop.first %}selected{% endif %}>{{ h }}</option>{% endfor %}
|
||||
</select></label>
|
||||
<label>bis <select name="b">
|
||||
<option value="current" selected>aktuell</option>
|
||||
<label>To <select name="b">
|
||||
<option value="current" selected>current</option>
|
||||
{% for h in history|reverse %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
|
||||
</select></label>
|
||||
<button type="submit">Vergleichen</button>
|
||||
<button type="submit">Compare</button>
|
||||
</form>
|
||||
<div id="history-compare-diff"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/ui/n/{{ note.id }}/edit">Bearbeiten</a>
|
||||
{% if note.state == 'inbox' %}
|
||||
{# Review without a round trip: having come here from the inbox to fix the
|
||||
frontmatter, the decision belongs on this page too. Same route the inbox
|
||||
posts to, so revision merges and supersede chains behave identically. #}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/approve">
|
||||
<button class="primary" type="submit">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/n/{{ note.id }}/reject">
|
||||
<input type="text" name="reason" placeholder="Reason (optional)">
|
||||
<button class="danger" type="submit">Reject</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="button" href="/ui/n/{{ note.id }}/edit">Edit</a>
|
||||
{% if note.state != 'archive' %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archivieren</button></form>
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archive</button></form>
|
||||
{% else %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Wiederherstellen</button></form>
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Restore</button></form>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary class="danger">Endgültig löschen</summary>
|
||||
<summary class="danger">Delete permanently</summary>
|
||||
<form method="post" action="/ui/n/{{ note.id }}/delete">
|
||||
<p>Datei, Verlauf und Index werden entfernt. Zum Bestätigen die id eingeben:</p>
|
||||
<p class="meta">The file, its history and its index rows are removed. Type the id to confirm:</p>
|
||||
<input type="text" name="confirm" placeholder="{{ note.id }}">
|
||||
<button class="danger" type="submit">Löschen</button>
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Suche — mdmem{% endblock %}
|
||||
{% block title %}Search — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Suche</h1>
|
||||
<h1>Search</h1>
|
||||
<form method="get" action="/ui/search" class="filters">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Suchbegriff" autofocus>
|
||||
<select name="type">
|
||||
<option value="">alle Typen</option>
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Search notes…" aria-label="Query" autofocus>
|
||||
<select name="type" aria-label="Type">
|
||||
<option value="">All types</option>
|
||||
{% for t in ['fact','preference','decision','procedure','entity','event','note'] %}
|
||||
<option value="{{ t }}" {% if type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
|
||||
<select name="state">
|
||||
<option value="">inbox + aktiv</option>
|
||||
<option value="active" {% if state == 'active' %}selected{% endif %}>nur aktiv</option>
|
||||
<option value="archive" {% if state == 'archive' %}selected{% endif %}>inkl. Archiv</option>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag" aria-label="Tag">
|
||||
<select name="state" aria-label="Lifecycle state">
|
||||
<option value="">Inbox + active</option>
|
||||
<option value="inbox" {% if state == 'inbox' %}selected{% endif %}>Inbox only</option>
|
||||
<option value="active" {% if state == 'active' %}selected{% endif %}>Active only</option>
|
||||
<option value="archive" {% if state == 'archive' %}selected{% endif %}>Archive only</option>
|
||||
</select>
|
||||
<input type="text" name="since" value="{{ since }}" placeholder="seit (ISO)">
|
||||
<input type="text" name="until" value="{{ until }}" placeholder="bis (ISO)">
|
||||
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> ersetzte zeigen</label>
|
||||
<button type="submit">Suchen</button>
|
||||
<input type="text" name="since" value="{{ since }}" placeholder="Since (ISO)" aria-label="Since">
|
||||
<input type="text" name="until" value="{{ until }}" placeholder="Until (ISO)" aria-label="Until">
|
||||
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> Show superseded</label>
|
||||
<button class="primary" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{% if resp is not none %}
|
||||
<p class="meta">{{ resp.results|length }} Treffer · Fusion {{ resp.fusion }}{% if resp.truncated %} · gekürzt{% endif %}</p>
|
||||
<p class="meta">{{ resp.results|length }} hits · fusion {{ resp.fusion }}{% if resp.truncated %} · truncated{% endif %}</p>
|
||||
{% for r in resp.results %}
|
||||
<article class="card">
|
||||
<h2><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>
|
||||
<span class="pill">{{ r.type }}</span>
|
||||
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'geprüft' if r.verified else 'ungeprüft' }}</span>
|
||||
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'reviewed' if r.verified else 'unreviewed' }}</span>
|
||||
</h2>
|
||||
<p class="snippet">{{ r.snippet }}</p>
|
||||
<p class="meta" title="lex_rank/sem_rank/cosine/fused">
|
||||
@@ -43,6 +44,6 @@
|
||||
</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% if not resp.results %}<p class="empty">Keine Treffer.</p>{% endif %}
|
||||
{% if not resp.results %}<p class="empty">No hits.</p>{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Smoke tests for the committed Kubernetes overlay (PLAN §13a).
|
||||
|
||||
The overlay is not directly buildable — `secret.yaml` is SOPS-encrypted and kept out
|
||||
of the repo, and the image digest plus the PocketID client id are release-time
|
||||
placeholders. That is deliberate, but until now nothing checked that the rest of it
|
||||
stays coherent: a renamed environment variable, a probe that points at a port the
|
||||
container does not expose, or a resource dropped from the kustomization would only
|
||||
surface on a cluster.
|
||||
The overlay is not directly buildable: `secret.yaml` is SOPS-encrypted and kept out
|
||||
of the repo. The image digest and the PocketID client id are filled in, but both are
|
||||
release-time values — the digest is bumped by hand on every build (§13a.2) — so their
|
||||
placeholder spellings stay on the allowlist below rather than being treated as a
|
||||
defect if they reappear.
|
||||
|
||||
Nothing else checked that the overlay stayed coherent: a renamed environment
|
||||
variable, a probe pointing at a port the container does not expose, or a resource
|
||||
dropped from the kustomization would only surface on a cluster.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -387,3 +387,112 @@ def test_store_vectors_requires_chunk_hash_match(idx):
|
||||
assert stored == 0
|
||||
assert db.read().execute("SELECT count(*) FROM chunks_vec").fetchone()[0] == 0
|
||||
assert db.read().execute("SELECT vec_pending FROM chunks").fetchone()[0] == 1
|
||||
|
||||
|
||||
# -- renames must not orphan the links that still spell the old title --------
|
||||
def _link_dst(db, src_id):
|
||||
row = db.read().execute("SELECT dst FROM links WHERE src=? AND rel='links'", (src_id,)).fetchone()
|
||||
return row["dst"] if row else None
|
||||
|
||||
|
||||
def test_renaming_a_note_keeps_inbound_links_resolved(idx):
|
||||
"""Resolution re-reads the link text on every reindex, so before the alias
|
||||
table a rename turned every inbound `[[Old Title]]` into an unresolved link
|
||||
the next time its source happened to be indexed — silently, and long after
|
||||
the rename itself."""
|
||||
indexer, vault, db = idx
|
||||
target = Note.new(title="Old Title", body="the target", type="fact")
|
||||
vault.write(target)
|
||||
indexer.index_note(vault.read(vault.find(target.id)))
|
||||
|
||||
source = Note.new(title="Source", body="see [[Old Title]] for detail", type="note")
|
||||
vault.write(source)
|
||||
indexer.index_note(vault.read(vault.find(source.id)))
|
||||
assert _link_dst(db, source.id) == target.id
|
||||
|
||||
renamed = vault.read(vault.find(target.id))
|
||||
renamed.set("title", "New Title")
|
||||
vault.write(renamed)
|
||||
indexer.index_note(vault.read(vault.find(target.id)))
|
||||
|
||||
# the inbound source is reindexed for some unrelated reason later on
|
||||
indexer.index_note(vault.read(vault.find(source.id)))
|
||||
assert _link_dst(db, source.id) == target.id, "the rename orphaned the inbound link"
|
||||
|
||||
# and a full rebuild does not detonate them all at once either
|
||||
indexer.scan(full=True)
|
||||
assert _link_dst(db, source.id) == target.id
|
||||
|
||||
|
||||
def test_a_new_note_claiming_a_freed_title_beats_the_alias(idx):
|
||||
"""The alias lookup runs last: link text names whatever holds that title
|
||||
now, not whatever held it first."""
|
||||
indexer, vault, db = idx
|
||||
first = Note.new(title="Shared Title", body="the original", type="fact")
|
||||
vault.write(first)
|
||||
indexer.index_note(vault.read(vault.find(first.id)))
|
||||
|
||||
renamed = vault.read(vault.find(first.id))
|
||||
renamed.set("title", "Renamed Away")
|
||||
vault.write(renamed)
|
||||
indexer.index_note(vault.read(vault.find(first.id)))
|
||||
|
||||
second = Note.new(title="Shared Title", body="the newcomer", type="fact")
|
||||
vault.write(second)
|
||||
indexer.index_note(vault.read(vault.find(second.id)))
|
||||
|
||||
source = Note.new(title="Source", body="points at [[Shared Title]]", type="note")
|
||||
vault.write(source)
|
||||
indexer.index_note(vault.read(vault.find(source.id)))
|
||||
assert _link_dst(db, source.id) == second.id
|
||||
|
||||
|
||||
def test_deleting_a_note_drops_its_former_titles(idx):
|
||||
"""Otherwise a dead ULID keeps winning resolution for a title nothing holds."""
|
||||
indexer, vault, db = idx
|
||||
target = Note.new(title="Doomed", body="x", type="fact")
|
||||
vault.write(target)
|
||||
indexer.index_note(vault.read(vault.find(target.id)))
|
||||
indexer.remove(target.id)
|
||||
assert db.read().execute(
|
||||
"SELECT count(*) FROM note_titles WHERE note_id=?", (target.id,)
|
||||
).fetchone()[0] == 0
|
||||
|
||||
source = Note.new(title="Source", body="points at [[Doomed]]", type="note")
|
||||
vault.write(source)
|
||||
indexer.index_note(vault.read(vault.find(source.id)))
|
||||
assert _link_dst(db, source.id) == "unresolved:Doomed"
|
||||
|
||||
|
||||
def test_existing_indexes_are_backfilled_so_the_first_rename_is_safe(tmp_path):
|
||||
"""An upgraded index only learns a title when the note is next reindexed, and
|
||||
an unchanged note never is — so without a backfill the first rename after the
|
||||
upgrade would still break its inbound links."""
|
||||
vault = Vault(tmp_path / "vault", tmp_path / ".history")
|
||||
vault.ensure_dirs()
|
||||
db = Database(tmp_path / "index.db", dim=8, trigram=True)
|
||||
db.open()
|
||||
indexer = Indexer(db, vault)
|
||||
target = Note.new(title="Before Upgrade", body="x", type="fact")
|
||||
vault.write(target)
|
||||
indexer.index_note(vault.read(vault.find(target.id)))
|
||||
source = Note.new(title="Source", body="see [[Before Upgrade]]", type="note")
|
||||
vault.write(source)
|
||||
indexer.index_note(vault.read(vault.find(source.id)))
|
||||
# simulate an index that predates the alias table
|
||||
with db.write() as w:
|
||||
w.execute("DELETE FROM note_titles")
|
||||
db.close()
|
||||
|
||||
db2 = Database(tmp_path / "index.db", dim=8, trigram=True)
|
||||
db2.open() # the migration backfills from the notes table
|
||||
try:
|
||||
indexer2 = Indexer(db2, vault)
|
||||
renamed = vault.read(vault.find(target.id))
|
||||
renamed.set("title", "After Upgrade")
|
||||
vault.write(renamed)
|
||||
indexer2.index_note(vault.read(vault.find(target.id)))
|
||||
indexer2.index_note(vault.read(vault.find(source.id)))
|
||||
assert _link_dst(db2, source.id) == target.id
|
||||
finally:
|
||||
db2.close()
|
||||
+124
-1
@@ -260,7 +260,7 @@ async def test_a_valid_session_gets_in(app_and_svc):
|
||||
cookie = codec.issue(Principal(sub="u1", name="Markus", email=""))
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.get("/ui/inbox")
|
||||
assert r.status_code == 200 and "Posteingang" in r.text
|
||||
assert r.status_code == 200 and "Inbox" in r.text
|
||||
|
||||
|
||||
async def test_probes_and_metrics_stay_open(app_and_svc):
|
||||
@@ -295,6 +295,17 @@ async def test_security_headers_are_set(app_and_svc):
|
||||
r = await c.get("/ui/inbox")
|
||||
assert r.headers["content-security-policy"].startswith("default-src 'self'")
|
||||
assert r.headers["x-frame-options"] == "DENY"
|
||||
# Per the Fetch standard these policies make a browser send `Origin: null`
|
||||
# on an HTML form submission. Every write in this UI is a form POST and the
|
||||
# CSRF guard fails closed, so shipping one of them silently refuses every
|
||||
# write in a real browser while every test that sets Origin by hand passes.
|
||||
assert r.headers["referrer-policy"] not in {
|
||||
"no-referrer",
|
||||
"no-referrer-when-downgrade",
|
||||
"strict-origin",
|
||||
"strict-origin-when-cross-origin",
|
||||
}
|
||||
assert r.headers["referrer-policy"] == "same-origin"
|
||||
|
||||
|
||||
async def test_post_from_a_foreign_origin_is_refused(app_and_svc):
|
||||
@@ -416,3 +427,115 @@ async def test_auth_disabled_is_loudly_logged_and_lets_requests_through(settings
|
||||
assert (await c.get("/ui/inbox")).status_code == 200
|
||||
finally:
|
||||
await svc.stop()
|
||||
|
||||
|
||||
async def test_post_survives_a_proxy_that_forwards_the_default_port(app_and_svc):
|
||||
"""Browsers omit the default port from Origin; Traefik forwards Host with it.
|
||||
Comparing the two raw refused every state-changing POST in the deployed UI."""
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
w = await svc.write_note(title="T", body="v1", type="fact")
|
||||
|
||||
for host in (
|
||||
"memory.cloud.traunseenet.com:443", # proxy keeps the default port
|
||||
"MEMORY.Cloud.Traunseenet.com", # case differs
|
||||
"memory.cloud.traunseenet.com", # the plain form still works
|
||||
):
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.post(
|
||||
f"/ui/n/{w['id']}/state",
|
||||
data={"state": "archive"},
|
||||
headers={"origin": "https://memory.cloud.traunseenet.com", "host": host},
|
||||
)
|
||||
assert r.status_code != 403, f"host {host!r} was refused"
|
||||
|
||||
|
||||
async def test_a_foreign_host_is_still_refused_with_an_allowed_origin(app_and_svc):
|
||||
"""The normalisation must not weaken what the check exists for (M-10)."""
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
w = await svc.write_note(title="T2", body="v1", type="fact")
|
||||
for host in ("evil.example", "evil.example:443", "memory.cloud.traunseenet.com.evil.example"):
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.post(
|
||||
f"/ui/n/{w['id']}/state",
|
||||
data={"state": "archive"},
|
||||
headers={"origin": "https://memory.cloud.traunseenet.com", "host": host},
|
||||
)
|
||||
assert r.status_code == 403, f"host {host!r} was accepted"
|
||||
|
||||
|
||||
async def test_a_refused_write_is_logged_with_what_was_compared(app_and_svc, caplog):
|
||||
"""uvicorn's access log is off, so a silent 403 was only visible in the
|
||||
browser — a proxy spelling the authority differently looked like a UI bug."""
|
||||
import logging as _logging
|
||||
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.post(
|
||||
"/ui/n/whatever/state",
|
||||
data={"state": "archive"},
|
||||
headers={"origin": "https://evil.example", "host": "evil.example"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
record = next(r for r in caplog.records if "CSRF" in r.message)
|
||||
fields = record.fields
|
||||
assert fields["origin"] == "https://evil.example"
|
||||
assert fields["host"] == "evil.example"
|
||||
assert fields["reason"] == "origin not allowed"
|
||||
assert "https://memory.cloud.traunseenet.com" in fields["allowed"]
|
||||
assert fields["path"] == "/ui/n/whatever/state"
|
||||
|
||||
|
||||
async def test_a_write_with_no_origin_header_says_so_in_the_log(app_and_svc, caplog):
|
||||
import logging as _logging
|
||||
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="https://memory.cloud.traunseenet.com",
|
||||
cookies={SESSION_COOKIE: cookie},
|
||||
) as c:
|
||||
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
|
||||
r = await c.post("/ui/n/whatever/state", data={"state": "archive"})
|
||||
assert r.status_code == 403
|
||||
record = next(r for r in caplog.records if "CSRF" in r.message)
|
||||
assert record.fields["reason"] == "no Origin or Referer"
|
||||
|
||||
|
||||
async def test_an_opaque_origin_is_refused_and_named_in_the_log(app_and_svc, caplog):
|
||||
"""`Origin: null` is what a browser sends for a form POST under
|
||||
Referrer-Policy: no-referrer. Refusing it is right, but the log has to say
|
||||
*why* — it means something stripped the origin, not that the caller is on
|
||||
the wrong host."""
|
||||
import logging as _logging
|
||||
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
with caplog.at_level(_logging.WARNING, logger="mdmem.ui"):
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.post(
|
||||
"/ui/n/whatever/state", data={"state": "archive"}, headers={"origin": "null"}
|
||||
)
|
||||
assert r.status_code == 403
|
||||
record = next(r for r in caplog.records if "CSRF" in r.message)
|
||||
assert "opaque origin" in record.fields["reason"]
|
||||
|
||||
|
||||
async def test_a_real_browser_form_post_is_accepted(app_and_svc):
|
||||
"""The end-to-end shape a browser actually sends for a same-origin form POST
|
||||
under the policy this app ships: a real Origin, no Referer."""
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
w = await svc.write_note(title="Form post", body="v1", type="fact")
|
||||
async with _client(app, cookie) as c:
|
||||
r = await c.post(
|
||||
f"/ui/n/{w['id']}/approve",
|
||||
data={},
|
||||
headers={"origin": "https://memory.cloud.traunseenet.com"},
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert svc.get_note(w["id"]).state == "active"
|
||||
+47
-6
@@ -80,7 +80,7 @@ async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
|
||||
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
|
||||
|
||||
page = await client.get("/ui/inbox")
|
||||
assert "Revision von" in page.text
|
||||
assert "Revision of" in page.text
|
||||
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
|
||||
|
||||
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
|
||||
@@ -101,7 +101,7 @@ async def test_duplicates_are_offered_for_merging(ui):
|
||||
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
|
||||
|
||||
page = await client.get("/ui/inbox")
|
||||
assert "Mögliche Duplikate" in page.text
|
||||
assert "Possible duplicates" in page.text
|
||||
|
||||
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
|
||||
assert svc.get_note(second["id"]).state == "archive"
|
||||
@@ -118,7 +118,7 @@ async def test_note_page_shows_links_backlinks_and_history(ui):
|
||||
|
||||
page = await client.get(f"/ui/n/{src['id']}")
|
||||
assert "evo-x2" in page.text and "Backlinks" in page.text
|
||||
assert "Verlauf" in page.text and "keine früheren Fassungen" not in page.text
|
||||
assert "History" in page.text and "no earlier versions" not in page.text
|
||||
|
||||
back = await client.get(f"/ui/n/{target['id']}")
|
||||
assert "llama-swap" in back.text
|
||||
@@ -153,7 +153,7 @@ async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
|
||||
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "Konflikt" in r.text
|
||||
assert "Conflict" in r.text
|
||||
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
|
||||
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
|
||||
|
||||
@@ -396,7 +396,7 @@ async def test_health_page_shows_the_audit_source_and_eval_action(ui):
|
||||
svc.audit.record(tool="memory_write", args="deadbeef", source="mcp", ok=True, ids=[], ms=3.0)
|
||||
r = await client.get("/ui/health")
|
||||
assert "mcp" in r.text
|
||||
assert "Eval starten" in r.text
|
||||
assert "Run eval" in r.text
|
||||
|
||||
|
||||
async def test_run_eval_is_auth_and_csrf_protected_like_the_other_health_actions(ui):
|
||||
@@ -460,7 +460,7 @@ async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui
|
||||
|
||||
# and the health page renders the status rather than swallowing it
|
||||
page = await client.get("/ui/health", params={"eval": f"started:{data_dir.name}.json"})
|
||||
assert page.status_code == 200 and "Eval gestartet" in page.text
|
||||
assert page.status_code == 200 and "Eval started" in page.text
|
||||
|
||||
|
||||
async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
|
||||
@@ -476,3 +476,44 @@ async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
|
||||
r = await client.get("/ui/search", params={"q": "Bifrost routet Modelle", "state": "inbox"})
|
||||
assert r.status_code == 200
|
||||
assert needle["id"] in r.text
|
||||
|
||||
|
||||
async def test_an_inbox_note_can_be_reviewed_from_its_own_page(ui):
|
||||
"""Fixing an LLM's frontmatter happens on the note page; without approve here
|
||||
the owner has to navigate back to the inbox to act on what they just fixed."""
|
||||
client, svc = ui
|
||||
w = await svc.write_note(title="Needs a fix", body="v1", type="fact")
|
||||
|
||||
page = await client.get(f"/ui/n/{w['id']}")
|
||||
assert "Approve" in page.text and "Reject" in page.text
|
||||
|
||||
r = await client.post(f"/ui/n/{w['id']}/approve", data={})
|
||||
assert r.status_code == 303
|
||||
assert svc.get_note(w["id"]).state == "active"
|
||||
|
||||
# and an active note offers no review buttons, only archive/edit/delete
|
||||
page = await client.get(f"/ui/n/{w['id']}")
|
||||
assert "/approve" not in page.text and "/reject" not in page.text
|
||||
|
||||
|
||||
async def test_frontmatter_fixed_on_the_note_page_survives_approval(ui):
|
||||
"""The whole point of the button: correct what the agent got wrong, then
|
||||
accept it, without the fix being lost on the way."""
|
||||
client, svc = ui
|
||||
w = await svc.write_note(title="Wrong type", body="v1", type="note", tags=["bogus"])
|
||||
note = svc.get_note(w["id"])
|
||||
fm = note.to_text().split("---\n")[1].replace("type: note", "type: fact")
|
||||
fm = fm.replace("- bogus", "- homelab")
|
||||
|
||||
saved = await client.post(
|
||||
f"/ui/n/{w['id']}/edit",
|
||||
data={"body": "v1 corrected", "frontmatter": fm, "expected_version": note.file_hash},
|
||||
)
|
||||
assert saved.status_code in (200, 303)
|
||||
|
||||
assert (await client.post(f"/ui/n/{w['id']}/approve", data={})).status_code == 303
|
||||
approved = svc.get_note(w["id"])
|
||||
assert approved.state == "active"
|
||||
assert approved.type == "fact"
|
||||
assert approved.tags == ["homelab"]
|
||||
assert approved.body.strip() == "v1 corrected"
|
||||
Reference in new issue
Block a user