Compare commits

..
11 Commits
Author SHA1 Message Date
tris 26c6e7771b Read users from config! 2025-10-03 17:50:09 +10:00
Tris Forster 7cca8cd232 Added missing decoders 2025-10-03 17:30:59 +10:00
tris 128f34c997 Added index command 2025-10-03 16:35:04 +10:00
Tris Forster 66eed73c0e Add missing key 2025-10-03 15:24:57 +10:00
Tris Forster 5707282513 Move all into single Dockerfile 2025-10-03 15:24:24 +10:00
tris e453fa5559 Shifted to flask 2025-10-03 14:39:30 +10:00
tris 9bd19c8ac2 Disable autocomplete 2025-09-30 13:54:56 +10:00
tris 330ad09048 Streamline build 2025-09-30 13:54:15 +10:00
tris 3b7cb32b57 Started on README 2025-09-29 23:52:21 +10:00
tris c2aae2afc3 Allow for version bumps 2025-09-29 23:41:01 +10:00
tris 34aa402292 Set title if not set 2025-09-29 23:40:45 +10:00
26 changed files with 1032 additions and 207 deletions
+47 -4
View File
@@ -1,12 +1,55 @@
FROM recoll FROM debian:trixie-slim
# install important dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
antiword \
aspell \
aspell-en \
git \
libxml2 \
libimage-exiftool-perl \
net-tools \
poppler-utils \
python3 \
python3-chm \
python3-libxml2 \
python3-lxml \
python3-mutagen \
python3-pip \
unrtf \
unzip \
vim
# install recollcmd from recolls programmers website
COPY recoll.gpg /etc/apt/trusted.gpg.d/recoll.gpg
RUN apt-get install --reinstall -y ca-certificates
RUN apt-get update
RUN echo deb http://www.lesbonscomptes.com/recoll/debian/ buster main > \
/etc/apt/sources.list.d/recoll.list
RUN echo deb-src http://www.lesbonscomptes.com/recoll/debian/ buster main >> \
/etc/apt/sources.list.d/recoll.list
RUN apt-get install -y --no-install-recommends recollcmd python3-recoll
RUN apt-get autoremove
RUN apt-get -y install breeze-icon-theme python3-venv RUN apt-get -y install breeze-icon-theme python3-venv
COPY dist/squirrel-0.3.0-py3-none-any.whl squirrel-0.3.0-py3-none-any.whl # clean up
RUN apt-get clean
ARG SQUIRREL_VERSION=0.4.1
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 python3 -m venv --system-site-packages /opt/squirrel
RUN /opt/squirrel/bin/pip install squirrel-0.3.0-py3-none-any.whl 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 PATH /opt/squirrel/bin:${PATH}
ENV SQUIRREL_CONFIG /etc/squirrel/config.json
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/opt/squirrel/bin/squirrel"] CMD ["/opt/squirrel/bin/squirrel"]
+32
View File
@@ -0,0 +1,32 @@
# Squirrel
**Find your nuts**
Squirrel is a web interface to the excellent Recoll file search engine. It provides a modern
search engine interface for a headless first experience.
## Building from source
```bash
poetry install
poetry build
pip install dist/squirrel-0.3.x-py3-none-any.whl
```
## Running
```bash
docker run -v /srv/archive:/mnt/docs:ro -p 8000:8000 squirrel
```
## Development
```bash
# Build the package and image
poetry build
docker build . -t squirrel
# Mount the source and start in watching mode
docker run -v ./data:/var/recoll -v /srv/archive:/mnt/docs:ro -v .:/app:ro -e 8000:8000 --entrypoint bash squirrel
$ /opt/squirrel/bin/python3 -m squirrel.server
```
+6 -4
View File
@@ -1,18 +1,20 @@
[project] [project]
name = "squirrel" name = "squirrel"
version = "0.3.0" version = "0.4.1"
description = "Find your nuts" description = "Find your nuts"
authors = [ authors = [
{name = "Your Name",email = "you@example.com"} {name = "trisf"}
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ 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] [project.scripts]
"squirrel" = "squirrel.server:daemon" "sqindex" = "squirrel.cmd:reindex"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
+95
View File
@@ -0,0 +1,95 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=ZCmr
-----END PGP PUBLIC KEY BLOCK-----
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
/opt/squirrel/bin/gunicorn -w 4 -b 0.0.0.0 "squirrel.main:app"
+39 -88
View File
@@ -1,106 +1,57 @@
from math import ceil from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.staticfiles import StaticFiles from typing import Annotated, Any
from fastapi.templating import Jinja2Templates
from typing import Any
import logging import logging
import subprocess from squirrel.auth import User, auth_user, auth_user_or_guest # type: ignore
import os.path from squirrel.conf import settings # type: ignore
from dotenv import dotenv_values from squirrel import repo # type:ignore
from recoll import recoll
logger = logging.getLogger() 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" archive = repo.get_archive()
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:///")
class Recoll(object): @router.get("/reindex")
def __init__(self): async def get_reindex(
self._indexing = False user: Annotated[User, Depends(auth_user)],
tasks: BackgroundTasks,
def reindex(self): repo: str = "main",
if self._indexing: ) -> dict[str, str]:
raise RuntimeError("Reindex already underway") if repo not in user.repos:
self._indexing = True raise HTTPException(status_code=403, detail="No privelleges on that repo")
subprocess.call(["recollindex"]) tasks.add_task(archive.reindex, repo)
self._indexing = False return {"message": f"Started indexing of {repo}"}
repo = Recoll() @router.get("/search")
def get_search(
app = FastAPI() user: Annotated[User, Depends(auth_user_or_guest)],
app.mount("/static", StaticFiles(directory=f"{SQUIRREL_APP}/static")) q: str,
app.mount("/docs", StaticFiles(directory=DOC_FOLDER)) count: int = 20,
app.mount( offset: int = 1,
"/icons", ) -> dict[str, Any]:
StaticFiles(directory=SQUIRREL_ICONS, follow_symlink=True), return archive.query(q, count, offset, extra=user.repos)
)
templates = Jinja2Templates(directory=f"{SQUIRREL_APP}/templates")
@app.get("/") @router.get("/repos")
def home(request: Request): def get_repos():
ctx = {"request": request} return archive.user_repos("foo")
return templates.TemplateResponse("home.html", context=ctx)
@app.get("/search") @router.get("/complete")
def search_results(request: Request, q: str, count: int = 20, page: int = 1): def get_completions(q: str, field: str = "") -> list[str]:
ctx: dict[str, Any] = {"request": request} if ":" in q:
ctx["search"] = search(q, count, page) field, _, q = q.partition(":")
return templates.TemplateResponse("search_results.html", context=ctx)
db = archive.db([])
@app.get("/api/reindex") result = db.termMatch("wildcard", q + "*", field=field, maxlen=10)
async def get_reindex(tasks: BackgroundTasks) -> dict[str, str]: if field:
tasks.add_task(repo.reindex) result = [f"{field}:{x}" for x in result]
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:]
result["results"].append(d)
return result return result
@app.get("/api/info") @router.get("/settings")
def get_info(): def get_info():
db = recoll.connect() return settings
return dir(db)
-14
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;
}
-13
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>
@@ -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 %}
+90
View File
@@ -0,0 +1,90 @@
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
from squirrel.conf import settings
import functools
bp = Blueprint("auth", __name__, url_prefix="/auth")
@dataclass
class User:
name: str
password: str
repos: list[str] | None = None
users = settings["users"]
@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])
+16
View File
@@ -0,0 +1,16 @@
from squirrel.repo import get_archive
import sys
archive = get_archive()
def reindex():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("repo")
options = parser.parse_args()
p = archive.reindex(options.repo)
for line in p.stderr: # type:ignore
sys.stderr.write(line.decode("utf8"))
+21
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
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
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)
-9
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)
+42
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;
}
@@ -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, base, components, utilities;
@layer theme { @layer theme {
:root, :host { :root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
'Noto Color Emoji'; "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace; "Courier New", monospace;
--color-red-900: oklch(0.396 0.141 25.723); --color-red-300: oklch(80.8% 0.114 19.571);
--color-slate-600: oklch(0.446 0.043 257.281); --color-red-600: oklch(57.7% 0.245 27.325);
--color-gray-700: oklch(0.373 0.034 259.733); --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; --spacing: 0.25rem;
--container-md: 28rem;
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875); --text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem; --text-xl: 1.25rem;
@@ -18,13 +27,11 @@
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-8xl: 6rem; --text-8xl: 6rem;
--text-8xl--line-height: 1; --text-8xl--line-height: 1;
--font-weight-bold: 700;
--leading-tight: 1.25; --leading-tight: 1.25;
--radius-xl: 0.75rem;
--default-font-family: var(--font-sans); --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-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 { @layer base {
@@ -38,14 +45,11 @@
line-height: 1.5; line-height: 1.5;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
tab-size: 4; 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-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
body {
line-height: inherit;
}
hr { hr {
height: 0; height: 0;
color: inherit; color: inherit;
@@ -68,7 +72,7 @@
font-weight: bolder; font-weight: bolder;
} }
code, kbd, samp, pre { 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-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em; font-size: 1em;
@@ -134,7 +138,14 @@
} }
::placeholder { ::placeholder {
opacity: 1; 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 { textarea {
resize: vertical; 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 { ::-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; padding-block: 0;
} }
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid { :-moz-ui-invalid {
box-shadow: none; 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; appearance: button;
} }
::-webkit-inner-spin-button, ::-webkit-outer-spin-button { ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto; height: auto;
} }
[hidden]:where(:not([hidden='until-found'])) { [hidden]:where(:not([hidden="until-found"])) {
display: none !important; display: none !important;
} }
} }
@@ -175,18 +189,36 @@
.m-4 { .m-4 {
margin: calc(var(--spacing) * 4); margin: calc(var(--spacing) * 4);
} }
.m-10 {
margin: calc(var(--spacing) * 10);
}
.mx-10 { .mx-10 {
margin-inline: calc(var(--spacing) * 10); margin-inline: calc(var(--spacing) * 10);
} }
.mx-auto { .mx-auto {
margin-inline: auto; margin-inline: auto;
} }
.my-5 {
margin-block: calc(var(--spacing) * 5);
}
.my-10 { .my-10 {
margin-block: calc(var(--spacing) * 10); margin-block: calc(var(--spacing) * 10);
} }
.mt-20 {
margin-top: calc(var(--spacing) * 20);
}
.mt-50 { .mt-50 {
margin-top: calc(var(--spacing) * 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 { .block {
display: block; display: block;
} }
@@ -196,6 +228,18 @@
.h-16 { .h-16 {
height: calc(var(--spacing) * 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 { .w-1\/2 {
width: calc(1/2 * 100%); width: calc(1/2 * 100%);
} }
@@ -205,9 +249,24 @@
.w-16 { .w-16 {
width: calc(var(--spacing) * 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 { .w-full {
width: 100%; width: 100%;
} }
.max-w-md {
max-width: var(--container-md);
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
@@ -217,9 +276,24 @@
.items-center { .items-center {
align-items: center; align-items: center;
} }
.rounded {
border-radius: 0.25rem;
}
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); 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-b {
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px; border-bottom-width: 1px;
@@ -230,6 +304,18 @@
.border-slate-600 { .border-slate-600 {
border-color: var(--color-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 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
@@ -239,6 +325,12 @@
.px-5 { .px-5 {
padding-inline: calc(var(--spacing) * 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 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@@ -251,9 +343,15 @@
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-justify {
text-align: justify;
}
.text-right { .text-right {
text-align: right; text-align: right;
} }
.font-mono {
font-family: var(--font-mono);
}
.text-2xl { .text-2xl {
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
@@ -274,12 +372,18 @@
--tw-leading: var(--leading-tight); --tw-leading: var(--leading-tight);
line-height: var(--leading-tight); line-height: var(--leading-tight);
} }
.whitespace-pre {
white-space: pre;
}
.text-gray-700 { .text-gray-700 {
color: var(--color-gray-700); color: var(--color-gray-700);
} }
.text-red-900 { .text-red-900 {
color: var(--color-red-900); color: var(--color-red-900);
} }
.text-slate-300 {
color: var(--color-slate-300);
}
.shadow { .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)); --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); 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 { SPAN.rclmatch {
font-weight: bold; --tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-red-900); color: var(--color-red-900);
} }
@font-face { @font-face {
@@ -320,6 +474,11 @@ SPAN.rclmatch {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow { @property --tw-inset-shadow {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -329,6 +488,11 @@ SPAN.rclmatch {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color { @property --tw-ring-color {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -366,3 +530,30 @@ SPAN.rclmatch {
inherits: false; inherits: false;
initial-value: 0 0 #0000; 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;
}
}
}

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+22
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 %}
+21
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 %}
+53
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>
@@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block header %}{% endblock %}
{% block content %} {% block content %}
<div id="home-header"></div> <div id="home-header"></div>
@@ -9,6 +11,7 @@
<form action="/search" method="get"> <form action="/search" method="get">
<input type="text" name="q" placeholder="Find your nuts..." <input type="text" name="q" placeholder="Find your nuts..."
class="shadow appearance-none border-slate-600 text-2xl rounded-full w-full py-3 px-5 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" class="shadow appearance-none border-slate-600 text-2xl rounded-full w-full py-3 px-5 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
autocorrect="off" spellcheck="false" autocomplete="off"
></input> ></input>
</form> </form>
</div> </div>
+23
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>
+8
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>
+59
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 %}