Shifted to flask

This commit is contained in:
Tris Forster 2025-10-03 14:39:30 +10:00
parent 9bd19c8ac2
commit e453fa5559
23 changed files with 854 additions and 207 deletions

View File

@ -1,13 +1,17 @@
FROM recoll
RUN apt-get update && apt-get -y install breeze-icon-theme python3-venv && apt-get clean
RUN apt-get -y install breeze-icon-theme python3-venv && apt-get clean
ARG SQUIRREL_VERSION=0.3.1
ARG SQUIRREL_VERSION=0.4.0
COPY dist/squirrel-${SQUIRREL_VERSION}-py3-none-any.whl squirrel-${SQUIRREL_VERSION}-py3-none-any.whl
RUN python3 -m venv --system-site-packages /opt/squirrel
RUN /opt/squirrel/bin/pip install squirrel-${SQUIRREL_VERSION}-py3-none-any.whl
RUN /opt/squirrel/bin/pip install gunicorn
COPY squirrel-cmd /opt/squirrel/bin/squirrel
ENV SQUIRREL_CONFIG /etc/squirrel/config.json
EXPOSE 8000

View File

@ -1,18 +1,20 @@
[project]
name = "squirrel"
version = "0.3.1"
version = "0.4.0"
description = "Find your nuts"
authors = [
{name = "Your Name",email = "you@example.com"}
{name = "trisf"}
]
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard] (>=0.118.0,<0.119.0)"
"flask (>=3.1.2,<4.0.0)",
"pillow (>=11.3.0,<12.0.0)",
"dotenv (>=0.9.9,<0.10.0)"
]
[project.scripts]
"squirrel" = "squirrel.server:daemon"
#[project.scripts]
#"squirrel" = "squirrel.server:daemon"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]

3
squirrel-cmd Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
/opt/squirrel/bin/gunicorn -w 4 -b 0.0.0.0 "squirrel.main:app"

View File

@ -1,107 +1,57 @@
from math import ceil
from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from typing import Annotated, Any
import logging
import subprocess
import os.path
from dotenv import dotenv_values
from recoll import recoll
from squirrel.auth import User, auth_user, auth_user_or_guest # type: ignore
from squirrel.conf import settings # type: ignore
from squirrel import repo # type:ignore
logger = logging.getLogger()
logging.basicConfig(level=logging.DEBUG)
config = {**dotenv_values(".env"), **os.environ}
router = APIRouter(prefix="/api")
SQUIRREL_APP = os.path.dirname(os.path.realpath(__file__)) + "/app"
SQUIRREL_ICONS = config.get("SQUIRREL_ICONS", "/usr/share/icons/breeze/mimetypes/32")
DOC_FOLDER = config.get("SQUIRREL_DOCS", "/mnt/docs")
STRIP_CHARS = len(DOC_FOLDER) + len("file:///")
archive = repo.get_archive()
class Recoll(object):
def __init__(self):
self._indexing = False
def reindex(self):
if self._indexing:
raise RuntimeError("Reindex already underway")
self._indexing = True
subprocess.call(["recollindex"])
self._indexing = False
@router.get("/reindex")
async def get_reindex(
user: Annotated[User, Depends(auth_user)],
tasks: BackgroundTasks,
repo: str = "main",
) -> dict[str, str]:
if repo not in user.repos:
raise HTTPException(status_code=403, detail="No privelleges on that repo")
tasks.add_task(archive.reindex, repo)
return {"message": f"Started indexing of {repo}"}
repo = Recoll()
app = FastAPI()
app.mount("/static", StaticFiles(directory=f"{SQUIRREL_APP}/static"))
app.mount("/docs", StaticFiles(directory=DOC_FOLDER))
app.mount(
"/icons",
StaticFiles(directory=SQUIRREL_ICONS, follow_symlink=True),
)
templates = Jinja2Templates(directory=f"{SQUIRREL_APP}/templates")
@router.get("/search")
def get_search(
user: Annotated[User, Depends(auth_user_or_guest)],
q: str,
count: int = 20,
offset: int = 1,
) -> dict[str, Any]:
return archive.query(q, count, offset, extra=user.repos)
@app.get("/")
def home(request: Request):
ctx = {"request": request}
return templates.TemplateResponse("home.html", context=ctx)
@router.get("/repos")
def get_repos():
return archive.user_repos("foo")
@app.get("/search")
def search_results(request: Request, q: str, count: int = 20, page: int = 1):
ctx: dict[str, Any] = {"request": request}
ctx["search"] = search(q, count, page)
return templates.TemplateResponse("search_results.html", context=ctx)
@router.get("/complete")
def get_completions(q: str, field: str = "") -> list[str]:
if ":" in q:
field, _, q = q.partition(":")
@app.get("/api/reindex")
async def get_reindex(tasks: BackgroundTasks) -> dict[str, str]:
tasks.add_task(repo.reindex)
return {"message": "Recoll index started"}
@app.get("/api/search")
def get_search(q: str, count: int = 20, page: int = 1) -> dict[str, Any]:
return search(q, count, page)
def search(q: str, count: int, page: int):
db = recoll.connect()
query = db.query()
nres = query.execute(q)
result = {
"query": q,
"count": count,
"page": page,
"pages": ceil(nres / count),
"total": nres,
"xquery": query.getxquery(),
"results": [],
}
if page > result["pages"] or page < 1:
return result
query.scroll((page - 1) * count)
docs = query.fetchmany(count)
for doc in docs:
d = dict(doc)
d["snippets"] = query.getsnippets(doc, ctxwords=20)
d["fpath"] = d["url"][STRIP_CHARS:]
d["title"] = d["title"] or d["filename"]
result["results"].append(d)
db = archive.db([])
result = db.termMatch("wildcard", q + "*", field=field, maxlen=10)
if field:
result = [f"{field}:{x}" for x in result]
return result
@app.get("/api/info")
@router.get("/settings")
def get_info():
db = recoll.connect()
return dir(db)
return settings

View File

@ -1,14 +0,0 @@
@import "tailwindcss";
SPAN.rclmatch {
font-weight: bold;
color: var(--color-red-900);
}
@font-face {
font-family: beyondTheMountains; /* set name */
src: url(/static/fonts/beyond_the_mountains.ttf); /* url of the font */
}
.font-beyond-the-mountains {
font-family: beyondTheMountains;
}

View File

@ -1,13 +0,0 @@
<html>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Squirrel</title>
<link rel="icon" type="image/png" href="/static/images/squirrel_32.png">
<link rel="stylesheet" href="/static/css/search.css"></link>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block scripts %}{% endblock %}
<head>
</head>
<body>
{% block content %}CONTENT{% endblock %}
</body>
</html>

View File

@ -1,54 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div x-data="query">
<div id="search-header" class="flex m-2 items-center border-b pb-2 border-red-900 mx-10">
<a href="/" class="mx-10 text-2xl text-red-900 font-beyond-the-mountains">Squirrel</a>
<form action="/search" method="get">
<input
class="shadow appearance-none border-slate-600 rounded-full w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
type="text" size="80" name="q"
onfocus="this.select()"
x-bind:value="query"></input>
</form>
<div class="grow text-right">
Foo
</div>
</div>
<div id="search-main" class="mx-auto w-2/3">
<div id="search-info" class="flex">
<p class="grow"></p>
<p>Page <span x-text="page"></span> of <span x-text="pages"></span>,
<span x-text="total"></span> results.</p>
</div>
<div id="search-results">
<template x-for="result in results">
<div class="p-4 m-4">
<div class="m-2 flex">
<img class="w-16 h-16 m-2" x-bind:src="'/icons/' + result.mtype.replace('/', '-') + '.svg'"></img>
<div>
<a class="text-xl text-red-900" x-text="result.title" x-bind:href="'/docs/' + result.fpath" target="_blank"></a><br/>
<span class="text-sm" x-text="result.relevancyrating"></span>
</div>
</div>
<div>
<template x-for="snippet in result.snippets">
<span class="text-sm"><span x-html="snippet[2]"></span><br/></span>
</template>
</div>
</div>
</template>
</div>
<div id="search-footer" class="text-sm">
XQuery: <span x-text="xquery"></span>
</div>
</div>
<div>
<script>
var query = {{ search|tojson }};
</script>
{% endblock %}

96
squirrel/auth.py Normal file
View File

@ -0,0 +1,96 @@
from flask import (
Blueprint,
request,
render_template,
session,
redirect,
url_for,
flash,
g,
)
from werkzeug.security import check_password_hash, generate_password_hash
from dataclasses import dataclass
import functools
bp = Blueprint("auth", __name__, url_prefix="/auth")
@dataclass
class User:
name: str
password: str
repos: list[str] | None = None
users: dict[str, dict] = {
"guest": {"password": "x", "name": "Guest"},
"tris": {
"password": "scrypt:32768:8:1$4srkVJ5rxSAvZp8s$6a9b1bd9199ceb07a8e650897d6ca0c229330773a77728f83c110525c0cde5c9a019bcafbfd0274a2e532e6db03996ff34521d8f36758f4892bcc13107ac24e5",
"name": "Tris",
"repos": ["test"],
},
}
@bp.route("/login", methods=("GET", "POST"))
def login():
username = ""
referer = request.args.get("referer") or request.headers.get("Referer")
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
error = None
user = users.get(username)
if user is None or not check_password_hash(user["password"], password):
error = "Incorrect username or password."
if error is None:
session.clear()
session["user"] = username
return redirect(referer or url_for("render_home"))
flash(error)
return render_template("auth/login.html", username=username, referer=referer)
@bp.route("/generate", methods=("GET", "POST"))
def generate_password():
hashed = None
if request.method == "POST":
password = request.form["password"]
hashed = generate_password_hash(password)
return render_template("auth/generate.html", hashed=hashed)
@bp.route("/logout")
def logout():
referer = request.headers.get("Referer")
session.clear()
return redirect(referer or url_for("auth.login"))
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for("auth.login"))
return view(**kwargs)
return wrapped_view
@bp.before_app_request
def load_logged_in_user():
username = session.get("user")
if username is None:
g.user = User(**users["guest"])
else:
g.user = User(**users[username])

21
squirrel/conf.py Normal file
View File

@ -0,0 +1,21 @@
import os.path
import json
from dotenv import dotenv_values
env = {**dotenv_values(".env"), **os.environ}
SQUIRREL_DIR = os.path.expanduser(env.get("SQUIRREL_DIR", "~/.config/squirrel"))
SQUIRREL_CONFIG = env.get("SQUIRREL_CONFIG", os.path.join(SQUIRREL_DIR, "config.json"))
DEFAULT_SETTINGS = {}
settings = {
"base_dir": SQUIRREL_DIR,
"config_file": SQUIRREL_CONFIG,
"app_dir": os.path.dirname(os.path.realpath(__file__)) + "/app",
"icon_dir": env.get("SQUIREL_ICONS", "/usr/share/icons/breeze/mimetypes/32"),
}
settings.update(DEFAULT_SETTINGS)
with open(SQUIRREL_CONFIG, "r") as f:
settings.update(json.load(f))

127
squirrel/main.py Normal file
View File

@ -0,0 +1,127 @@
from flask import (
Flask,
render_template,
redirect,
request,
g,
send_file,
jsonify,
Response,
)
from flask.logging import default_handler
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
import io
from squirrel.conf import settings
from squirrel import auth, repo
from PIL import Image
import os.path
# Enable logging on repo
repo.logger.addHandler(default_handler)
app = Flask(__name__)
app.config["SECRET_KEY"] = "foobar23"
# TODO: better singleton methods
archive = repo.get_archive()
app.register_blueprint(auth.bp)
@app.get("/")
def render_home():
return render_template("home.html")
def search_params(d):
"""
Reference:
"""
try:
params = {
"q": d.get("q"),
"count": int(d.get("count", 20)),
"offset": int(d.get("offset", 0)),
}
except Exception as e:
raise BadRequest(str(e))
if not params["q"]:
raise BadRequest("Missing q parameter")
if params["offset"] < 0:
raise BadRequest("Invalid offset")
return params
def user_repos(user):
if not user:
return []
return user.repos or []
@app.get("/search/<doctype>")
@app.get("/search")
def render_search(doctype="general"):
try:
search = archive.query(**search_params(request.args), extra=user_repos(g.user))
except BadRequest:
return redirect("/")
return render_template(
"search_results.html",
**search,
doctype=doctype,
renderer=f"results_{doctype}.html",
)
@app.get("/search-fragment/<doctype>")
def render_search_fragment(doctype):
search = archive.query(**search_params(request.args), extra=user_repos(g.user))
return render_template(f"results_{doctype}.html", **search)
@app.get("/icon/<type>/<subtype>.svg")
def get_icon(type, subtype):
return send_file(os.path.join(settings["icon_dir"], f"{type}-{subtype}.svg"))
@app.get("/doc/<repo>/<path:udi>")
def get_document(repo, udi):
if repo != "main" and repo not in user_repos(g.user):
raise Forbidden("No access to repo")
filepath = archive.path_for_udi(repo, "/" + udi)
if not os.path.exists(filepath):
raise NotFound("No such document")
return send_file(filepath)
@app.get("/preview/<repo>/<path:docpath>")
def get_preview(repo, docpath):
if repo not in user_repos(g.user):
raise Forbidden("No access to repo")
try:
im = Image.open("/" + docpath)
new_size = (200, 200)
resized_img = im.resize(new_size)
# Save the image to a buffer
img_io = io.BytesIO()
resized_img.save(img_io, "JPEG", quality=70)
img_io.seek(0)
return send_file(img_io, mimetype="image/jpeg")
except Exception as e:
return f"Error: {str(e)}"
@app.get("/api/info")
def get_info():
return jsonify(archive.config)
@app.get("/api/reindex/<repo>")
def get_reindex(repo):
p = archive.reindex(repo)
return Response(p.stderr, mimetype="text/plain")

113
squirrel/repo.py Normal file
View File

@ -0,0 +1,113 @@
"""
Original WebUI: https://framagit.org/medoc92/recollwebui
"""
import os.path
import configparser
import subprocess
from recoll import recoll # type:ignore
import logging
from typing import Any
from functools import cache
from squirrel.conf import settings # type:ignore
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
STRIP_CHARS = len("file://")
logger.info("TEST")
class Squirrel(object):
def __init__(self, config):
self.config = config
self.repos = self.config["repos"]
self.extra_dbs = {}
self.paths = {}
for name, config_path in self.repos.items():
logger.info("Loading repo %s [%s]", name, config_path)
# Really basic RCLConfig parser
parser = configparser.ConfigParser(allow_unnamed_section=True)
if not parser.read(os.path.join(config_path, "recoll.conf")):
raise IOError(f"Failed to open config at {config_path}")
repo: dict[str, str] = dict(parser.items(configparser.UNNAMED_SECTION)) # type:ignore
for d in repo.get("topdirs", "").split():
self.paths[d] = name
self.extra_dbs[name] = os.path.join(
repo.get("cachedir", config_path), "xapiandb"
)
self.search_paths = list(self.paths.keys())
self.search_paths.sort(reverse=True)
logger.info("Search paths: %s", self.search_paths)
def db(self, repo: str = "main", extra: list[str] = []):
dbs = [db for name, db in self.extra_dbs.items() if name in extra]
return recoll.connect(self.repos[repo], extra_dbs=dbs)
def reindex(self, repo="main"):
config = self.config["repos"][repo]
p = subprocess.Popen(["recollindex", "-c", config], stderr=subprocess.PIPE)
return p
def get_repos(self, filename):
return {self.paths[x] for x in self.search_paths if filename.startswith(x)}
def query(
self,
q: str,
count: int = 20,
offset: int = 0,
repo: str = "main",
extra: list[str] = [],
) -> dict[str, Any]:
db = self.db(repo, extra)
query = db.query()
nres = query.execute(q)
s_extra = set(extra + ["main"])
result = {
"query": q,
"count": count,
"start": offset,
"end": offset,
"total": nres,
"xquery": query.getxquery(),
"repos": [repo] + extra,
"results": [],
}
if offset >= nres or offset < 0:
return result
if nres == 0:
return result
if offset > 0:
query.scroll(offset)
docs = query.fetchmany(count)
for doc in docs:
d = dict(doc)
d["udi"] = doc.rcludi
d["snippets"] = query.getsnippets(doc, ctxwords=10)
d["fpath"] = d["url"][STRIP_CHARS:]
d["title"] = d["title"] or d["filename"]
d["repos"] = list(self.get_repos(d["fpath"]) & s_extra)
result["results"].append(d)
result["end"] += 1
return result
def path_for_udi(self, repo, udi: str):
db = recoll.connect(self.config["repos"][repo])
doc = db.getDoc(udi)
if not doc or not doc.url:
raise IOError("No such file")
return doc.url[STRIP_CHARS:]
@cache
def get_archive():
return Squirrel(settings)

View File

@ -1,9 +0,0 @@
import uvicorn
def daemon(reload=False):
uvicorn.run("squirrel.api:app", host="0.0.0.0", port=8000, reload=reload)
if __name__ == "__main__":
daemon(True)

View File

@ -0,0 +1,42 @@
@import "tailwindcss";
.flashes {
@apply bg-amber-300 my-2 mx-20 px-10 py-2 border border-amber-600 rounded-xl;
}
.header {
@apply mx-2;
}
.button {
@apply border rounded bg-gray-100 px-2 py-1 text-red-900 font-bold hover:cursor-pointer;
}
/* Form elements */
.field {
@apply border rounded py-2 px-3 text-gray-900;
}
.field-label {
@apply mb-2 font-bold text-red-900;
}
.field-control {
@apply flex flex-col mb-2;
}
SPAN.rclmatch {
@apply font-bold text-red-900;
}
/* Custom fonts */
@font-face {
font-family: beyondTheMountains; /* set name */
src: url(/static/fonts/beyond_the_mountains.ttf); /* url of the font */
}
.font-beyond-the-mountains {
font-family: beyondTheMountains;
}

View File

@ -1,15 +1,24 @@
/*! tailwindcss v4.0.9 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.1.14 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-900: oklch(0.396 0.141 25.723);
--color-slate-600: oklch(0.446 0.043 257.281);
--color-gray-700: oklch(0.373 0.034 259.733);
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-red-900: oklch(39.6% 0.141 25.723);
--color-amber-300: oklch(87.9% 0.169 91.605);
--color-amber-600: oklch(66.6% 0.179 58.318);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-600: oklch(44.6% 0.043 257.281);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-700: oklch(37.3% 0.034 259.733);
--color-gray-900: oklch(21% 0.034 264.665);
--spacing: 0.25rem;
--container-md: 28rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
@ -18,13 +27,11 @@
--text-2xl--line-height: calc(2 / 1.5);
--text-8xl: 6rem;
--text-8xl--line-height: 1;
--font-weight-bold: 700;
--leading-tight: 1.25;
--radius-xl: 0.75rem;
--default-font-family: var(--font-sans);
--default-font-feature-settings: var(--font-sans--font-feature-settings);
--default-font-variation-settings: var(--font-sans--font-variation-settings);
--default-mono-font-family: var(--font-mono);
--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);
--default-mono-font-variation-settings: var(--font-mono--font-variation-settings);
}
}
@layer base {
@ -38,14 +45,11 @@
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' );
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
body {
line-height: inherit;
}
hr {
height: 0;
color: inherit;
@ -68,7 +72,7 @@
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var( --default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace );
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
@ -134,7 +138,14 @@
}
::placeholder {
opacity: 1;
color: color-mix(in oklab, currentColor 50%, transparent);
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
@ -155,16 +166,19 @@
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@ -175,18 +189,36 @@
.m-4 {
margin: calc(var(--spacing) * 4);
}
.m-10 {
margin: calc(var(--spacing) * 10);
}
.mx-10 {
margin-inline: calc(var(--spacing) * 10);
}
.mx-auto {
margin-inline: auto;
}
.my-5 {
margin-block: calc(var(--spacing) * 5);
}
.my-10 {
margin-block: calc(var(--spacing) * 10);
}
.mt-20 {
margin-top: calc(var(--spacing) * 20);
}
.mt-50 {
margin-top: calc(var(--spacing) * 50);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.block {
display: block;
}
@ -196,6 +228,18 @@
.h-16 {
height: calc(var(--spacing) * 16);
}
.h-18 {
height: calc(var(--spacing) * 18);
}
.h-20 {
height: calc(var(--spacing) * 20);
}
.h-22 {
height: calc(var(--spacing) * 22);
}
.h-64 {
height: calc(var(--spacing) * 64);
}
.w-1\/2 {
width: calc(1/2 * 100%);
}
@ -205,9 +249,24 @@
.w-16 {
width: calc(var(--spacing) * 16);
}
.w-18 {
width: calc(var(--spacing) * 18);
}
.w-20 {
width: calc(var(--spacing) * 20);
}
.w-22 {
width: calc(var(--spacing) * 22);
}
.w-64 {
width: calc(var(--spacing) * 64);
}
.w-full {
width: 100%;
}
.max-w-md {
max-width: var(--container-md);
}
.grow {
flex-grow: 1;
}
@ -217,9 +276,24 @@
.items-center {
align-items: center;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-l-full {
border-top-left-radius: calc(infinity * 1px);
border-bottom-left-radius: calc(infinity * 1px);
}
.rounded-r-full {
border-top-right-radius: calc(infinity * 1px);
border-bottom-right-radius: calc(infinity * 1px);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@ -230,6 +304,18 @@
.border-slate-600 {
border-color: var(--color-slate-600);
}
.bg-red-300 {
background-color: var(--color-red-300);
}
.bg-red-600 {
background-color: var(--color-red-600);
}
.bg-slate-300 {
background-color: var(--color-slate-300);
}
.mask-clip-border {
mask-clip: border-box;
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
@ -239,6 +325,12 @@
.px-5 {
padding-inline: calc(var(--spacing) * 5);
}
.px-10 {
padding-inline: calc(var(--spacing) * 10);
}
.px-20 {
padding-inline: calc(var(--spacing) * 20);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
@ -251,9 +343,15 @@
.text-center {
text-align: center;
}
.text-justify {
text-align: justify;
}
.text-right {
text-align: right;
}
.font-mono {
font-family: var(--font-mono);
}
.text-2xl {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
@ -274,12 +372,18 @@
--tw-leading: var(--leading-tight);
line-height: var(--leading-tight);
}
.whitespace-pre {
white-space: pre;
}
.text-gray-700 {
color: var(--color-gray-700);
}
.text-red-900 {
color: var(--color-red-900);
}
.text-slate-300 {
color: var(--color-slate-300);
}
.shadow {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -291,8 +395,58 @@
}
}
}
.flashes {
margin-inline: calc(var(--spacing) * 20);
margin-block: calc(var(--spacing) * 2);
border-radius: var(--radius-xl);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-amber-600);
background-color: var(--color-amber-300);
padding-inline: calc(var(--spacing) * 10);
padding-block: calc(var(--spacing) * 2);
}
.header {
margin-inline: calc(var(--spacing) * 2);
}
.button {
border-radius: 0.25rem;
border-style: var(--tw-border-style);
border-width: 1px;
background-color: var(--color-gray-100);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-red-900);
&:hover {
@media (hover: hover) {
cursor: pointer;
}
}
}
.field {
border-radius: 0.25rem;
border-style: var(--tw-border-style);
border-width: 1px;
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 2);
color: var(--color-gray-900);
}
.field-label {
margin-bottom: calc(var(--spacing) * 2);
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-red-900);
}
.field-control {
margin-bottom: calc(var(--spacing) * 2);
display: flex;
flex-direction: column;
}
SPAN.rclmatch {
font-weight: bold;
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-red-900);
}
@font-face {
@ -320,6 +474,11 @@ SPAN.rclmatch {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
@ -329,6 +488,11 @@ SPAN.rclmatch {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
@ -366,3 +530,30 @@ SPAN.rclmatch {
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-border-style: solid;
--tw-leading: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-font-weight: initial;
}
}
}

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div id="password-generator">
<div id="generator-form" class="max-w-md mx-auto mt-20">
<form action="/auth/generate" method="POST">
<div class="field-control">
<label class="field-label" for="password">Password</label>
<input class="field" type="password", name="password"></input>
</div>
<div class="text-right">
<button class="button" type="submit" value="submit">Login</button>
</div>
</form>
</div>
{% if hashed %}
<div class="border rounded m-10 py-2 px-10">
Password: <span class="font-mono">{{ hashed }}</span>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block content %}
<div id="login page">
<div id="login-form" class="max-w-md mx-auto mt-20">
<form action="/auth/login?referer={{referer|urlencode}}" method="POST">
<div class="field-control">
<label class="field-label" for="username">User</label>
<input class="field" type="text" name="username" value="{{username}}"></input>
</div>
<div class="field-control">
<label class="field-label" for="password">Password</label>
<input class="field" type="password", name="password"></input>
</div>
<div class="text-right">
<button class="button" type="submit" value="submit">Login</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Squirrel</title>
<link rel="icon" type="image/png" href="/static/images/squirrel_32.png">
<link rel="stylesheet" href="/static/css/search.css"></link>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block scripts %}{% endblock %}
<head>
</head>
<body>
{% block header %}
<div id="search-header" class="flex m-2 items-center border-b pb-2 border-red-900 mx-10">
<a href="/" class="mx-10 text-2xl text-red-900 font-beyond-the-mountains">Squirrel</a>
<div id="header-main" class="header grow">
{% if query %}
<form action="/search" method="get">
<div class="shadow border-slate-600 rounded-full w-full text-gray-700 leading-tight focus:shadow-outline flex">
<div class="bg-red-300 py-2 px-3 mask-clip-border rounded-l-full">Bubble</div>
<input
class="appearance-none focus:outline-none py-2 px-3 grow"
type="text" size="80" name="q" onfocus="this.select()" value="{{ query }}"></input>
<button type="submit" class="bg-slate-300 py-2 px-3 rounded-r-full">Search</button>
</div>
</form>
{% endif %}
</div>
<div class="header">User: {{ g.user.name }}</div>
<div class="header">
{% if g.user.name == "Guest" %}
<a href="/auth/login"><span class="material-symbols-outlined">lock_open</span></a>
{% else %}
<a href="/auth/logout"><span class="material-symbols-outlined">lock<span></a>
{% endif %}
</div>
</div>
{% endblock %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
{% block content %}CONTENT{% endblock %}
</body>
</html>

View File

@ -1,5 +1,7 @@
{% extends "base.html" %}
{% block header %}{% endblock %}
{% block content %}
<div id="home-header"></div>

View File

@ -0,0 +1,23 @@
<div class="result-block">
{% for result in results %}
<div class="search-result mb-4">
<div class="mb-2">
<div class="m-2 flex">
<img class="w-18 h-18 m-2" src="/icon/{{result.mtype}}.svg"></img>
<div>
<a class="text-xl text-red-900" href="/doc/{{result.repos.0}}{{result.udi}}" target="_blank">{{ result.title }}</a><br/>
<span class="text-sm">{{ result.relevancyrating }}</span> <span class="text-sm">{{ result.repos|join(", ") }}</span><br>
<span class="text-sm">{{ result.fpath }}</span>
</div>
</div>
</div>
<div class="">
{% for snippet in result.snippets %}
<div class="text-sm mb-2">{{ snippet[2]|safe }}</div>
{% endfor %}
</div>
<hr class="text-slate-300"></hr>
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,8 @@
<div class="result-block flex">
{% for result in results %}
<div class="search-result">
<img class="w-64 h-64 m-2" src="/icon/{{result.mtype}}.svg"></img>
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<div x-data="ctx()">
<div id="search-main" class="mx-auto w-2/3">
<div id="search-info" class="flex">
<p class="grow"></p>
<p>Results {{ start+1 }} to <span x-text="resultCount">{{ end+1 }}</span> of {{ total }}</p>
</div>
<div id="search-results">
{% include renderer %}
</div>
<div class="text-center my-5" x-show="resultCount < totalResults">
<button class="button px-20" x-on:click="loadMore">Load More</button>
</div>
<div id="search-footer" class="text-sm text-right">
XQuery: <span>{{ xquery }}</span>
</div>
</div>
<div>
<script>
console.log("ONE");
document.addEventListener("alpine:init", () => {
console.log("TWO");
Alpine.data("ctx", () => ({
resultCount: {{ end }},
totalResults: {{ total }},
loadMore(foo) {
fetch("/search-fragment/{{doctype}}?q={{query|urlencode}}&count={{count}}&offset=" + this.resultCount).then((response) => {
if (response.status == 200) {
return response.text();
} else {
console.log(response);
}
}).then((text) => {
let el = document.createElement("div");
el.innerHTML = text;
let results = foo = el.firstElementChild.getElementsByClassName("search-result");
this.resultCount += results.length;
document.getElementById("search-results").firstElementChild.append(...results);
})
console.log("FOO");
}
}));
});
</script>
{% if request.args.debug %}
<div class="whitespace-pre">
{{ results|tojson(1) }}
</div>
{% endif %}
{% endblock %}