Shifted to flask
This commit is contained in:
parent
9bd19c8ac2
commit
e453fa5559
@ -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
|
||||
|
||||
|
||||
@ -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
3
squirrel-cmd
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
/opt/squirrel/bin/gunicorn -w 4 -b 0.0.0.0 "squirrel.main:app"
|
||||
128
squirrel/api.py
128
squirrel/api.py
@ -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 %}
|
||||
96
squirrel/auth.py
Normal file
96
squirrel/auth.py
Normal 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
21
squirrel/conf.py
Normal 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
127
squirrel/main.py
Normal 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
113
squirrel/repo.py
Normal 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)
|
||||
@ -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
squirrel/static/css/search-source.css
Normal file
42
squirrel/static/css/search-source.css
Normal 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 {
|
||||
: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 |
22
squirrel/templates/auth/generate.html
Normal file
22
squirrel/templates/auth/generate.html
Normal 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
squirrel/templates/auth/login.html
Normal file
21
squirrel/templates/auth/login.html
Normal 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
squirrel/templates/base.html
Normal file
53
squirrel/templates/base.html
Normal 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" %}
|
||||
|
||||
{% block header %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="home-header"></div>
|
||||
23
squirrel/templates/results_general.html
Normal file
23
squirrel/templates/results_general.html
Normal 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
squirrel/templates/results_image.html
Normal file
8
squirrel/templates/results_image.html
Normal 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
squirrel/templates/search_results.html
Normal file
59
squirrel/templates/search_results.html
Normal 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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user