wrolpi/modules/zim/api.py
lrnselfreliance e0fdb762fc
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
zim: recompute the outdated-Zims flag after startup and on Delete (#706)
* zim: recompute the outdated-Zims flag after startup and on Delete

The Dashboard shows the "Outdated Zim Files" banner from the persisted
`outdated_zims` flag, but the modal lists files from a live scan.  The two
disagreed: the flag was restored from the DB on startup and never
recomputed once the outdated file had been removed, so the banner showed
with nothing to delete, and the modal displayed an endless placeholder.

- The startup recompute now runs after the DB is up and configs are
  imported, so it overrides the value `init_flags` restored from the DB.
  Previously it ran before the DB was up, and the restored value won.
- DELETE /api/zim/outdated always recomputes the flag, even when nothing
  was deleted, so clicking Delete clears a stale banner.
- The modal re-scans when opened and says when nothing was found instead
  of showing a placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* zim: enforce restore-then-scan ordering; no Kiwix restart on empty delete

- Startup calls flags.init_flags() then flag_outdated_zim_files() right
  after db_up, before the DB config import, so the disk scan always
  overwrites the DB-restored value in one local sequence.
- DELETE /api/zim/outdated recomputes the flag in a finally.
- remove_outdated_zim_files skips refresh and the Kiwix restart when no
  file was deleted.
- Trim narrative comments to the constraint they enforce.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 15:19:36 -06:00

239 lines
7.6 KiB
Python

import asyncio
import urllib.parse
from http import HTTPStatus
from sanic import Request, response, Blueprint
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from wrolpi import lang
from wrolpi.api_utils import json_response
from wrolpi.common import logger
from wrolpi.db import get_db_session
from wrolpi.downloader import download_manager
from wrolpi.events import Events
from . import lib, schema
from .models import Zims
zim_bp = Blueprint('Zim', '/api/zim')
logger = logger.getChild(__name__)
@zim_bp.get('/')
@openapi.definition(
summary='List all known Zim files',
response=schema.GetZimsResponse,
)
async def get_zims(request: Request):
session = request.ctx.session
zims = lib.get_zims(session)
resp = {
'zims': [i.__json__() for i in zims],
}
return json_response(resp)
@zim_bp.delete('/<zim_ids:[0-9,]+>')
@openapi.definition(
summary='Delete all Zim files',
)
async def delete_zims(request: Request, zim_ids: str):
session = request.ctx.session
zim_ids = [int(i) for i in zim_ids.split(',')]
lib.delete_zims(session, zim_ids)
return response.empty()
@zim_bp.post('/<zim_id:[0-9]+>/auto_search')
@openapi.definition(
summary='Change if a Zim file will be searched by default.',
body=schema.ZimAutoSearchRequest,
)
@validate(schema.ZimAutoSearchRequest)
async def post_set_zim_auto_search(request: Request, zim_id: int, body: schema.ZimAutoSearchRequest):
session = request.ctx.session
zim_id = int(zim_id)
lib.set_zim_auto_search(session, zim_id, body.auto_search)
return response.empty()
@zim_bp.post('/search')
@openapi.definition(
summary='Search all Zim files that have auto_search enabled',
body=schema.ZimSearchRequest,
)
@validate(schema.ZimSearchRequest)
async def search_all_zims(_: Request, body: schema.ZimSearchRequest):
# libzim searches are synchronous and slow; run off the event loop. The session is opened in the
# worker thread: a SQLAlchemy Session must not be shared across threads.
def search():
with get_db_session() as session:
return lib.search_all_zims(session, body.search_str, tag_names=body.tag_names, offset=body.offset,
limit=body.limit)
results = await asyncio.to_thread(search)
return json_response({'zims': results})
@zim_bp.post('/search/<zim_id:[0-9]+>')
@openapi.definition(
summary='Search all entries of a Zim',
body=schema.ZimSearchRequest,
response=schema.ZimSearchResponse,
)
@validate(schema.ZimSearchRequest)
async def search_zim(_: Request, zim_id: int, body: schema.ZimSearchRequest):
def search():
with get_db_session() as session:
return lib.headline_zim(session, body.search_str, zim_id, tag_names=body.tag_names, offset=body.offset,
limit=body.limit)
headlines = await asyncio.to_thread(search)
return json_response({'zim': headlines})
@zim_bp.get('/<zim_id:[0-9]+>/entry/<zim_path:[ -~/]*>')
@openapi.definition(
summary='Read the entry at `zim_path` from the Zim file',
)
async def get_zim_entry(_: Request, zim_id: int, zim_path: str):
zim_path = urllib.parse.unquote(zim_path)
entry = lib.get_entry(zim_path, zim_id=zim_id)
content = bytes(entry.get_item().content)
try:
resp = response.html(content.decode('UTF-8'))
logger.debug(f'Returning ZIM HTML response {repr(str(zim_path))}')
except UnicodeDecodeError:
resp = response.raw(content)
logger.debug(f'Returning ZIM raw response {repr(str(zim_path))}')
return resp
@zim_bp.post('/tag')
@openapi.definition(
summary='Tag a Zim entry',
body=schema.TagZimEntry,
)
@validate(schema.TagZimEntry)
async def post_zim_tag(request: Request, body: schema.TagZimEntry):
session = request.ctx.session
await lib.add_tag(session, body.tag_name, body.zim_id, body.zim_entry)
return response.empty(HTTPStatus.CREATED)
@zim_bp.post('/untag')
@openapi.definition(
summary='Untag a Zim entry',
body=schema.TagZimEntry,
)
@validate(schema.TagZimEntry)
async def post_zim_untag(request: Request, body: schema.TagZimEntry):
session = request.ctx.session
await lib.untag(session, body.tag_name, body.zim_id, body.zim_entry)
return response.empty(HTTPStatus.NO_CONTENT)
@zim_bp.get('/subscribe')
@openapi.definition(
summary='Retrieve Zim subscriptions',
response=schema.ZimSubscriptions,
)
async def get_zim_subscriptions(request: Request):
catalog = lib.get_kiwix_catalog()
session = request.ctx.session
subscriptions = {i: j.__json__() for i, j in lib.get_kiwix_subscriptions(session).items()}
resp = dict(
catalog=catalog,
iso_639_codes=lang.ISO_639_CODES,
subscriptions=subscriptions,
)
return json_response(resp)
@zim_bp.post('/subscribe')
@openapi.definition(
summary='Subscribe to a particular Kiwix Zim',
body=schema.ZimSubscribeRequest,
)
@validate(schema.ZimSubscribeRequest)
async def post_zim_subscribe(request: Request, body: schema.ZimSubscribeRequest):
session = request.ctx.session
await lib.subscribe(session, body.name, body.language)
if download_manager.disabled or download_manager.stopped:
# Downloads are disabled, warn the user.
Events.send_downloads_disabled('Download created. But, downloads are disabled.')
Events.send_created(f'Zim subscription created')
return response.empty(HTTPStatus.CREATED)
@zim_bp.delete('/subscribe/<subscription_id:[0-9]+>')
@openapi.definition(
summary='Unsubscribe to a particular Kiwix Zim',
)
async def delete_zim_subscription(request: Request, subscription_id: int):
session = request.ctx.session
await lib.unsubscribe(session, subscription_id)
Events.send_deleted(f'Zim subscription deleted')
return response.empty(HTTPStatus.NO_CONTENT)
@zim_bp.get('/outdated')
@openapi.definition(
summary='Returns the outdated and current Zim files',
response=schema.OutdatedZims,
)
async def get_outdated_zims(_: Request):
outdated, current = lib.find_outdated_zim_files()
d = dict(outdated=outdated, current=current)
return json_response(d)
@zim_bp.delete('/outdated')
@openapi.definition(
summary='Remove all outdated Zims, if any.'
)
async def delete_outdated_zims(_: Request):
try:
deleted_count = await lib.remove_outdated_zim_files()
finally:
# The flag must match disk whether or not the delete succeeded.
lib.flag_outdated_zim_files()
if deleted_count:
Events.send_deleted(f'Deleted {deleted_count} outdated Zims')
return response.empty(HTTPStatus.NO_CONTENT)
@zim_bp.post('/search_estimates')
@validate(json=schema.SearchEstimateRequest)
async def post_search_estimates(request: Request, body: schema.SearchEstimateRequest):
"""Get an estimated count of FileGroups/Zims which may or may not have been tagged."""
if not body.search_str and not body.tag_names:
return response.empty(HTTPStatus.BAD_REQUEST)
def estimate():
zims_estimates = list()
with get_db_session() as session:
if body.tag_names:
# Get actual count of entries tagged with the tag names.
counts = Zims.entries_with_tags(session, body.tag_names)
else:
# Get estimates using libzim.
counts = Zims.estimate(session, body.search_str)
for zim, count in counts.items():
zims_estimates.append(dict(estimate=count, **zim.__json__()))
return zims_estimates
zims_estimates = await asyncio.to_thread(estimate)
ret = dict(
zims_estimates=zims_estimates
)
return json_response(ret)