snotes updates, actions, attachments, easy user space

This commit is contained in:
2026-09-22 17:31:55 -07:00
parent ce30c6f4f7
commit abfbf3de1f
21 changed files with 669 additions and 53 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -18,7 +18,7 @@ REMOTE_PASS=
SECRET_KEY=change-me-in-production
# Subnet(s) treated as "local" (no password), comma-separated
LAN_CIDRS=192.168.1.0/24
LAN_CIDRS=192.168.1.0/24,127.0.0.1/32,::1/128
HOST=0.0.0.0
PORT=8000

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

BIN
assets/snotes-new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

75
backend/actions.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import re
import tomllib
from pathlib import Path
from urllib.parse import quote
import httpx
import structlog
from backend.config import settings
logger = structlog.get_logger()
ACTION_LINE_RE = re.compile(r"^/(?P<kw>[A-Za-z0-9_-]+)\s*:?\s*(?P<rest>.*)$")
_actions_cache: dict[str, dict] | None = None
def load_actions(reload: bool = False) -> dict[str, dict]:
global _actions_cache
if _actions_cache is not None and not reload:
return _actions_cache
actions: dict[str, dict] = {}
path = Path(settings.actions_file)
if path.is_file():
data = tomllib.loads(path.read_text(encoding="utf-8"))
for item in data.get("actions", []):
name = item.get("name")
uri = item.get("uri")
if name and uri:
actions[name] = {"uri": uri, "link": item.get("link", uri)}
_actions_cache = actions
return actions
async def _perform(uri: str) -> bool:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(uri)
resp.raise_for_status()
return True
except Exception:
logger.exception("action_failed", uri=uri)
return False
async def process_actions(body: str) -> str:
actions = load_actions()
if not actions:
return body
out_lines: list[str] = []
for line in body.split("\n"):
m = ACTION_LINE_RE.match(line)
action = actions.get(m.group("kw")) if m else None
if action is None:
out_lines.append(line)
continue
rest = m.group("rest").strip()
if not rest:
out_lines.append(line)
continue
encoded = quote(rest)
uri = action["uri"].replace("#string1#", encoded)
link = action["link"].replace("#string1#", encoded)
if await _perform(uri):
out_lines.append(f"[{rest}]({link})")
else:
out_lines.append(line)
return "\n".join(out_lines)

View File

@@ -16,6 +16,9 @@ class Settings(BaseSettings):
# SQLite metadata + search index (local disk)
database_path: str = "snotes.db"
# TOML file defining slash "actions" (see snotes.toml)
actions_file: str = "snotes.toml"
# Fixed list of usernames, comma-separated (seeded at startup)
users: str = "schmeeve,ilya"
@@ -29,7 +32,7 @@ class Settings(BaseSettings):
secret_key: str = "change-me-in-production"
# Subnet(s) considered "local" and allowed in without a password
lan_cidrs: str = "192.168.1.0/24"
lan_cidrs: str = "192.168.1.0/24,127.0.0.1/32,::1/128"
host: str = "0.0.0.0"
port: int = 8000

View File

@@ -69,8 +69,9 @@ async def init_db() -> None:
async def seed_users() -> None:
names = list(dict.fromkeys([*settings.user_list, settings.common_space]))
async with aiosqlite.connect(settings.database_path) as db:
for name in settings.user_list:
for name in names:
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
await db.commit()

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from contextlib import asynccontextmanager
import structlog
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -49,6 +49,12 @@ def current_user(request: Request) -> str:
return username or settings.user_list[0]
def valid_username(username: str) -> str:
if username not in settings.user_list and username != settings.common_space:
raise HTTPException(status_code=404, detail="unknown user")
return username
# ---------------------------------------------------------------------------
# SPA + static
# ---------------------------------------------------------------------------
@@ -61,7 +67,7 @@ async def index() -> FileResponse:
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
return FileResponse("static/snotes-main.png")
return FileResponse("static/snotes-new.png")
# ---------------------------------------------------------------------------
@@ -114,6 +120,8 @@ async def login(body: LoginRequest) -> Response:
@app.post("/api/notes")
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
username = current_user(request)
if body.username:
username = valid_username(body.username)
if not body.body.strip():
raise HTTPException(status_code=400, detail="empty note")
try:
@@ -148,7 +156,13 @@ async def get_note(note_id: str, _: None = Depends(require_access)):
async def update_note(
note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access)
):
note = await notes.update_note(note_id, body.body, ip=client_ip(request), ua=client_ua(request))
note = await notes.update_note(
note_id,
body.body,
ip=client_ip(request),
ua=client_ua(request),
username=valid_username(body.username) if body.username else None,
)
if note is None:
raise HTTPException(status_code=404, detail="note not found")
return note
@@ -168,10 +182,45 @@ async def note_history(note_id: str, _: None = Depends(require_access)):
# ---------------------------------------------------------------------------
# Tags / search
# Attachments
# ---------------------------------------------------------------------------
@app.post("/api/attachments")
async def upload_attachment(
request: Request,
file: UploadFile = File(...),
username: str | None = Form(None),
_: None = Depends(require_access),
):
owner = current_user(request)
if username:
owner = valid_username(username)
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="empty file")
filename = await notes.store_attachment(owner, file.filename or "file", data)
return {
"url": f"/api/attachments/{owner}/{filename}",
"name": file.filename or filename,
"path": f"{owner}/attachments/{filename}",
}
@app.get("/api/attachments/{username}/{filename}")
async def get_attachment(username: str, filename: str, _: None = Depends(require_access)):
from pathlib import Path
full = Path(settings.data_dir) / username / "attachments" / filename
if not full.is_file():
raise HTTPException(status_code=404, detail="not found")
return FileResponse(full)
# ---------------------------------------------------------------------------
# Tags / search
# ---------------------------------------------------------------------------
@app.get("/api/tags")
async def tags(_: None = Depends(require_access)):
return await notes.tag_counts()

View File

@@ -9,10 +9,12 @@ class UserSelect(BaseModel):
class NoteCreate(BaseModel):
body: str
username: str | None = None
class NoteUpdate(BaseModel):
body: str
username: str | None = None
class LoginRequest(BaseModel):

View File

@@ -5,6 +5,7 @@ import uuid
import aiosqlite
from backend import actions
from backend.config import settings
from backend.database import get_user_id
@@ -75,6 +76,17 @@ def new_note_id() -> str:
return uuid.uuid4().hex
async def store_attachment(username: str, original_name: str, data: bytes) -> str:
from pathlib import Path
ext = re.sub(r"[^A-Za-z0-9.]", "", Path(original_name or "").suffix)[:16]
filename = new_note_id() + ext
full = Path(settings.data_dir) / username / "attachments" / filename
full.parent.mkdir(parents=True, exist_ok=True)
full.write_bytes(data)
return filename
# ---------------------------------------------------------------------------
# Note CRUD (metadata + tags + FTS)
# ---------------------------------------------------------------------------
@@ -86,6 +98,7 @@ async def create_note(
ip: str | None = None,
ua: str | None = None,
) -> dict:
body = await actions.process_actions(body)
title = extract_title(body)
tags = extract_tags(body)
note_id = new_note_id()
@@ -123,7 +136,9 @@ async def update_note(
body: str,
ip: str | None = None,
ua: str | None = None,
username: str | None = None,
) -> dict | None:
body = await actions.process_actions(body)
title = extract_title(body)
tags = extract_tags(body)
@@ -138,14 +153,31 @@ async def update_note(
if row is None:
return None
await write_note_file(row["username"], note_id, body)
await db.execute(
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
(title, note_id),
)
new_username = username or row["username"]
user_id = row["user_id"]
rel = row["path"]
if new_username != row["username"]:
new_user_id = await get_user_id(db, new_username)
if new_user_id is None:
raise ValueError(f"unknown user: {new_username}")
await delete_note_file(row["path"])
rel = await write_note_file(new_username, note_id, body)
user_id = new_user_id
await db.execute(
"UPDATE notes SET title = ?, path = ?, user_id = ?,"
" updated_at = datetime('now') WHERE id = ?",
(title, rel, user_id, note_id),
)
else:
await write_note_file(row["username"], note_id, body)
await db.execute(
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
(title, note_id),
)
await _set_tags(db, note_id, tags)
await _index_note(db, note_id, row["user_id"], title, body)
await _record_history(db, note_id, row["user_id"], "update", ip, ua)
await _index_note(db, note_id, user_id, title, body)
await _record_history(db, note_id, user_id, "update", ip, ua)
await db.commit()
return await get_note(note_id)

View File

@@ -11,6 +11,7 @@ dependencies = [
"httpx>=0.27.0",
"structlog>=25.0.0",
"itsdangerous>=2.2.0",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]

BIN
snotes.db

Binary file not shown.

9
snotes.toml Normal file
View File

@@ -0,0 +1,9 @@
[[actions]]
name = "task"
uri = "http://tasky.loc/new?title=#string1#"
link = "http://tasky.loc/?title=#string1#"
[[actions]]
name = "foo"
url = "http://homepage.loc:3000/"
link = "http://homepage.loc:3000/"

View File

@@ -1,14 +1,96 @@
:root {
--bg: #f5f7fb;
--surface: #ffffff;
--border: #e4e8f0;
--text: #1b2430;
--muted: #6b7688;
--primary: #3b82f6;
--primary-dark: #2563eb;
--danger: #ef4444;
--radius: 10px;
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
--code-bg: #1e293b;
--code-fg: #e2e8f0;
}
/* ── Dark (default) ──────────────────────────────────── */
html, [data-theme="dark"] {
--bg: #1a1b1e; --surface: #26272b; --border: #3a3b40;
--text: #e4e4e7; --muted: #9ca3af;
--primary: #7dd3fc; --primary-bg: #0c4a6e; --primary-dark: #075985; --on-primary: #7dd3fc;
--danger: #f87171; --input-bg: #1a1b1e; --bg-hover: #2b2c31;
}
/* ── Light ───────────────────────────────────────────── */
[data-theme="light"] {
--bg: #f5f7fb; --surface: #ffffff; --border: #e4e8f0;
--text: #1b2430; --muted: #6b7688;
--primary: #3b82f6; --primary-bg: #3b82f6; --primary-dark: #2563eb; --on-primary: #ffffff;
--danger: #ef4444; --input-bg: #ffffff; --bg-hover: #f3f5f9;
}
/* ── Summer ──────────────────────────────────────────── */
[data-theme="summer"] {
--bg: #fff8f0; --surface: #fff0e0; --border: #ffd0a0;
--text: #4a3528; --muted: #9a8070;
--primary: #f08040; --primary-bg: #e06830; --primary-dark: #c05020; --on-primary: #fff8f0;
--danger: #c04030; --input-bg: #ffffff; --bg-hover: #ffe0c0;
}
/* ── Max Summer ───────────────────────────────────────── */
[data-theme="max-summer"] {
--bg: #fffdf5; --surface: #fff9e8; --border: #ffe082;
--text: #212121; --muted: #757575;
--primary: #e53935; --primary-bg: #d32f2f; --primary-dark: #b71c1c; --on-primary: #fffdf5;
--danger: #c62828; --input-bg: #fffefa; --bg-hover: #fff3c8;
}
/* ── Autumn ──────────────────────────────────────────── */
[data-theme="autumn"] {
--bg: #f4f1ec; --surface: #eae5de; --border: #d4c8bc;
--text: #3d3228; --muted: #8a7a6a;
--primary: #d59b6a; --primary-bg: #d59b6a; --primary-dark: #c0834a; --on-primary: #f4f1ec;
--danger: #c04040; --input-bg: #ffffff; --bg-hover: #e0d8ce;
}
/* ── Dracula ─────────────────────────────────────────── */
[data-theme="dracula"] {
--bg: #0c0c1e; --surface: #12122a; --border: #222;
--text: #eee; --muted: #aaa;
--primary: #7c6af7; --primary-bg: #4a3a9a; --primary-dark: #6a5aba; --on-primary: #eee;
--danger: #f87272; --input-bg: #1a1a3a; --bg-hover: #2a2a4e;
}
/* ── Nord ────────────────────────────────────────────── */
[data-theme="nord"] {
--bg: #2e3440; --surface: #3b4252; --border: #4c566a;
--text: #eceff4; --muted: #b0b9ca;
--primary: #88c0d0; --primary-bg: #5e81ac; --primary-dark: #81a1c1; --on-primary: #eceff4;
--danger: #bf616a; --input-bg: #3b4252; --bg-hover: #434c5e;
}
/* ── Tokyo Night ─────────────────────────────────────── */
[data-theme="tokyo-night"] {
--bg: #1a1b26; --surface: #1f2335; --border: #2f3549;
--text: #c0caf5; --muted: #787c99;
--primary: #7aa2f7; --primary-bg: #3d59a1; --primary-dark: #5470b8; --on-primary: #a9b1d6;
--danger: #f7768e; --input-bg: #1f2335; --bg-hover: #292e42;
}
/* ── Catppuccin ──────────────────────────────────────── */
[data-theme="catppuccin"] {
--bg: #1e1e2e; --surface: #26263a; --border: #313150;
--text: #cdd6f4; --muted: #9399b2;
--primary: #cba6f7; --primary-bg: #7350a0; --primary-dark: #8b6bc0; --on-primary: #cdd6f4;
--danger: #f38ba8; --input-bg: #26263a; --bg-hover: #313150;
}
/* ── One Dark ────────────────────────────────────────── */
[data-theme="one-dark"] {
--bg: #21252b; --surface: #282c34; --border: #333842;
--text: #dcdfe4; --muted: #9196a0;
--primary: #61afef; --primary-bg: #2b5780; --primary-dark: #3a6ea0; --on-primary: #61afef;
--danger: #e06c75; --input-bg: #282c34; --bg-hover: #333842;
}
/* ── Sunset ──────────────────────────────────────────── */
[data-theme="sunset"] {
--bg: #1c1018; --surface: #251420; --border: #352030;
--text: #fce7f3; --muted: #b090a0;
--primary: #f472b6; --primary-bg: #831843; --primary-dark: #9d174d; --on-primary: #f9a8d4;
--danger: #f87171; --input-bg: #251420; --bg-hover: #352030;
}
* { box-sizing: border-box; }
@@ -20,6 +102,7 @@ body {
color: var(--text);
font-size: 15px;
line-height: 1.5;
transition: background 0.2s, color 0.2s;
}
.muted { color: var(--muted); }
@@ -46,25 +129,27 @@ body {
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
background: var(--input-bg);
color: var(--text);
}
.search:focus { outline: 2px solid var(--primary); background: #fff; }
.search:focus { outline: 2px solid var(--primary); background: var(--input-bg); }
.topbar-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.user-select { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
.user-select { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); }
.btn {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn:hover { background: #f3f5f9; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn:hover { background: var(--bg-hover); }
.btn.primary { background: var(--primary-bg); border-color: var(--primary-bg); color: var(--on-primary); }
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.ghost { background: transparent; border-color: transparent; }
@@ -97,7 +182,7 @@ body {
.tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-chip {
background: #eef4ff;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: none;
border-radius: 16px;
@@ -105,14 +190,14 @@ body {
font-size: 13px;
cursor: pointer;
}
.tag-chip:hover { background: #dbe7ff; }
.tag-chip:hover { background: color-mix(in srgb, var(--primary) 22%, transparent); }
.editor-wrap { max-width: 900px; margin: 20px auto; padding: 0 20px; }
.editor-topbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.spacer { flex: 1; }
.toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; }
.tool-btn { padding: 6px 10px; border: none; background: transparent; border-radius: 6px; cursor: pointer; font-size: 13px; }
.tool-btn:hover { background: #f3f5f9; }
.tool-btn { padding: 6px 10px; border: none; background: transparent; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text); }
.tool-btn:hover { background: var(--bg-hover); }
.sep { width: 1px; background: var(--border); margin: 4px 6px; }
.editor {
@@ -125,10 +210,11 @@ body {
.editor .ProseMirror { min-height: 55vh; outline: none; }
.editor h1, .editor h2, .editor h3 { margin: 0.5em 0 0.25em; }
.editor p { margin: 0.4em 0; }
.editor pre { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; }
.editor code { background: #f1f5f9; padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
.editor pre { background: var(--code-bg); color: var(--code-fg); padding: 12px; border-radius: 8px; overflow-x: auto; }
.editor code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
.editor pre code { background: none; padding: 0; }
.editor blockquote { border-left: 3px solid var(--primary); margin: 0.5em 0; padding-left: 12px; color: var(--muted); }
.editor a { color: var(--primary); text-decoration: underline; cursor: pointer; }
.status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; }
@@ -146,7 +232,7 @@ body {
text-align: center;
}
.login-logo { width: 56px; height: 56px; border-radius: 12px; margin: 0 auto; object-fit: cover; }
.login-card input { padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; }
.login-card input { padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); }
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
.overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
@@ -154,8 +240,65 @@ body {
.history-list { list-style: none; padding: 0; margin: 0 0 16px; }
.history-list li { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
/* ── Theme selector ──────────────────────────────────── */
.theme-selector { position: relative; }
.theme-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface); color: var(--text);
font-size: 13px; font-family: inherit; cursor: pointer;
transition: background 0.15s;
}
.theme-btn:hover { background: var(--bg-hover); }
.theme-label { color: var(--muted); }
.chevron { font-size: 9px; color: var(--muted); }
.theme-popover {
position: absolute; top: calc(100% + 6px); right: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
padding: 10px; min-width: 200px; z-index: 60;
}
.theme-pop-header {
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.6px;
margin-bottom: 8px; padding: 0 4px;
}
.theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.theme-option {
display: flex; align-items: center; gap: 8px;
padding: 7px 8px; border: 1px solid transparent; border-radius: 6px;
background: var(--bg); cursor: pointer; font-family: inherit;
font-size: 13px; color: var(--text);
transition: background 0.12s, border-color 0.12s;
}
.theme-option:hover { border-color: var(--border); }
.theme-option.active { border-color: var(--primary); }
.theme-swatch { display: flex; gap: 2px; flex-shrink: 0; }
.theme-dot { width: 10px; height: 10px; border-radius: 2px; }
.mode-row {
display: flex; gap: 4px; margin-bottom: 8px;
background: var(--bg); padding: 3px; border-radius: 8px;
border: 1px solid var(--border);
}
.mode-btn {
flex: 1; padding: 5px 0; border: none; border-radius: 6px;
background: transparent; color: var(--muted);
font-family: inherit; font-size: 12px; cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mode-btn.active { background: var(--primary-bg); color: var(--on-primary); font-weight: 600; }
.auto-panel { display: flex; flex-direction: column; gap: 6px; }
.auto-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
.auto-panel select {
width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--input-bg); color: var(--text); font-family: inherit; font-size: 13px;
}
.hidden { display: none !important; }
@media (max-width: 720px) {
.layout { grid-template-columns: 1fr; }
.topbar { flex-wrap: wrap; }
.search { max-width: none; order: 3; flex-basis: 100%; }
.theme-popover { right: -60px; }
}

View File

@@ -1,11 +1,109 @@
import { Editor } from "https://esm.sh/@tiptap/core@2.11.5";
import StarterKit from "https://esm.sh/@tiptap/starter-kit@2.11.5";
import Link from "https://esm.sh/@tiptap/extension-link@2.11.5";
const root = document.getElementById("root");
const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
marked.setOptions({ gfm: true, breaks: false });
const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home" };
const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home", space: null, userNames: null };
const THEMES = [
{ id: "dark", label: "Dark", dark: true, dots: ["#1a1b1e", "#26272b", "#7dd3fc"] },
{ id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6"] },
{ id: "summer", label: "Summer", dark: false, dots: ["#fff8f0", "#fff0e0", "#f08040"] },
{ id: "max-summer", label: "Max Summer", dark: false, dots: ["#fffdf5", "#fff9e8", "#e53935"] },
{ id: "autumn", label: "Autumn", dark: false, dots: ["#f4f1ec", "#eae5de", "#d59b6a"] },
{ id: "dracula", label: "Dracula", dark: true, dots: ["#0c0c1e", "#12122a", "#7c6af7"] },
{ id: "nord", label: "Nord", dark: true, dots: ["#2e3440", "#3b4252", "#88c0d0"] },
{ id: "tokyo-night", label: "Tokyo Night", dark: true, dots: ["#1a1b26", "#1f2335", "#7aa2f7"] },
{ id: "catppuccin", label: "Catppuccin", dark: true, dots: ["#1e1e2e", "#26263a", "#cba6f7"] },
{ id: "one-dark", label: "One Dark", dark: true, dots: ["#21252b", "#282c34", "#61afef"] },
{ id: "sunset", label: "Sunset", dark: true, dots: ["#1c1018", "#251420", "#f472b6"] },
];
const THEME_CONFIG_KEY = "snotes-theme-config";
const themeMQ = window.matchMedia("(prefers-color-scheme: dark)");
function findTheme(id) { return THEMES.find((t) => t.id === id); }
function loadThemeConfig() {
let cfg = {};
try { cfg = JSON.parse(localStorage.getItem(THEME_CONFIG_KEY)) || {}; } catch (e) {}
cfg.mode = cfg.mode === "manual" ? "manual" : "auto";
cfg.light = findTheme(cfg.light) ? cfg.light : "light";
cfg.dark = findTheme(cfg.dark) ? cfg.dark : "dark";
cfg.manual = findTheme(cfg.manual) ? cfg.manual : "dark";
return cfg;
}
let themeConfig = loadThemeConfig();
function saveThemeConfig() { try { localStorage.setItem(THEME_CONFIG_KEY, JSON.stringify(themeConfig)); } catch (e) {} }
function activeThemeId() {
return themeConfig.mode === "auto" ? (themeMQ.matches ? themeConfig.dark : themeConfig.light) : themeConfig.manual;
}
function applyTheme() {
const id = activeThemeId();
document.documentElement.setAttribute("data-theme", id);
const t = findTheme(id);
const label = document.querySelector(".theme-label");
const swatch = document.querySelector(".theme-btn .theme-swatch");
if (label) label.textContent = t ? t.label : "";
if (swatch && t) swatch.innerHTML = t.dots.map((c) => `<span class="theme-dot" style="background:${c}"></span>`).join("");
}
function themeSelector() {
const sel = el("div", { class: "theme-selector" });
const btn = el("button", { class: "theme-btn", title: "Change theme" }, [
el("span", { class: "theme-swatch" }),
el("span", { class: "theme-label" }),
el("span", { class: "chevron", html: "&#9662;" }),
]);
const pop = el("div", { class: "theme-popover hidden" });
sel.appendChild(btn); sel.appendChild(pop);
function render() {
applyTheme();
pop.innerHTML = `
<div class="theme-pop-header">Themes</div>
<div class="mode-row">
<button type="button" class="mode-btn ${themeConfig.mode === "auto" ? "active" : ""}" data-mode="auto">Auto</button>
<button type="button" class="mode-btn ${themeConfig.mode === "manual" ? "active" : ""}" data-mode="manual">Manual</button>
</div>
<div class="auto-panel ${themeConfig.mode === "auto" ? "" : "hidden"}">
<label class="auto-label">Light theme</label>
<select data-role="light">${THEMES.filter((t) => !t.dark).map((t) => `<option value="${t.id}" ${t.id === themeConfig.light ? "selected" : ""}>${t.label}</option>`).join("")}</select>
<label class="auto-label">Dark theme</label>
<select data-role="dark">${THEMES.filter((t) => t.dark).map((t) => `<option value="${t.id}" ${t.id === themeConfig.dark ? "selected" : ""}>${t.label}</option>`).join("")}</select>
</div>
<div class="theme-grid ${themeConfig.mode === "manual" ? "" : "hidden"}">
${THEMES.map((t) => `<button type="button" class="theme-option ${themeConfig.mode === "manual" && t.id === themeConfig.manual ? "active" : ""}" data-theme="${t.id}">
<span class="theme-swatch">${t.dots.map((c) => `<span class="theme-dot" style="background:${c}"></span>`).join("")}</span>
<span>${t.label}</span>
</button>`).join("")}
</div>`;
}
btn.addEventListener("click", (e) => { e.stopPropagation(); pop.classList.toggle("hidden"); });
pop.addEventListener("click", (e) => {
const modeBtn = e.target.closest(".mode-btn");
if (modeBtn) { themeConfig.mode = modeBtn.dataset.mode; saveThemeConfig(); render(); return; }
const opt = e.target.closest(".theme-option");
if (opt) { themeConfig.mode = "manual"; themeConfig.manual = opt.dataset.theme; saveThemeConfig(); render(); pop.classList.add("hidden"); }
});
pop.addEventListener("change", (e) => {
if (e.target.matches('select[data-role="light"]')) { themeConfig.light = e.target.value; saveThemeConfig(); render(); }
else if (e.target.matches('select[data-role="dark"]')) { themeConfig.dark = e.target.value; saveThemeConfig(); render(); }
});
document.addEventListener("click", (e) => { if (!sel.contains(e.target)) pop.classList.add("hidden"); });
render();
return sel;
}
if (themeMQ.addEventListener) themeMQ.addEventListener("change", () => { if (themeConfig.mode === "auto") applyTheme(); });
else if (themeMQ.addListener) themeMQ.addListener(() => { if (themeConfig.mode === "auto") applyTheme(); });
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
@@ -51,7 +149,7 @@ async function boot() {
function showLogin() {
root.innerHTML = "";
const form = el("form", { class: "login-card", onsubmit: async (e) => { e.preventDefault(); await doLogin(); } }, [
el("img", { src: "/static/snotes-main.png", class: "login-logo" }),
el("img", { src: "/static/snotes-new.png", class: "login-logo" }),
el("h1", { text: "snotes" }),
el("p", { class: "muted", text: "Remote access — enter password" }),
el("input", { type: "password", id: "pw", placeholder: "Password", autofocus: true }),
@@ -74,13 +172,14 @@ async function doLogin() {
function header() {
return el("header", { class: "topbar" }, [
el("div", { class: "brand" }, [
el("img", { src: "/static/snotes-main.png", class: "brand-logo" }),
el("img", { src: "/static/snotes-new.png", class: "brand-logo" }),
el("span", { text: "snotes" }),
]),
el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }),
el("div", { class: "topbar-right" }, [
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
themeSelector(),
]),
]);
}
@@ -107,7 +206,8 @@ async function showHome() {
async function populateUsers() {
const users = await api("GET", "/api/users");
const sel = document.getElementById("user-select");
const names = users.map((u) => u.name).concat(["common"]);
const names = users.map((u) => u.name);
state.userNames = names;
for (const n of names) {
sel.appendChild(el("option", { value: n, text: n }));
}
@@ -191,15 +291,16 @@ function debounce(fn, ms) {
async function openNote(noteId) {
state.view = "editor";
state.noteId = noteId;
let body = "";
let body = "", owner = null;
if (noteId) {
const note = await api("GET", "/api/notes/" + noteId);
body = note.body;
owner = note.username;
}
renderEditor(body);
renderEditor(body, owner);
}
function renderEditor(body) {
function renderEditor(body, owner) {
root.innerHTML = "";
state.editor?.destroy();
@@ -208,6 +309,14 @@ function renderEditor(body) {
const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null;
const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null;
const spaceSel = el("select", { id: "space-select", class: "user-select", onchange: (e) => { state.space = e.target.value; } });
for (const n of (state.userNames || [state.user])) {
spaceSel.appendChild(el("option", { value: n, text: n }));
}
const initialSpace = owner || state.space || state.user;
spaceSel.value = initialSpace;
state.space = initialSpace;
const toolbar = el("div", { class: "toolbar" }, [
toolbarBtn("B", "bold"), toolbarBtn("I", "italic"),
sep(), toolbarBtn("H1", "heading1"), toolbarBtn("H2", "heading2"), toolbarBtn("H3", "heading3"),
@@ -218,16 +327,22 @@ function renderEditor(body) {
const editorArea = el("div", { class: "editor" });
const status = el("div", { class: "status", text: "" });
root.appendChild(el("div", { class: "editor-wrap" }, [
el("div", { class: "editor-topbar" }, [back, el("div", { class: "spacer" }), hist, del, save]),
const fileInput = el("input", { type: "file", style: "display:none" });
fileInput.addEventListener("change", () => uploadAttachment(fileInput));
const attach = el("button", { class: "btn", text: "Attach", onclick: () => fileInput.click() });
const wrap = el("div", { class: "editor-wrap" }, [
el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]),
toolbar,
editorArea,
status,
]));
]);
wrap.appendChild(fileInput);
root.appendChild(wrap);
state.editor = new Editor({
element: editorArea,
extensions: [StarterKit],
extensions: [StarterKit, Link.configure({ openOnClick: false })],
content: marked.parse(body),
autofocus: true,
});
@@ -262,14 +377,15 @@ async function saveNote() {
const body = turndownService.turndown(html);
const status = document.querySelector(".status");
try {
let note;
if (state.noteId) {
const note = await api("PUT", "/api/notes/" + state.noteId, { body });
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
note = await api("PUT", "/api/notes/" + state.noteId, { body, username: state.space });
} else {
const note = await api("POST", "/api/notes", { body });
note = await api("POST", "/api/notes", { body, username: state.space });
state.noteId = note.id;
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
}
state.editor.commands.setContent(marked.parse(note.body));
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
} catch (e) { status.textContent = "Error: " + e.message; }
}
@@ -279,6 +395,32 @@ async function deleteNote() {
showHome();
}
async function uploadAttachment(input) {
const file = input.files && input.files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
fd.append("username", state.space);
const status = document.querySelector(".status");
try {
const resp = await fetch("/api/attachments", { method: "POST", body: fd });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || resp.statusText);
}
const data = await resp.json();
state.editor.chain().focus().insertContent({
type: "text",
text: data.name,
marks: [{ type: "link", attrs: { href: data.url } }],
}).run();
if (status) status.textContent = "Attached " + data.name;
} catch (e) {
if (status) status.textContent = "Error: " + e.message;
}
input.value = "";
}
async function showHistory() {
const rows = await api("GET", "/api/notes/" + state.noteId + "/history");
const overlay = el("div", { class: "overlay" }, [

View File

@@ -3,8 +3,22 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>snotes</title>
<link rel="icon" type="image/png" href="/static/snotes-main.png">
<title>snotes</title>
<script>
(function () {
var cfg = {};
try { cfg = JSON.parse(localStorage.getItem("snotes-theme-config")) || {}; } catch (e) {}
var id;
if (cfg.mode === "manual") {
id = cfg.manual || "dark";
} else {
var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
id = dark ? (cfg.dark || "dark") : (cfg.light || "light");
}
if (id) document.documentElement.setAttribute("data-theme", id);
})();
</script>
<link rel="icon" type="image/png" href="/static/snotes-new.png">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>

BIN
static/snotes-new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

View File

@@ -15,6 +15,7 @@ async def client(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
monkeypatch.setattr(settings, "actions_file", str(tmp_path / "actions.toml"))
monkeypatch.setattr(settings, "users", "schmeeve,ilya")
monkeypatch.setattr(settings, "remote_pass", "hunter2")
monkeypatch.setattr(settings, "secret_key", "test-secret")
@@ -37,6 +38,7 @@ async def remote_client(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
monkeypatch.setattr(settings, "actions_file", str(tmp_path / "actions.toml"))
monkeypatch.setattr(settings, "users", "schmeeve")
monkeypatch.setattr(settings, "remote_pass", "hunter2")
monkeypatch.setattr(settings, "secret_key", "test-secret")

89
tests/test_actions.py Normal file
View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from backend import actions
def test_load_actions(tmp_path, monkeypatch):
f = tmp_path / "snotes.toml"
f.write_text(
'[[actions]]\nname = "task"\n'
'uri = "http://tasky.loc/new?title=#string1#"\n'
'link = "http://tasky.loc/?title=#string1#"\n'
'[[actions]]\nname = "other"\nuri = "http://x.loc/?q=#string1#"\n'
)
monkeypatch.setattr(actions.settings, "actions_file", str(f))
got = actions.load_actions(reload=True)
assert set(got) == {"task", "other"}
assert got["task"]["uri"] == "http://tasky.loc/new?title=#string1#"
assert got["task"]["link"] == "http://tasky.loc/?title=#string1#"
# "other" has no link -> link defaults to uri
assert got["other"]["link"] == "http://x.loc/?q=#string1#"
async def test_process_actions(monkeypatch):
monkeypatch.setattr(
actions,
"load_actions",
lambda: {
"task": {
"uri": "http://tasky.loc/new?title=#string1#",
"link": "http://tasky.loc/?title=#string1#",
}
},
)
called = []
async def fake_perform(uri):
called.append(uri)
return True
monkeypatch.setattr(actions, "_perform", fake_perform)
out = await actions.process_actions("before\n/task Go to the supermarket\nafter")
assert called == ["http://tasky.loc/new?title=Go%20to%20the%20supermarket"]
assert (
"[Go to the supermarket](http://tasky.loc/?title=Go%20to%20the%20supermarket)"
in out
)
async def test_process_actions_colon_separator(monkeypatch):
monkeypatch.setattr(
actions,
"load_actions",
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
)
monkeypatch.setattr(actions, "_perform", lambda uri: _ok(uri))
out = await actions.process_actions("/task: shop milk")
assert "[shop milk](http://x/?t=shop%20milk)" in out
async def test_process_actions_failure_leaves_line(monkeypatch):
monkeypatch.setattr(
actions,
"load_actions",
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
)
async def fail(uri):
return False
monkeypatch.setattr(actions, "_perform", fail)
out = await actions.process_actions("/task keep this line")
assert out == "/task keep this line"
async def test_process_actions_unknown_keyword(monkeypatch):
monkeypatch.setattr(
actions,
"load_actions",
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
)
monkeypatch.setattr(actions, "_perform", lambda uri: _ok(uri))
out = await actions.process_actions("/nope do nothing")
assert out == "/nope do nothing"
async def _ok(uri):
return True

View File

@@ -34,6 +34,49 @@ async def test_users_seeded(client):
names = [u["name"] for u in r.json()]
assert "schmeeve" in names
assert "ilya" in names
assert "common" in names
async def test_create_note_in_common_space(client):
r = await client.post("/api/notes", json={"body": "shared note", "username": "common"})
assert r.status_code == 200
assert r.json()["username"] == "common"
async def test_move_note_between_spaces(client):
r = await client.post("/api/notes", json={"body": "start here"})
note_id = r.json()["id"]
assert r.json()["username"] == "schmeeve"
r2 = await client.put(f"/api/notes/{note_id}", json={"body": "moved", "username": "common"})
assert r2.status_code == 200
assert r2.json()["username"] == "common"
async def test_create_note_unknown_space(client):
r = await client.post("/api/notes", json={"body": "x", "username": "ghost"})
assert r.status_code == 404
async def test_upload_and_serve_attachment(client):
r = await client.post(
"/api/attachments",
files={"file": ("photo.png", b"fakeimage", "image/png")},
data={"username": "common"},
)
assert r.status_code == 200
data = r.json()
assert data["url"].startswith("/api/attachments/common/")
assert data["path"] == f"common/attachments/{data['url'].rsplit('/', 1)[-1]}"
r2 = await client.get(data["url"])
assert r2.status_code == 200
assert r2.content == b"fakeimage"
async def test_attachment_not_found(client):
r = await client.get("/api/attachments/common/does-not-exist.png")
assert r.status_code == 404
async def test_create_and_get_note(client):

11
uv.lock generated
View File

@@ -388,6 +388,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -478,6 +487,7 @@ dependencies = [
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -508,6 +518,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.3.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "structlog", specifier = ">=25.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },