Rewrite using fastapi

This commit is contained in:
Tris Forster 2025-09-29 22:44:17 +10:00
commit 4291c845d8
15 changed files with 621 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
*.pyc

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM recoll
RUN apt-get install breeze-icon-theme
COPY . /opt/squirrel
WORKDIR /opt/squirrel
RUN pip install --break-system-packages -e .
EXPOSE 8000
ENTRYPOINT ["/bin/bash"]

0
README.md Normal file
View File

View File

@ -0,0 +1,14 @@
@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;
}

368
app/static/css/search.css Normal file
View File

@ -0,0 +1,368 @@
/*! tailwindcss v4.0.9 | MIT License | https://tailwindcss.com */
@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);
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--text-8xl: 6rem;
--text-8xl--line-height: 1;
--leading-tight: 1.25;
--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 {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
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-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;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
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-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
color: color-mix(in oklab, currentColor 50%, transparent);
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-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;
}
:-moz-ui-invalid {
box-shadow: none;
}
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'])) {
display: none !important;
}
}
@layer utilities {
.m-2 {
margin: calc(var(--spacing) * 2);
}
.m-4 {
margin: calc(var(--spacing) * 4);
}
.mx-10 {
margin-inline: calc(var(--spacing) * 10);
}
.mx-auto {
margin-inline: auto;
}
.my-10 {
margin-block: calc(var(--spacing) * 10);
}
.mt-50 {
margin-top: calc(var(--spacing) * 50);
}
.block {
display: block;
}
.flex {
display: flex;
}
.h-16 {
height: calc(var(--spacing) * 16);
}
.w-1\/2 {
width: calc(1/2 * 100%);
}
.w-2\/3 {
width: calc(2/3 * 100%);
}
.w-16 {
width: calc(var(--spacing) * 16);
}
.w-full {
width: 100%;
}
.grow {
flex-grow: 1;
}
.appearance-none {
appearance: none;
}
.items-center {
align-items: center;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-red-900 {
border-color: var(--color-red-900);
}
.border-slate-600 {
border-color: var(--color-slate-600);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-5 {
padding-inline: calc(var(--spacing) * 5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-3 {
padding-block: calc(var(--spacing) * 3);
}
.pb-2 {
padding-bottom: calc(var(--spacing) * 2);
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-2xl {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
}
.text-8xl {
font-size: var(--text-8xl);
line-height: var(--tw-leading, var(--text-8xl--line-height));
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xl {
font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height));
}
.leading-tight {
--tw-leading: var(--leading-tight);
line-height: var(--leading-tight);
}
.text-gray-700 {
color: var(--color-gray-700);
}
.text-red-900 {
color: var(--color-red-900);
}
.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);
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
outline-style: none;
}
}
}
SPAN.rclmatch {
font-weight: bold;
color: var(--color-red-900);
}
@font-face {
font-family: beyondTheMountains;
src: url(/static/fonts/beyond_the_mountains.ttf);
}
.font-beyond-the-mountains {
font-family: beyondTheMountains;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-leading {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

13
app/templates/base.html Normal file
View File

@ -0,0 +1,13 @@
<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>

17
app/templates/home.html Normal file
View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<div id="home-header"></div>
<div id="home-search" class="mx-auto mt-50">
<div class="font-beyond-the-mountains text-8xl text-red-900 text-center">Squirrel</div>
<div class="mx-auto w-1/2 my-10">
<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"
></input>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% 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 %}

19
pyproject.toml Normal file
View File

@ -0,0 +1,19 @@
[project]
name = "squirrel"
version = "0.3.0"
description = "Find your nuts"
authors = [
{name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard] (>=0.118.0,<0.119.0)"
]
[project.scripts]
"squirrel" = "squirrel.server:daemon"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

0
squirrel/__init__.py Normal file
View File

101
squirrel/api.py Normal file
View File

@ -0,0 +1,101 @@
from math import ceil
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from typing import Any
import logging
import subprocess
from recoll import recoll
from fastapi import BackgroundTasks
# from .config import settings
logger = logging.getLogger()
logging.basicConfig(level=logging.DEBUG)
DOC_FOLDER = "/mnt/docs"
STRIP_CHARS = len(DOC_FOLDER) + len("file:///")
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
repo = Recoll()
app = FastAPI()
app.mount("/static", StaticFiles(directory="app/static"))
app.mount("/docs", StaticFiles(directory=DOC_FOLDER))
app.mount(
"/icons",
StaticFiles(directory="/usr/share/icons/breeze/mimetypes/32", follow_symlink=True),
)
templates = Jinja2Templates(directory="app/templates")
@app.get("/")
def home(request: Request):
ctx = {"request": request}
return templates.TemplateResponse("home.html", context=ctx)
@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)
@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, str]:
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
@app.get("/api/info")
def get_info():
db = recoll.connect()
return dir(db)

12
squirrel/config.py Normal file
View File

@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings
from os.path import abspath
class Settings(BaseSettings):
app_name: str = "Squirrel"
items_per_user: int = 50
archive: str = "./archive"
settings = Settings()
settings.archive = abspath(settings.archive)

9
squirrel/server.py Normal file
View File

@ -0,0 +1,9 @@
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)