Compare commits
9
Commits
c2aae2afc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c6e7771b | ||
|
|
7cca8cd232 | ||
|
|
128f34c997 | ||
|
|
66eed73c0e | ||
|
|
5707282513 | ||
|
|
e453fa5559 | ||
|
|
9bd19c8ac2 | ||
|
|
330ad09048 | ||
|
|
3b7cb32b57 |
+44
-3
@@ -1,14 +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
|
||||
|
||||
ARG SQUIRREL_VERSION=0.3.1
|
||||
# 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 /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
|
||||
|
||||
ENTRYPOINT ["/opt/squirrel/bin/squirrel"]
|
||||
CMD ["/opt/squirrel/bin/squirrel"]
|
||||
|
||||
@@ -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
@@ -1,18 +1,20 @@
|
||||
[project]
|
||||
name = "squirrel"
|
||||
version = "0.3.1"
|
||||
version = "0.4.1"
|
||||
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"
|
||||
"sqindex" = "squirrel.cmd:reindex"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
=ZCmr
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
/opt/squirrel/bin/gunicorn -w 4 -b 0.0.0.0 "squirrel.main:app"
|
||||
+39
-89
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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])
|
||||
@@ -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"))
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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" %}
|
||||
|
||||
{% block header %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="home-header"></div>
|
||||
@@ -9,6 +11,7 @@
|
||||
<form action="/search" method="get">
|
||||
<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"
|
||||
autocorrect="off" spellcheck="false" autocomplete="off"
|
||||
></input>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user