diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..c328838 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index e153862..25e9849 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000..0627a27 Binary files /dev/null and b/assets/.DS_Store differ diff --git a/assets/snotes-new.png b/assets/snotes-new.png new file mode 100644 index 0000000..365cde3 Binary files /dev/null and b/assets/snotes-new.png differ diff --git a/backend/actions.py b/backend/actions.py new file mode 100644 index 0000000..d79e86c --- /dev/null +++ b/backend/actions.py @@ -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[A-Za-z0-9_-]+)\s*:?\s*(?P.*)$") + +_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) diff --git a/backend/config.py b/backend/config.py index a91e8bb..6d06f44 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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 diff --git a/backend/database.py b/backend/database.py index 7b9807a..5e5803b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 730cefb..813e122 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 87f5446..475ffec 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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): diff --git a/backend/notes.py b/backend/notes.py index 078ebb9..c90f737 100644 --- a/backend/notes.py +++ b/backend/notes.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index c7135c3..9dd0d81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/snotes.db b/snotes.db index d0f72fd..226492a 100644 Binary files a/snotes.db and b/snotes.db differ diff --git a/snotes.toml b/snotes.toml new file mode 100644 index 0000000..0799f80 --- /dev/null +++ b/snotes.toml @@ -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/" diff --git a/static/app.css b/static/app.css index 63f7dc6..e73bac0 100644 --- a/static/app.css +++ b/static/app.css @@ -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; } } diff --git a/static/app.js b/static/app.js index d91c0f9..a34cfe3 100644 --- a/static/app.js +++ b/static/app.js @@ -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) => ``).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: "▾" }), + ]); + const pop = el("div", { class: "theme-popover hidden" }); + sel.appendChild(btn); sel.appendChild(pop); + + function render() { + applyTheme(); + pop.innerHTML = ` +
Themes
+
+ + +
+
+ + + + +
+
+ ${THEMES.map((t) => ``).join("")} +
`; + } + + 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" }, [ diff --git a/static/index.html b/static/index.html index 366e5b6..c1eb15a 100644 --- a/static/index.html +++ b/static/index.html @@ -3,8 +3,22 @@ -snotes - + snotes + + diff --git a/static/snotes-new.png b/static/snotes-new.png new file mode 100644 index 0000000..365cde3 Binary files /dev/null and b/static/snotes-new.png differ diff --git a/tests/conftest.py b/tests/conftest.py index d5a6c4c..3770fb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..93436bd --- /dev/null +++ b/tests/test_actions.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py index 0e71c7a..7721b5a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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): diff --git a/uv.lock b/uv.lock index 1e4920c..3c67bf5 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },