Compare commits

...

3 Commits

Author SHA1 Message Date
Ryan Rix b6517c39b9 add basic metadata views to [localapi] and authentication decorator 2024-03-13 17:02:19 -07:00
Ryan Rix e11e154b33 handle file deletions in watchsync 2024-03-13 16:12:00 -07:00
Ryan Rix 477659779a beginning of "local API" support 2024-03-13 16:11:15 -07:00
11 changed files with 369 additions and 11 deletions

View File

@ -540,6 +540,7 @@ urlpatterns = [
path("feeds.json", views.feed_list, name="feed-list"), path("feeds.json", views.feed_list, name="feed-list"),
path("", include("django_prometheus.urls")), path("", include("django_prometheus.urls")),
path("", include("sitemap.urls")), path("", include("sitemap.urls")),
path("api/v1/", include("localapi.urls")),
# ensure these ones are last because they're greedy! # ensure these ones are last because they're greedy!
re_path("(?P<key>[0-9a-zA-Z/_\-]+\.xml)", views.feed, name="feed"), re_path("(?P<key>[0-9a-zA-Z/_\-]+\.xml)", views.feed, name="feed"),
re_path("(?P<key>[0-9a-zA-Z/_\-]+)", views.org_page, name="org-page"), re_path("(?P<key>[0-9a-zA-Z/_\-]+)", views.org_page, name="org-page"),

View File

@ -32,6 +32,10 @@ CACHES = {
} }
# Environment Variables:4 ends here # Environment Variables:4 ends here
# [[file:../../configuration.org::*Environment Variables][Environment Variables:5]]
LOCALAPI_BEARER_TOKEN = os.environ.get("ARCOLOGY_LOCALAPI_BEARER_TOKEN", "changeme!")
# Environment Variables:5 ends here
# [[file:../../configuration.org::*Hostname configuration from =arcology.model.Site=, eventually][Hostname configuration from =arcology.model.Site=, eventually:1]] # [[file:../../configuration.org::*Hostname configuration from =arcology.model.Site=, eventually][Hostname configuration from =arcology.model.Site=, eventually:1]]
ALLOWED_HOSTS = "thelionsrear.com,rix.si,arcology.garden,whatthefuck.computer,cce.whatthefuck.computer,cce.rix.si,engine.arcology.garden,127.0.0.1,localhost,v2.thelionsrear.com,v2.arcology.garden,cce2.whatthefuck.computer,engine2.arcology.garden".split(',') ALLOWED_HOSTS = "thelionsrear.com,rix.si,arcology.garden,whatthefuck.computer,cce.whatthefuck.computer,cce.rix.si,engine.arcology.garden,127.0.0.1,localhost,v2.thelionsrear.com,v2.arcology.garden,cce2.whatthefuck.computer,engine2.arcology.garden".split(',')
# Hostname configuration from =arcology.model.Site=, eventually:1 ends here # Hostname configuration from =arcology.model.Site=, eventually:1 ends here
@ -44,6 +48,7 @@ INSTALLED_APPS = [
"generators", # [[id:arroyo/django/generators][The Arroyo Generators]] "generators", # [[id:arroyo/django/generators][The Arroyo Generators]]
"syncthonk", # [[id:20231218T183551.765340][Arcology watchsync Command]] "syncthonk", # [[id:20231218T183551.765340][Arcology watchsync Command]]
"sitemap", # [[id:20240226T132507.817450][The Arcology's Site Maps and Discovery Mechanisms]] "sitemap", # [[id:20240226T132507.817450][The Arcology's Site Maps and Discovery Mechanisms]]
"localapi", # [[id:20240313T153901.656967][A Localhost API for the Arcology]]
"django_htmx", "django_htmx",
"django_prometheus", "django_prometheus",

View File

@ -13,6 +13,7 @@ urlpatterns = [
path("feeds.json", views.feed_list, name="feed-list"), path("feeds.json", views.feed_list, name="feed-list"),
path("", include("django_prometheus.urls")), path("", include("django_prometheus.urls")),
path("", include("sitemap.urls")), path("", include("sitemap.urls")),
path("api/v1/", include("localapi.urls")),
# ensure these ones are last because they're greedy! # ensure these ones are last because they're greedy!
re_path("(?P<key>[0-9a-zA-Z/_\-]+\.xml)", views.feed, name="feed"), re_path("(?P<key>[0-9a-zA-Z/_\-]+\.xml)", views.feed, name="feed"),
re_path("(?P<key>[0-9a-zA-Z/_\-]+)", views.org_page, name="org-page"), re_path("(?P<key>[0-9a-zA-Z/_\-]+)", views.org_page, name="org-page"),

View File

@ -240,6 +240,10 @@ CACHES = {
} }
#+END_SRC #+END_SRC
#+begin_src python :tangle arcology/settings/__init__.py
LOCALAPI_BEARER_TOKEN = os.environ.get("ARCOLOGY_LOCALAPI_BEARER_TOKEN", "changeme!")
#+end_src
** NEXT Hostname configuration from =arcology.model.Site=, eventually ** NEXT Hostname configuration from =arcology.model.Site=, eventually
When I have the sites organized in an org-mode table, i'll reapproach the hostname list, and probably before then when i want to test domain-based routing. When I have the sites organized in an org-mode table, i'll reapproach the hostname list, and probably before then when i want to test domain-based routing.
@ -258,6 +262,7 @@ basically, each org file in this repository, and maybe one or two of your own, a
| "generators" | [[id:arroyo/django/generators][The Arroyo Generators]] | | "generators" | [[id:arroyo/django/generators][The Arroyo Generators]] |
| "syncthonk" | [[id:20231218T183551.765340][Arcology watchsync Command]] | | "syncthonk" | [[id:20231218T183551.765340][Arcology watchsync Command]] |
| "sitemap" | [[id:20240226T132507.817450][The Arcology's Site Maps and Discovery Mechanisms]] | | "sitemap" | [[id:20240226T132507.817450][The Arcology's Site Maps and Discovery Mechanisms]] |
| "localapi" | [[id:20240313T153901.656967][A Localhost API for the Arcology]] |
#+BEGIN_SRC python :tangle arcology/settings/__init__.py :noweb yes #+BEGIN_SRC python :tangle arcology/settings/__init__.py :noweb yes
# Application definition # Application definition

View File

@ -206,9 +206,11 @@ in {
description = mdDoc '' description = mdDoc ''
A file containing environment variables you may not want to put in the nix store. A file containing environment variables you may not want to put in the nix store.
For example, you could put a syncthing key in there: For example, you could put a syncthing key
and a bearer token for the Local API in there:
ARCOLOGY_SYNCTHING_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; ARCOLOGY_SYNCTHING_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA;
ARCOLOGY_LOCALAPI_BEARER_TOKEN=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA;
''; '';
}; };

View File

@ -1,4 +1,5 @@
# -*- org-src-preserve-indentation: t; -*- # -*- org-src-preserve-indentation: t; -*-
#+filetags: :Project:
:PROPERTIES: :PROPERTIES:
:ID: arcology/django/interfaces :ID: arcology/django/interfaces
:ROAM_ALIASES: "The Arcology Management Commands" :ROAM_ALIASES: "The Arcology Management Commands"
@ -279,14 +280,16 @@ from django.conf import settings
import json import json
import pathlib import pathlib
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import polling import polling
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# logger.setLevel(logging.DEBUG) # logger.setLevel(logging.DEBUG)
import roam.models
#+end_src #+end_src
The command goes in to a simple loop with [[https://github.com/justiniso/polling][=polling=]] used to ensure that transient errors are retried and the loop does not die without good cause. The [[id:cce/syncthing][Syncthing]] API key is loaded from the [[id:arcology/django/config][Project Configuration]] and the path to monitor is passed in as an argument. Note that this requires the path to match a Syncthing directory! If you are monitoring a folder which is subdirectory of a shared folder, make sure to pass the root of the shared folder in instead or break the path in to its own Syncthing share. The command goes in to a simple loop with [[https://github.com/justiniso/polling][=polling=]] used to ensure that transient errors are retried and the loop does not die without good cause. The [[id:cce/syncthing][Syncthing]] API key is loaded from the [[id:arcology/django/config][Project Configuration]] and the path to monitor is passed in as an argument. Note that this requires the path to match a Syncthing directory! If you are monitoring a folder which is subdirectory of a shared folder, make sure to pass the root of the shared folder in instead or break the path in to its own Syncthing share.
@ -315,7 +318,7 @@ class Command(BaseCommand):
self.folder_id = self.get_folder_id_for_path(self.expanded_path) self.folder_id = self.get_folder_id_for_path(self.expanded_path)
logger.info(f"fetched folder ID {self.folder_id} AKA {self.expanded_path} from syncthing API") logger.info(f"fetched folder ID {self.folder_id} AKA {self.expanded_path} from syncthing API")
#+end_src #+end_src
it would be nice to get the pagination key from the DB to have persistence across invocations, but re-running the ingester on startup when there isn't necessarily changes isn't really that bad, it'll just cause some disk IO while it checks file hashes: it would be nice to get the pagination key from the DB to have persistence across invocations, but re-running the ingester on startup when there isn't necessarily changes isn't really that bad, it'll just cause some disk IO while it checks file hashes:
#+begin_src python :tangle syncthonk/management/commands/watchsync.py #+begin_src python :tangle syncthonk/management/commands/watchsync.py
@ -372,7 +375,8 @@ The functionality to query the Syncthing [[https://docs.syncthing.net/rest/event
last_since = None last_since = None
for event in jason: for event in jason:
last_since = event.get("id") last_since = event.get("id")
event_folder_id = event.get("data", {}).get("folder", "") data = event.get("data", {})
event_folder_id = data.get("folder", "")
file_path = pathlib.Path(event.get("data", {}).get("path")) file_path = pathlib.Path(event.get("data", {}).get("path"))
#+end_src #+end_src
@ -389,8 +393,23 @@ For each file path, it checks that the file name is not a temporary file name, t
elif event_folder_id != self.folder_id: elif event_folder_id != self.folder_id:
logger.debug(f"skip unmonitored folder {event_folder_id}") logger.debug(f"skip unmonitored folder {event_folder_id}")
ingest_this = False ingest_this = False
#+end_src
If a file is deleted, make sure it's removed from the database. Note that this is still liable to miss files; eventually I will want to add a management command to go over the whole file listing and true up the database, but this is good enough for now as long as it's kept to be realtime.
#+begin_src python :tangle syncthonk/management/commands/watchsync.py
elif data.get("action") == "deleted":
final_path = self.expanded_path.joinpath(file_path)
f = roam.models.File.objects.get(path=final_path)
assert f
logger.debug(f"deleting {final_path}!")
f.delete()
ingest_this = False
#+end_src
#+begin_src python :tangle syncthonk/management/commands/watchsync.py
# add new failure cases here. # add new failure cases here.
logger.debug(f"proc {last_since} {event_folder_id}, ingest? {ingest}, ingest_this? {ingest_this}") logger.debug(f"proc {last_since} {event_folder_id}, ingest? {ingest}, ingest_this? {ingest_this}: {json.dumps(event)}")
if ingest_this == True: if ingest_this == True:
ingest = ingest_this ingest = ingest_this
logger.debug(f"{event}") logger.debug(f"{event}")
@ -401,6 +420,11 @@ For each file path, it checks that the file name is not a temporary file name, t
The command returns the last event ID of the request which is used to paginate future requests made in the loop. The command returns the last event ID of the request which is used to paginate future requests made in the loop.
** DONE make sure we are able to handle deletions here...
:LOGBOOK:
- State "DONE" from "NEXT" [2024-03-13 Wed 15:37]
:END:
* Create Base Sites * Create Base Sites
:PROPERTIES: :PROPERTIES:
:ID: 20231217T154835.232283 :ID: 20231217T154835.232283

171
localapi.org Normal file
View File

@ -0,0 +1,171 @@
:PROPERTIES:
:ID: 20240313T153901.656967
:END:
#+TITLE: A Localhost API for the Arcology
#+filetags: :Project:
By moving the database out of EmacSQL and in to Django, I *have* hobbled some of the Arcology's [[id:knowledge_base][Knowledge Management]] [[id:e79d4cdc-082f-4b1d-ae08-d979802f09ee][Living Systems]] and meta-cognitive skills. =arroyo-db-query= no longer existing means that things like the [[id:20230526T105534.711282][Direnv arroyo-db integration]] stopped working, and the helpers in [[id:cce/my_nixos_configuration][My NixOS configuration]] and elsewhere rely on the stringly-typed [[id:20231217T154938.132553][Arcology generate Command]] and a =nix run= invocation.
I think it's worth building a small HTTP JSON API which can serve some of the common queries, to at least see if it's worth the trouble.
I probably should use Django REST Framework for this, but. I'm just gonna beat two stones together until a JSON API falls out. This will probably change the first time I add a =POST= call or want to do more complex auth stuff.
* Server view scaffolding
#+begin_src python :tangle localapi/urls.py :mkdirp yes
from django.contrib import admin
from django.urls import path, re_path, include
from localapi import views
urlpatterns = [
path("", views.index),
path("keywords/<slug:key>", views.keyword_by_key, name="keyword_by_key"),
re_path("keywords/(?P<key>[0-9a-zA-Z_-]+)/(?P<value>[0-9a-zA-Z/_\-]+)", views.keyword_by_key_value, name="keyword_by_key"),
path("page/<>", views.page_metadata, name="page_metadata"),
re_path("page/(?P<route_key>[0-9a-zA-Z/_\-]+)", views.page_metadata, name="page_metadata"),
re_path("file/(?P<file_path>[0-9a-zA-Z/_\-\.]+)", views.file_metadata, name="file_metadata"),
]
#+end_src
#+begin_src python :tangle localapi/views.py
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound, Http404, JsonResponse
from django.shortcuts import render, get_object_or_404
from arcology.models import Page, Feed, Site
from roam.models import Link
from localapi.auth import authenticated
from prometheus_client import Counter, Histogram
import logging
logger = logging.getLogger(__name__)
#+end_src
The API is fairly simple and built-to-purpose, we start with an endpoint that can be queried to validate that the API Bearer token is valid:
#+begin_src python :tangle localapi/views.py
@authenticated
def index(request):
return JsonResponse(dict(state="ok :)"))
#+end_src
* DONE Auth Middleware
:LOGBOOK:
- State "DONE" from [2024-03-13 Wed 17:01]
:END:
The Bearer token is set in the [[id:arcology/django/config][Arcology Project Configuration]], and can be overridden in an environment variable =ARCOLOGY_LOCALAPI_BEARER_TOKEN=. The NixOS endpoint configuration module for this API will include token generation and placement so that this should be transparent to a local command user. Decorating a view with =@authenticated= will add this bearer token authentication to the view.
#+begin_src python :tangle localapi/auth.py
import re
from django.conf import settings
from django.http import HttpRequest, JsonResponse
def authenticate_request(request: HttpRequest):
r = re.compile(r'Bearer (\S+)')
bearer = request.headers.get("Authorization", "")
match = r.match(bearer)
if not match:
return False
tok = match.group(1)
if tok != settings.LOCALAPI_BEARER_TOKEN:
return False
return True
def authenticated(func):
def wrapper(*args, **kwargs):
request=args[0]
if not authenticate_request(request):
return JsonResponse(dict(state="no :("), status=401)
return func(*args, **kwargs)
return wrapper
#+end_src
* Keyword metadata
#+begin_src python :tangle localapi/views.py
import roam.models
def _json_keywords(keywords):
return [
dict(path=kw.path.path, keyword=kw.keyword, value=kw.value)
for kw in keywords
]
#+end_src
** /keywords/{key}
#+begin_src python :tangle localapi/views.py
@authenticated
def keyword_by_key(request, key):
keywords = roam.models.Keyword.objects.filter(keyword=key).all()
return JsonResponse(dict(
state="ok :)",
key=key,
keywords=_json_keywords(keywords),
))
#+end_src
** /keywords/{key}/{value}
#+begin_src python :tangle localapi/views.py
@authenticated
def keyword_by_key_value(request, key, value):
keywords = roam.models.Keyword.objects.filter(keyword=key, value=value).all()
return JsonResponse(dict(
state="ok :)",
key=key,
value=value,
keywords=_json_keywords(keywords),
))
#+end_src
* Page and File metadata
#+begin_src python :tangle localapi/views.py
import arcology.models
def _json_page(page):
return dict(
title=page.title,
url=page.to_url(),
site=page.site.title,
)
#+end_src
#+begin_src python :tangle localapi/views.py
@authenticated
def page_metadata(request, route_key):
page = arcology.models.Page.objects.get(route_key=route_key)
keywords = page.file.keyword_set.all()
return JsonResponse(dict(
state="ok :)",
route_key=route_key,
file=page.file.path,
page=_json_page(page),
keywords=_json_keywords(keywords)
))
#+end_src
#+begin_src python :tangle localapi/views.py
@authenticated
def file_metadata(request, file_path):
the_file = roam.models.File.objects.get(path=file_path)
page = the_file.page_set.first()
return JsonResponse(dict(
state="ok :)",
file_path=file_path,
file=dict(
path=the_file.path,
page=_json_page(page),
),
))
#+end_src
* NEXT [[id:20221021T150631.404359][Arroyo Nix Library Helpers]]
* NEXT [[id:20231217T154938.132553][Arcology generate Command]]
* NEXT NixOS deployment for endpoints in [[id:cce/my_nixos_configuration][My NixOS configuration]]

26
localapi/auth.py Normal file
View File

@ -0,0 +1,26 @@
# [[file:../localapi.org::*Auth Middleware][Auth Middleware:1]]
import re
from django.conf import settings
from django.http import HttpRequest, JsonResponse
def authenticate_request(request: HttpRequest):
r = re.compile(r'Bearer (\S+)')
bearer = request.headers.get("Authorization", "")
match = r.match(bearer)
if not match:
return False
tok = match.group(1)
if tok != settings.LOCALAPI_BEARER_TOKEN:
return False
return True
def authenticated(func):
def wrapper(*args, **kwargs):
request=args[0]
if not authenticate_request(request):
return JsonResponse(dict(state="no :("), status=401)
return func(*args, **kwargs)
return wrapper
# Auth Middleware:1 ends here

15
localapi/urls.py Normal file
View File

@ -0,0 +1,15 @@
# [[file:../localapi.org::*Server view scaffolding][Server view scaffolding:1]]
from django.contrib import admin
from django.urls import path, re_path, include
from localapi import views
urlpatterns = [
path("", views.index),
path("keywords/<slug:key>", views.keyword_by_key, name="keyword_by_key"),
re_path("keywords/(?P<key>[0-9a-zA-Z_-]+)/(?P<value>[0-9a-zA-Z/_\-]+)", views.keyword_by_key_value, name="keyword_by_key"),
path("page/<>", views.page_metadata, name="page_metadata"),
re_path("page/(?P<route_key>[0-9a-zA-Z/_\-]+)", views.page_metadata, name="page_metadata"),
re_path("file/(?P<file_path>[0-9a-zA-Z/_\-\.]+)", views.file_metadata, name="file_metadata"),
]
# Server view scaffolding:1 ends here

92
localapi/views.py Normal file
View File

@ -0,0 +1,92 @@
# [[file:../localapi.org::*Server view scaffolding][Server view scaffolding:2]]
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound, Http404, JsonResponse
from django.shortcuts import render, get_object_or_404
from arcology.models import Page, Feed, Site
from roam.models import Link
from localapi.auth import authenticated
from prometheus_client import Counter, Histogram
import logging
logger = logging.getLogger(__name__)
# Server view scaffolding:2 ends here
# [[file:../localapi.org::*Server view scaffolding][Server view scaffolding:3]]
@authenticated
def index(request):
return JsonResponse(dict(state="ok :)"))
# Server view scaffolding:3 ends here
# [[file:../localapi.org::*Keyword metadata][Keyword metadata:1]]
import roam.models
def _json_keywords(keywords):
return [
dict(path=kw.path.path, keyword=kw.keyword, value=kw.value)
for kw in keywords
]
# Keyword metadata:1 ends here
# [[file:../localapi.org::*/keywords/{key}][/keywords/{key}:1]]
@authenticated
def keyword_by_key(request, key):
keywords = roam.models.Keyword.objects.filter(keyword=key).all()
return JsonResponse(dict(
state="ok :)",
key=key,
keywords=_json_keywords(keywords),
))
# /keywords/{key}:1 ends here
# [[file:../localapi.org::*/keywords/{key}/{value}][/keywords/{key}/{value}:1]]
@authenticated
def keyword_by_key_value(request, key, value):
keywords = roam.models.Keyword.objects.filter(keyword=key, value=value).all()
return JsonResponse(dict(
state="ok :)",
key=key,
value=value,
keywords=_json_keywords(keywords),
))
# /keywords/{key}/{value}:1 ends here
# [[file:../localapi.org::*Page and File metadata][Page and File metadata:1]]
import arcology.models
def _json_page(page):
return dict(
title=page.title,
url=page.to_url(),
site=page.site.title,
)
# Page and File metadata:1 ends here
# [[file:../localapi.org::*Page and File metadata][Page and File metadata:2]]
@authenticated
def page_metadata(request, route_key):
page = arcology.models.Page.objects.get(route_key=route_key)
keywords = page.file.keyword_set.all()
return JsonResponse(dict(
state="ok :)",
route_key=route_key,
file=page.file.path,
page=_json_page(page),
keywords=_json_keywords(keywords)
))
# Page and File metadata:2 ends here
# [[file:../localapi.org::*Page and File metadata][Page and File metadata:3]]
@authenticated
def file_metadata(request, file_path):
the_file = roam.models.File.objects.get(path=file_path)
page = the_file.page_set.first()
return JsonResponse(dict(
state="ok :)",
file_path=file_path,
file=dict(
path=the_file.path,
page=_json_page(page),
),
))
# Page and File metadata:3 ends here

View File

@ -5,14 +5,16 @@ from django.conf import settings
import json import json
import pathlib import pathlib
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import polling import polling
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# logger.setLevel(logging.DEBUG) # logger.setLevel(logging.DEBUG)
import roam.models
# Ingest files on-demand using Syncthing:2 ends here # Ingest files on-demand using Syncthing:2 ends here
# [[file:../../../interfaces.org::*Ingest files on-demand using Syncthing][Ingest files on-demand using Syncthing:3]] # [[file:../../../interfaces.org::*Ingest files on-demand using Syncthing][Ingest files on-demand using Syncthing:3]]
@ -88,7 +90,8 @@ class Command(BaseCommand):
last_since = None last_since = None
for event in jason: for event in jason:
last_since = event.get("id") last_since = event.get("id")
event_folder_id = event.get("data", {}).get("folder", "") data = event.get("data", {})
event_folder_id = data.get("folder", "")
file_path = pathlib.Path(event.get("data", {}).get("path")) file_path = pathlib.Path(event.get("data", {}).get("path"))
# Ingest files on-demand using Syncthing:6 ends here # Ingest files on-demand using Syncthing:6 ends here
@ -103,12 +106,25 @@ class Command(BaseCommand):
elif event_folder_id != self.folder_id: elif event_folder_id != self.folder_id:
logger.debug(f"skip unmonitored folder {event_folder_id}") logger.debug(f"skip unmonitored folder {event_folder_id}")
ingest_this = False ingest_this = False
# Ingest files on-demand using Syncthing:7 ends here
# [[file:../../../interfaces.org::*Ingest files on-demand using Syncthing][Ingest files on-demand using Syncthing:8]]
elif data.get("action") == "deleted":
final_path = self.expanded_path.joinpath(file_path)
f = roam.models.File.objects.get(path=final_path)
assert f
logger.debug(f"deleting {final_path}!")
f.delete()
ingest_this = False
# Ingest files on-demand using Syncthing:8 ends here
# [[file:../../../interfaces.org::*Ingest files on-demand using Syncthing][Ingest files on-demand using Syncthing:9]]
# add new failure cases here. # add new failure cases here.
logger.debug(f"proc {last_since} {event_folder_id}, ingest? {ingest}, ingest_this? {ingest_this}") logger.debug(f"proc {last_since} {event_folder_id}, ingest? {ingest}, ingest_this? {ingest_this}: {json.dumps(event)}")
if ingest_this == True: if ingest_this == True:
ingest = ingest_this ingest = ingest_this
logger.debug(f"{event}") logger.debug(f"{event}")
if ingest: if ingest:
call_command('ingestfiles', self.expanded_path) call_command('ingestfiles', self.expanded_path)
return last_since return last_since
# Ingest files on-demand using Syncthing:7 ends here # Ingest files on-demand using Syncthing:9 ends here