commit 4291c845d8e0fe11ec94ba1f90f93cb75cd4aaf7 Author: Tris Forster Date: Mon Sep 29 22:44:17 2025 +1000 Rewrite using fastapi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..965f289 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/static/css/search-source.css b/app/static/css/search-source.css new file mode 100644 index 0000000..59dcbe2 --- /dev/null +++ b/app/static/css/search-source.css @@ -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; +} diff --git a/app/static/css/search.css b/app/static/css/search.css new file mode 100644 index 0000000..37d68df --- /dev/null +++ b/app/static/css/search.css @@ -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: ""; + 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; +} diff --git a/app/static/fonts/beyond_the_mountains.ttf b/app/static/fonts/beyond_the_mountains.ttf new file mode 100644 index 0000000..c8e5c15 Binary files /dev/null and b/app/static/fonts/beyond_the_mountains.ttf differ diff --git a/app/static/images/squirrel_32.png b/app/static/images/squirrel_32.png new file mode 100644 index 0000000..4039a2d Binary files /dev/null and b/app/static/images/squirrel_32.png differ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..742b448 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,13 @@ + + + Squirrel + + + + {% block scripts %}{% endblock %} + + + + {% block content %}CONTENT{% endblock %} + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..8025ef5 --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} + +
+ + +{% endblock %} diff --git a/app/templates/search_results.html b/app/templates/search_results.html new file mode 100644 index 0000000..d5776bd --- /dev/null +++ b/app/templates/search_results.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ Squirrel +
+ +
+
+ Foo +
+
+ +
+
+

+

Page of , + results.

+
+ +
+ +
+ + +
+
+ + +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..667053f --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/squirrel/__init__.py b/squirrel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/squirrel/api.py b/squirrel/api.py new file mode 100644 index 0000000..92ad5e2 --- /dev/null +++ b/squirrel/api.py @@ -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) diff --git a/squirrel/config.py b/squirrel/config.py new file mode 100644 index 0000000..c4d81f3 --- /dev/null +++ b/squirrel/config.py @@ -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) diff --git a/squirrel/server.py b/squirrel/server.py new file mode 100644 index 0000000..7d7fbc4 --- /dev/null +++ b/squirrel/server.py @@ -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)