mkdir -p /home/sebas/work/projects/rss-offline /var/www/rss-offline && ls -ld /home/sebas/work/projects/rss-offline /var/www/rss-offline

cd /home/sebas/work/projects/bb-004/freshrss-trial && sudo python - <<'PY'
import sqlite3, json
con=sqlite3.connect('/home/sebas/work/projects/bb-004/freshrss-trial/data/users/sebas/db.sqlite')
cur=con.cursor()
rows=cur.execute('SELECT name,url,website FROM feed ORDER BY name').fetchall()
print(json.dumps(rows, ensure_ascii=False, indent=2))
PY


mkdir: cannot create directory ‘/var/www/rss-offline’: Permission denied

Command exited with code 1


Armin Ronacher's Thoughts and Writings

Changelog on Tailscale

Cursor Blog | RSS Feed

David Heinemeier Hansson

DigitalOcean Blog

Earendil Posts

FreshRSS releases

Go Make Things

Infrequently Noted

Leadership in Tech

Martin Alderson

OpenClaw Blog

Peter Steinberger

Simon Willison’s Newsletter

Supernuclear

The GitHub Blog

Web Performance Calendar

{ Mario Zechner }


bash

mkdir -p /home/sebas/work/projects/rss-offline && sudo mkdir -p /var/www/rss-offline && ls -ld /home/sebas/work/projects/rss-offline /var/www/rss-offline


drwxrwxr-x 2 sebas sebas 4096 May 14 18:05 /home/sebas/work/projects/rss-offline
drwxr-xr-x 2 root  root  4096 May 14 18:05 /var/www/rss-offline


Successfully wrote 2576 bytes to /home/sebas/work/projects/rss-offline/feed-sources.json


write

#!/usr/bin/env python3
import email.utils
import hashlib
import json
import os
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from html import escape
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
SOURCES_PATH = BASE_DIR / 'feed-sources.json'
DATA_DIR = BASE_DIR / 'data'
OUTPUT_DIR = Path('/var/www/rss-offline')
MAX_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_ENTRIES', '250'))
DAYS = int(os.environ.get('RSS_OFFLINE_DAYS', '14'))
TIMEOUT = 20
HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}

def fetch(url: str) -> requests.Response:
    response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
    response.raise_for_status()
    return response

def text(node, path_options):
    for path in path_options:
        found = node.find(path)
        if found is not None and found.text and found.text.strip():
            return found.text.strip()
    return ''

def entry_links(item):
    for tag in ['link', '{http://www.w3.org/2005/Atom}link']:
        for node in item.findall(tag):
            href = node.get('href') or (node.text or '').strip()
            if href:
                rel = node.get('rel', 'alternate')
                if rel in ('alternate', ''):
                    return href
    return ''

def parse_timestamp(raw: str) -> int:
    raw = (raw or '').strip()
    if not raw:
        return 0
    try:
        return int(email.utils.parsedate_to_datetime(raw).timestamp())
    except Exception:
        pass
    try:
        return int(datetime.fromisoformat(raw.replace('Z', '+00:00')).timestamp())
    except Exception:
        return 0

def slug(url: str) -> str:
    return hashlib.sha256(url.encode()).hexdigest()[:16]

def extract_text(page_html: str) -> str:
    soup = BeautifulSoup(page_html, 'html.parser')
    for tag in soup(['script', 'style', 'noscript']):
        tag.decompose()
    title = soup.title.get_text(' ', strip=True) if soup.title else ''
    body = soup.body or soup
    lines = [line.strip() for line in body.get_text('\n', strip=True).splitlines() if line.strip()]
    text = '\n'.join(lines)
    return (title + '\n\n' + text).strip() if title and title not in text[:200] else text

def write_article_page(entry, article_text, article_file):
    article_file.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{escape(entry['title'])}</title>
← rss-offline · abrir original

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

{escape(article_text)}
''')

def build():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    sources = json.loads(SOURCES_PATH.read_text())
    cutoff = int((datetime.now(timezone.utc) - timedelta(days=DAYS)).timestamp())
    entries = []

    for source in sources:
        try:
            response = fetch(source['feedUrl'])
            xml_text = response.text
            root = ET.fromstring(xml_text)
        except Exception as e:
            print('FEED_ERR', source['name'], e)
            continue

        items = []
        if root.tag.endswith('rss'):
            channel = root.find('channel')
            items = channel.findall('item') if channel is not None else []
        else:
            items = root.findall('{http://www.w3.org/2005/Atom}entry')

        for item in items:
            title = text(item, ['title', '{http://www.w3.org/2005/Atom}title']) or source['name']
            link = entry_links(item)
            if not link:
                continue
            link = urljoin(source['siteUrl'], link)
            ts = parse_timestamp(text(item, ['pubDate', '{http://www.w3.org/2005/Atom}updated', '{http://www.w3.org/2005/Atom}published']))
            if ts and ts < cutoff:
                continue
            entries.append({
                'feed': source['name'],
                'feed_url': source['feedUrl'],
                'title': title,
                'link': link,
                'timestamp': ts,
            })

    entries.sort(key=lambda item: item['timestamp'], reverse=True)
    entries = entries[:MAX_ENTRIES]

    archived = []
    for entry in entries:
        try:
            response = fetch(entry['link'])
            article_text = extract_text(response.text)
            key = slug(entry['link'])
            article_file = OUTPUT_DIR / f'article-{key}.html'
            entry['local_path'] = f'/rss-offline/article-{key}.html'
            entry['date_label'] = datetime.fromtimestamp(entry['timestamp'] or time.time(), tz=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M')
            write_article_page(entry, article_text, article_file)
            archived.append(entry)
            print('OK', entry['link'])
        except Exception as e:
            print('ARTICLE_ERR', entry['link'], e)

    index = OUTPUT_DIR / 'index.html'
    sw = OUTPUT_DIR / 'sw.js'
    offline = OUTPUT_DIR / 'offline.html'
    json_index = OUTPUT_DIR / 'entries.json'

    json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))

    cards = []
    for entry in archived:
        cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')

    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')

    offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')

    sw.write_text("""const V='rss-offline-v1';const CORE=V+'-core';const PAGES=V+'-pages';self.addEventListener('install',e=>{e.waitUntil(caches.open(CORE).then(c=>c.addAll(['/rss-offline/','/rss-offline/offline.html','/rss-offline/entries.json'])));self.skipWaiting();});self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));});self.addEventListener('fetch',e=>{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{const cache=await caches.open(PAGES);try{const res=await fetch(r);if(res.ok) await cache.put(r,res.clone());return res;}catch{return await cache.match(r)||await caches.match('/rss-offline/offline.html')||Response.error();}})());});""")

if __name__ == '__main__':
    build()


Successfully wrote 9253 bytes to /home/sebas/work/projects/rss-offline/build.py


bash

chmod +x /home/sebas/work/projects/rss-offline/build.py && sudo /home/sebas/work/projects/rss-offline/build.py | sed -n '1,60p' && ls -1 /var/www/rss-offline | sed -n '1,40p'


OK https://github.blog/engineering/architecture-optimization/from-latency-to-instant-modernizing-github-issues-navigation-performance/
OK https://gomakethings.com/we-are-miracles/
OK https://leadershipintech.com/newsletters/2277-before-you-fire-all-your-glue-people-because-of-ai
OK https://tailscale.com/changelog/#2026-05-14-service
OK https://martinalderson.com/posts/managed-agents-are-the-new-lambda/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK https://openclaw.ai/blog/openai-models-in-openclaw-done-right/
OK https://www.digitalocean.com/blog/your-infrastructure-matters
OK https://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/
OK https://tailscale.com/changelog/#2026-05-13-client
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK https://github.com/FreshRSS/FreshRSS/releases/tag/1.29.0
OK https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
OK https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/
OK https://github.blog/news-insights/policy-news-and-insights/how-researchers-are-using-github-innovation-graph-data-to-reveal-the-digital-complexity-of-nations/
OK https://lucumr.pocoo.org/2026/5/8/local-models/
OK https://github.blog/ai-and-ml/github-copilot/improving-token-efficiency-in-github-agentic-workflows/
OK https://github.blog/ai-and-ml/generative-ai/agent-pull-requests-are-everywhere-heres-how-to-review-them/
OK https://supernuclear.substack.com/p/what-popup-villages-teach-us-about
OK https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai
OK https://github.blog/ai-and-ml/generative-ai/validating-agentic-behavior-when-correct-isnt-deterministic/
OK https://martinalderson.com/posts/open-weights-are-quietly-closing-up/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK https://openclaw.ai/blog/openclaw-rough-week/
OK https://www.digitalocean.com/blog/powering-the-inference-era
OK https://gomakethings.com/which-side-are-you-on/
OK https://lucumr.pocoo.org/2026/5/4/content-for-contents-sake/
OK https://martinalderson.com/posts/august-29-2026-a-scenario/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK https://leadershipintech.com/newsletters/2271-measure-less-to-learn-more
OK https://simonw.substack.com/p/deepseek-v4-and-the-end-of-the-openaimicrosoft
article-00517e9920463688.html
article-01abb99c09e69454.html
article-0409e49d86e8a171.html
article-07fe8783d6f3ac08.html
article-0b08cc8daf2cd6f8.html
article-0b4ff5a49b2af46f.html
article-22c38e85613911ce.html
article-30626cf6c11f2a57.html
article-30d6e4faa988619a.html
article-4d443773127eb5bb.html
article-57c425cc2a4fe9bb.html
article-5de306c8f07b39cf.html
article-60a3bacc8d20f732.html
article-66d66f99fd29fb39.html
article-74f95f5ce84e2847.html
article-763d0e7f1d9c5469.html
article-779e8c793e4c6f70.html
article-8ebb1572829e77ef.html
article-9084e1d1eaaaa4a1.html
article-9ec345ce998e8b2b.html
article-a4139cbabb03f50a.html
article-a5c352e10c62e36a.html
article-a7a71f6bda7d944f.html
article-a811a16760980904.html
article-baf0560bfe83e07a.html
article-be577c6425356c70.html
article-c858592a149e5a01.html
article-cafc54395b674132.html
article-cbebf207f4772426.html
article-ee31b1556857165d.html
article-f8d49f800ade15be.html
article-fa077056c8e5c708.html
article-fc2d142f0379dac3.html
entries.json
index.html
offline.html
sw.js


edit

    location = /rss {
        return 302 /rss/;
    }

    location = /rss-sw.js {

    location = /rss {
        return 302 /rss/;
    }

    location = /rss-offline {
        return 302 /rss-offline/;
    }

    location ^~ /rss-offline/ {
        alias /var/www/rss-offline/;
        try_files $uri $uri/ /rss-offline/index.html;
    }

    location = /rss-sw.js {


Successfully replaced 1 block(s) in /etc/nginx/sites-available/kiosk.

     ...
  99     location = /rss {
 100         return 302 /rss/;
 101     }
 102
+103     location = /rss-offline {
+104         return 302 /rss-offline/;
+105     }
+106
+107     location ^~ /rss-offline/ {
+108         alias /var/www/rss-offline/;
+109         try_files $uri $uri/ /rss-offline/index.html;
+110     }
+111
 103     location = /rss-sw.js {
 104         add_header Service-Worker-Allowed "/rss/" always;
 105     }
 106
     ...


Successfully replaced 1 block(s) in /etc/nginx/sites-enabled/kiosk.


      RSS offline
      Lista por fecha con copias locales y links reales.

      RSS
      FreshRSS trial local.


Successfully replaced 1 block(s) in /var/www/portal/index.html.

...
  48
  49       Musica
  50       MP3 compartidos.
  51
+ 52
+ 53       RSS offline
+ 54       Lista por fecha con copias locales y links reales.
+ 55
  52
  53       RSS
  54       FreshRSS trial local.
  55
     ...


bash

(crontab -l 2>/dev/null | grep -v 'rss-offline/build.py'; echo '*/30 * * * * sudo /home/sebas/work/projects/rss-offline/build.py >> /home/sebas/runtime/rss-offline-build.log 2>&1') | crontab -
sudo nginx -t && sudo systemctl reload nginx
python - <<'PY'
import requests
for url in [
 'https://ballbox-first.emperor-ratio.ts.net/rss-offline/',
 'https://ballbox-first.emperor-ratio.ts.net/rss-offline/entries.json'
]:
 r=requests.get(url,timeout=20)
 print(url, r.status_code, r.text[:180].replace('\n',' '))
PY


2026/05/14 18:08:37 [warn] 1165846#1165846: duplicate MIME type "text/html" in /etc/nginx/sites-enabled/kiosk:129
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
https://ballbox-first.emperor-ratio.ts.net/rss-offline/ 200 <!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss
https://ballbox-first.emperor-ratio.ts.net/rss-offline/entries.json 200 [   {     "feed": "The GitHub Blog",     "feed_url": "https://github.blog/feed/",     "title": "From latency to instant: Modernizing GitHub Issues navigation performance",     "lin


Done.

New route:
- https://ballbox-first.emperor-ratio.ts.net/rss-offline/

What it is:
- date-sorted list
- real blog links
- local copy for each article
- service worker on same route
- generated from feeds directly, not FreshRSS

Also set:
- auto rebuild every 30 min
- log:
  - `/home/sebas/runtime/rss-offline-build.log`

Portal:
- added `RSS offline` card on home

Kept:
- `/rss/` still exists
- but `/rss-offline/` is the one aligned with what you asked for

If you want next, I can:
- hide/de-emphasize FreshRSS
- or tune `rss-offline` to fewer/more days/articles.


[telegram] no precargado hasta que no navegue, y ahí aún así a veces siguen sin estar cachados


**Clarifying limitations and next steps**

I need to provide a concise answer explaining the limitations we're facing. There might not be any tools required for this. I’ll mention that we can potentially resolve the issue by using proxying or local copies instead of relying on cross-origin prefetch. This could strengthen our approach and overcome the limitations we're encountering. It’s essential to communicate this clearly and effectively to ensure understanding!

Sí. Eso confirma el límite real:

- `prefetch` a links externos es débil
- el navegador puede ignorarlo
- y aunque lo haga, no siempre queda usable offline
- sobre todo en iPhone/Safari

Entonces:
- **no podemos confiar en cachear blogs externos directo**
- la solución robusta es:
  - **leer la copia local**
  - no el link externo

O sea, para que funcione de verdad:
1. en `rss-offline` la lista abre por default la **copia local**
2. dejar botón aparte para:
   - `abrir original`
3. si querés, precargamos más artículos recientes del lado servidor
   - pero siempre como copia local

Ese cambio sí te da comportamiento consistente.

Recomendación:
- cambiar `rss-offline` para que:
  - click principal = copia local
  - link secundario = blog real

Si querés, lo hago ya.


[telegram] Hacelo, pero además hay que anden de la copia local, porque sin conexión no cargaban hasta que cargue una vez manualmente


**Fixing alias path issues**

I'm considering whether I need to address a potential fallback issue with the alias path in the service worker (SW). It seems like local copies aren't being cached until they're accessed. I think I should proactively cache all generated local article pages through a core list or by creating a pre-cache manifest. This would involve implementing an entries.json file for listing, ensuring that SW installs cache all local article pages and index links centrally. Sounds like a good plan!

todo

Make rss-offline local copies default and precached

Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.


Created #16: Make rss-offline local copies default and precached (pending)

BB-002 audit gomakethings patterns for Ballbox

Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.

BB-003 inventory nginx-served surfaces for offline/navigation improvements

Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.

BB-004 define self-hosted RSS/offline reader product bar

Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.

BB-002 prepare reviewable Ballbox offline prototype plan

Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.

BB-003 prepare reviewable Pi web offline rollout plan

Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.

BB-004 prepare OSS RSS reader shortlist and review plan

Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.

Make copyparty file URLs more direct and preserve old /files/home links

Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.

Re-focus on Ballbox QR testing

Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.

Fix Mercado Pago QR webhook product id mapping

Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.

Fix Mercado Pago QR webhook amount parsing

Accept Mercado Pago order total_amount as string or number so approved QR orders persist.

Reprioritize Ballbox work away from OPS-2 and machine-dependent payments

Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.

BB-003 implement portal offline slice 1

Harden portal service worker with explicit offline fallback and bounded caches.

BB-002 prepare Ballbox public offline slice 1 for implementation

Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.

BB-004 prepare FreshRSS local trial setup

Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.

Build minimal rss-offline reader

Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.


Updated #16 (pending → in_progress)

Re-focus on Ballbox QR testing

BB-003 implement portal offline slice 1

Build minimal rss-offline reader


edit

for entry in archived:
        cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')

    precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]
    sw.write_text(f"""const V='rss-offline-v2';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});""")


Successfully replaced 2 block(s) in /home/sebas/work/projects/rss-offline/build.py.

...
 148     json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))
 149
 150     cards = []
 151     for entry in archived:
-152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')
+152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')
 153
 154     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')
 155
 156     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')
 157
-158     sw.write_text("""const V='rss-offline-v1';const CORE=V+'-core';const PAGES=V+'-pages';self.addEventListener('install',e=>{e.waitUntil(caches.open(CORE).then(c=>c.addAll(['/rss-offline/','/rss-offline/offline.html','/rss-offline/entries.json'])));self.skipWaiting();});self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));});self.addEventListener('fetch',e=>{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{const cache=await caches.open(PAGES);try{const res=await fetch(r);if(res.ok) await cache.put(r,res.clone());return res;}catch{return await cache.match(r)||await caches.match('/rss-offline/offline.html')||Response.error();}})());});""")
+158     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]
+159     sw.write_text(f"""const V='rss-offline-v2';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});""")
 159
 160
 161 if __name__ == '__main__':
 162     build()


bash

sudo /home/sebas/work/projects/rss-offline/build.py >/tmp/rss-offline-build.out && sed -n '1,20p' /tmp/rss-offline-build.out && python - <<'PY'
import requests, re
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
print('local first', '
<a href="/rss-offline/article-' in idx)
sw=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/sw.js',timeout=20).text
print('v2', 'rss-offline-v2' in sw)
print('precache_count', sw.count('/rss-offline/article-'))
PY


OK https://github.blog/news-insights/company-news/github-availability-report-april-2026/
OK https://github.blog/engineering/architecture-optimization/from-latency-to-instant-modernizing-github-issues-navigation-performance/
OK https://gomakethings.com/we-are-miracles/
OK https://leadershipintech.com/newsletters/2277-before-you-fire-all-your-glue-people-because-of-ai
OK https://tailscale.com/changelog/#2026-05-14-service
OK https://martinalderson.com/posts/managed-agents-are-the-new-lambda/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK https://openclaw.ai/blog/openai-models-in-openclaw-done-right/
OK https://www.digitalocean.com/blog/your-infrastructure-matters
OK https://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/
OK https://tailscale.com/changelog/#2026-05-13-client
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://tailscale.com/changelog/#2026-05-13-service
OK https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK https://github.com/FreshRSS/FreshRSS/releases/tag/1.29.0
OK https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
local first True
v2 True
precache_count 35


Updated #16 (in_progress → completed)

Re-focus on Ballbox QR testing

BB-003 implement portal offline slice 1

Build minimal rss-offline reader


Hecho.

Cambios:
1. `rss-offline` ahora abre por default la **copia local**
- click principal = copia local
- link secundario = blog real

2. service worker endurecido
- ahora **precacha las copias locales generadas**
- no hace falta abrir cada artículo una vez para que quede offline

Estado nuevo:
- versión SW:
  - `rss-offline-v2`
- copias locales precargadas ahora:
  - 35 artículos

Qué probar:
1. abrir:
- https://ballbox-first.emperor-ratio.ts.net/rss-offline/

2. esperar un poco a que cargue normal

3. cortar conexión

4. volver a entrar a:
- la lista
- uno o dos artículos locales

Ahora sí deberían abrir offline sin visita previa manual a cada uno.


[telegram] parece funcionar el 10% de las veces. Sin embargo, como alternativa ingresar 1 × 1 ya es una mejora con lo que tenía antes. Si tienes alguna otra idea para mejorar ese access rate hacelo y si no por ahora ademaslo por terminado


**Considering service worker improvements**

I need to keep my answer concise. I might suggest increasing the precache timing since the service worker might not wait long enough before going offline. It could be beneficial to show synchronization status on the index. The user asked if there's another way to improve, so I think a quick improvement would be adding a visible warmup status and even prefetching local articles on load. Implementing a chunked warmup from page JavaScript using fetch sounds good but requires some modification in build.py.

todo

Improve rss-offline local article warmup hit rate

Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.


Created #17: Improve rss-offline local article warmup hit rate (pending)

Re-focus on Ballbox QR testing

BB-003 implement portal offline slice 1

Build minimal rss-offline reader

Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.


Updated #17 (pending → in_progress)

Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.

Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.

Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.

Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.

Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.

Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.

Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.

Re-focus on Ballbox QR testing

Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.

Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.

Accept Mercado Pago order total_amount as string or number so approved QR orders persist.

Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.

BB-003 implement portal offline slice 1

Harden portal service worker with explicit offline fallback and bounded caches.

Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.

Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.

Build minimal rss-offline reader

Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.

Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.


edit

local_paths = [entry['local_path'] for entry in archived]
    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')


Successfully replaced 1 block(s) in /home/sebas/work/projects/rss-offline/build.py.

...
 150     cards = []
 151     for entry in archived:
 152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')
 153
-154     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')
+154     local_paths = [entry['local_path'] for entry in archived]
+155     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')
 155
 156     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')
 157
 158     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]
     ...


bash

sudo /home/sebas/work/projects/rss-offline/build.py >/tmp/rss-offline-build2.out && python - <<'PY'
import requests
html=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
print('warm paths const', 'RSS_OFFLINE_LOCAL_PATHS' in html)
print('force-cache fetch', "cache:'force-cache'" in html)
PY


warm paths const True
force-cache fetch True


Updated #17 (in_progress → completed)

Re-focus on Ballbox QR testing

BB-003 implement portal offline slice 1

Build minimal rss-offline reader

Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.


Hice una mejora más.

Nuevo:
- además del precache por service worker
- la página ahora hace **warmup activo** de todas las copias locales al cargar
- o sea:
  - entra a `/rss-offline/`
  - espera un toque
  - intenta fetchear localmente todos los artículos
  - eso debería subir bastante el hit rate

No te prometo 100%
- Safari/iPhone sigue siendo caprichoso con caché
- pero esto es más fuerte que lo anterior

Conclusión práctica:
- si con esto te alcanza, lo damos por terminado
- y como fallback, abrir 1x1 sigue funcionando mejor que antes

Mi lectura:
- ya está en “good enough” para cerrar por ahora

Si querés cerrar:
- lo tomo como terminado.


[telegram] me gusta dejado documentado el estado actual donde haga falta


**Considering documentation updates**

I'm thinking it might be time to update the documentation, especially the backlogs. I suspect there are relevant details in pi-config that I should mention. Additionally, I could include the current state of rss-offline in the documentation related to bb-004. It feels like I need to execute a clear plan for these documentation updates to keep everything organized and useful! Let's see how I can tackle this efficiently.

rg -n "rss-offline|FreshRSS|BB-004|feed" /home/sebas/pi-config/docs /home/sebas/pi-config/docs/BACKLOG.md /home/sebas/work/projects/ballbox 2>/dev/null | head -200


/home/sebas/pi-config/docs/BACKLOG.md:81:  - Ballbox already has a maintainability note pushing away from global route-feedback DOM mutation toward localized pending boundaries
/home/sebas/pi-config/docs/BACKLOG.md:94:  - tightly related to BB-004 because feed reading is one concrete use case
/home/sebas/pi-config/docs/BACKLOG.md:100:### BB-004 - Self-hosted RSS/feed system for offline reading
/home/sebas/pi-config/docs/BACKLOG.md:102:- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
/home/sebas/pi-config/docs/BACKLOG.md:113:  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
/home/sebas/pi-config/docs/bb-004-freshrss-trial.md:1:# BB-004 FreshRSS trial on ballbox-first
/home/sebas/pi-config/docs/bb-004-freshrss-trial.md:10:- FreshRSS official image supports `arm64`.
/home/sebas/pi-config/docs/bb-004-freshrss-trial.md:12:- Nginx exists, but wiring FreshRSS behind an existing shared path is extra work vs a localhost-only trial.
/home/sebas/pi-config/docs/bb-004-freshrss-trial.md:16:- Run FreshRSS in Docker only.
/home/sebas/pi-config/docs/bb-004-freshrss-trial.md:64:- Create admin user in FreshRSS setup.
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:3:Concrete next deliverables for review/test on BB-002, BB-003, BB-004.
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:56:### Slice 3 — Replace global route feedback with local pending boundaries on public forms
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:62:  - `components/route-feedback.tsx`
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:193:## BB-004 — OSS self-hosted RSS reader review plan
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:202:- feed import/subscription
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:209:#### 1. FreshRSS
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:247:  - test if reading UX feels better than TT-RSS/FreshRSS
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:261:1. FreshRSS
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:269:- Import a representative feed set
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:281:#### Slice 1 — Stand up FreshRSS trial instance
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:285:  - seed with sample feeds
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:292:  - if FreshRSS is good enough, stop and add offline shell later
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:312:3. Stand up BB-004 FreshRSS trial
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:321:- After BB-004 slice 1:
/home/sebas/pi-config/docs/backlog-review-slices-2026-05-12.md:322:  - test feed reading UX on phone and in airplane mode
/home/sebas/pi-config/docs/BACKLOG.md:81:  - Ballbox already has a maintainability note pushing away from global route-feedback DOM mutation toward localized pending boundaries
/home/sebas/pi-config/docs/BACKLOG.md:94:  - tightly related to BB-004 because feed reading is one concrete use case
/home/sebas/pi-config/docs/BACKLOG.md:100:### BB-004 - Self-hosted RSS/feed system for offline reading
/home/sebas/pi-config/docs/BACKLOG.md:102:- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
/home/sebas/pi-config/docs/BACKLOG.md:113:  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
/home/sebas/pi-config/docs/snippets/freshrss-trial.compose.yml:13:      - ./data:/var/www/FreshRSS/data
/home/sebas/work/projects/ballbox/README.md:153:- Clubs nuevos quedan arriba en admin para feedback inmediato
/home/sebas/work/projects/ballbox/app/layout.tsx:5:import { RouteFeedback } from '@/components/route-feedback'
/home/sebas/work/projects/ballbox/docs/maintainability-priorities-2026-03.md:24:  - P1.7 route feedback redesign
/home/sebas/work/projects/ballbox/docs/maintainability-priorities-2026-03.md:162:### 7. Replace global imperative route feedback with narrower pending UX
/home/sebas/work/projects/ballbox/docs/maintainability-priorities-2026-03.md:164:- Problem: `components/route-feedback.tsx` mutates document-level interactions, styles, and disabled states.
/home/sebas/work/projects/ballbox/docs/maintainability-priorities-2026-03.md:166:  - `components/route-feedback.tsx`


- open_questions:
  - which Ballbox surfaces matter most first: docs/blog/public marketing vs authenticated admin/player?
  - does App Router already cover enough history/scroll restoration, or is custom restoration still needed on key screens?
  - should offline support stop at read-only surfaces first?
- next_step:
  - prototype the smallest read-only slice in Ballbox: network-first page caching for visited HTML, offline fallback page, bounded image/font cache, and explicit cache cleanup signal
- notes:
  - user explicitly called out offline visited-page navigation and restoring prior scroll position when going back
  - live site uses strong progressive enhancement: plain HTML first, one inline stylesheet, RSS available, minimal JS assumptions
  - service worker pattern is simple and durable: separate versioned caches for core/pages/images, network-first for HTML, offline-first for images/fonts, bounded cache sizes, explicit old-cache cleanup
  - likely Ballbox order: 1) public/read-only surfaces, 2) localized pending UX and native history/scroll behavior, 3) selective offline support for authenticated read paths only if worth complexity
  - Ballbox already has a maintainability note pushing away from global route-feedback DOM mutation toward localized pending boundaries

### BB-003 - Apply similar offline/navigation ideas to this machine's nginx-served surfaces
- status: `researching`
- goal: improve offline navigation and resilience for the Pi's nginx-served web surfaces, especially portal-like routes
- why: enables preloading and later offline use during travel/flights
- open_questions:
  - should offline support be portal-wide, or isolated per subpath (`/`, `/wifi-tracker/`, `/domotics/`, `/calistenia/`)?
  - is `/portal/` itself a better shell for preload/offline entry than `/`?
  - how much dynamic/API freshness is needed for `/wifi-tracker/` and `/domotics/` when offline?
- next_step:
  - start with one low-risk read-mostly surface: portal shell or `/calistenia/`, then decide whether to add per-surface service workers for `/wifi-tracker/` and `/domotics/`
- notes:
  - tightly related to BB-004 because feed reading is one concrete use case
  - current nginx surfaces: SPA root `/`, proxied `/portal/`, static SPA-like `/calistenia/`, proxied file browser `/files/`, static directory listings `/mp3-share/` and `/reports/`, static+API hybrids `/wifi-tracker/` and `/domotics/`, proxied `/pi/`
  - best offline candidates: `/`, `/portal/` if assets can be controlled, `/calistenia/`, `/wifi-tracker/` shell, `/domotics/` shell
  - poor/off-limits candidates for aggressive offline caching: `/files/`, `/reports/`, `/mp3-share/`, `/pi/`
  - likely architecture: separate offline behavior per surface/subpath, not one broad catch-all service worker across unrelated apps

### BB-004 - Self-hosted RSS/feed system for offline reading
- status: `ready`
- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
- why: better control, better offline use on flights, and integration with local portal/nginx improvements
- open_questions:
  - which current reader/app is being replaced, and which pain is worst today: offline failure, UX, or sync?
  - how much full-text extraction is needed vs headline+summary+open-original?
  - is a web-only reader enough at first, or is share-to-read-later/import flow required immediately?
- next_step:
  - evaluate one existing self-hosted reader first against the minimal product bar, before considering custom build
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
  - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion
  - build-vs-buy bias should be buy-first: existing OSS/self-hosted reader plus local offline shell is lower effort than custom reader core


# BB-004 FreshRSS trial on ballbox-first

## Verdict

Use Docker Compose.

Why:
- Docker + Compose already installed.
- `sudo` works here.
- FreshRSS official image supports `arm64`.
- Native PHP/FPM is not installed.
- Nginx exists, but wiring FreshRSS behind an existing shared path is extra work vs a localhost-only trial.

## Lowest-risk trial shape

- Run FreshRSS in Docker only.
- Bind only to `127.0.0.1`.
- Use SQLite for trial data.
- Keep files in a canonical work tree, not `/tmp`.
- Do **not** change nginx for the first boot.

Suggested work dir:

```bash
/home/sebas/work/projects/bb-004/freshrss-trial
```

Suggested data dir:

```bash
/home/sebas/work/projects/bb-004/freshrss-trial/data
```

## Compose snippet

Saved at:

```bash
/home/sebas/pi-config/docs/snippets/freshrss-trial.compose.yml
```

Use it as a starting point.

## Bring-up commands

```bash
mkdir -p /home/sebas/work/projects/bb-004/freshrss-trial/data
cp /home/sebas/pi-config/docs/snippets/freshrss-trial.compose.yml \
  /home/sebas/work/projects/bb-004/freshrss-trial/compose.yml
cd /home/sebas/work/projects/bb-004/freshrss-trial
sudo docker compose up -d
```

Open:

```bash
http://127.0.0.1:8095/
```

If testing from another device later, add a reversible proxy step after local validation.

## First-login notes

- Create admin user in FreshRSS setup.
- Prefer SQLite for the trial.
- If asked for base URL, use `http://127.0.0.1:8095` for the first pass.

## Cleanup

```bash
cd /home/sebas/work/projects/bb-004/freshrss-trial
sudo docker compose down
```

Remove data too:

```bash
rm -rf /home/sebas/work/projects/bb-004/freshrss-trial
```

## Optional later: nginx exposure

Only after local validation.

Best next step is a dedicated vhost or subdomain.
Subpath like `/freshrss/` is possible, but usually more fragile for apps that assume root paths.

If exposure is needed fast, safer order:
1. validate on `127.0.0.1:8095`
2. reverse proxy from nginx to that port
3. only then decide between `/freshrss/` vs dedicated host


**Updating documentation**

I need to update the documentation for BB-004 and possibly create a new document for rss-offline. I should write this new document and make sure to update the BACKLOG with the next steps. I also need to mention that the freshRSS document is now superseded. It sounds like a bit of work, but I think organizing these updates will help keep everything clear for everyone involved!

write

# BB-004 rss-offline status — 2026-05-14

Current state for offline feed reading on `ballbox-first`.

## Outcome

FreshRSS trial was useful for feed import and feed discovery, but it is **not** the primary reader path anymore.

Primary path now:

- `https://ballbox-first.emperor-ratio.ts.net/rss-offline/`

FreshRSS remains available as a side tool/trial at:

- `https://ballbox-first.emperor-ratio.ts.net/rss/`

## Why the pivot happened

FreshRSS + browser caching was not reliable enough for the main user goal:

- read recent content offline on phone
- avoid opening blogs one by one first

The weak point was cross-origin caching of external blog pages from mobile browsers.
That made offline hit rate inconsistent even after prefetch attempts.

## What `rss-offline` does

- reads a curated feed list from local config
- fetches recent entries directly from feeds
- sorts entries by date descending
- downloads recent linked articles server-side
- stores a local readable HTML copy for each article
- serves a static web UI at `/rss-offline/`
- makes the **local copy** the primary click target
- keeps a secondary link to the real/original blog
- registers a service worker under `/rss-offline/`
- precaches generated local article pages for better offline behavior
- runs a rebuild cron every 30 minutes

## Current files and paths

Project source:

- `/home/sebas/work/projects/rss-offline/`
- `/home/sebas/work/projects/rss-offline/feed-sources.json`
- `/home/sebas/work/projects/rss-offline/build.py`

Generated site:

- `/var/www/rss-offline/`
- `/var/www/rss-offline/index.html`
- `/var/www/rss-offline/entries.json`
- `/var/www/rss-offline/sw.js`
- `/var/www/rss-offline/offline.html`
- `/var/www/rss-offline/article-*.html`

Logs:

- `/home/sebas/runtime/rss-offline-build.log`

## Current behavior limits

- offline reliability is better for local copies than for real blog links
- Safari/iPhone caching still may not be perfect for every generated page every time
- opening articles one by one still improves confidence, but this is already materially better than the original FreshRSS-only path
- no category/tag UX yet; the current product is a date-sorted list only
- no full-text extraction rules per site; current article copies use a generic text extraction pass

## Operational notes

Cron currently rebuilds every 30 minutes.
That means:

- new recent articles should appear automatically
- local copies should refresh automatically
- service worker cache version only changes when the generated file content changes

## Product direction for now

Treat `rss-offline` as the current best offline reading path.
Do not invest more time in FreshRSS-first offline behavior unless a future need appears for:

- better feed management UX
- multi-user reader features
- categorization/search heavier than the current simple offline list


Successfully wrote 2856 bytes to /home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md


edit

### BB-004 - Self-hosted RSS/feed system for offline reading
- status: `ready`
- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
- why: better control, better offline use on flights, and integration with local portal/nginx improvements
- open_questions:
  - which current reader/app is being replaced, and which pain is worst today: offline failure, UX, or sync?
  - how much full-text extraction is needed vs headline+summary+open-original?
  - is a web-only reader enough at first, or is share-to-read-later/import flow required immediately?
- next_step:
  - evaluate one existing self-hosted reader first against the minimal product bar, before considering custom build
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
  - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion
  - build-vs-buy bias should be buy-first: existing OSS/self-hosted reader plus local offline shell is lower effort than custom reader core

### BB-004 - Self-hosted RSS/feed system for offline reading
- status: `researching`
- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
- why: better control, better offline use on flights, and integration with local portal/nginx improvements
- open_questions:
  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
  - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
  - should feed management stay in FreshRSS sidecar form, or move into the custom reader later?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - FreshRSS trial is still available at `/rss/`, but it is no longer the primary offline reader path
  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
  - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion


Successfully replaced 1 block(s) in /home/sebas/pi-config/docs/BACKLOG.md.

     ...
  97   - poor/off-limits candidates for aggressive offline caching: `/files/`, `/reports/`, `/mp3-share/`, `/pi/`
  98   - likely architecture: separate offline behavior per surface/subpath, not one broad catch-all service worker across unrelated apps
  99
 100 ### BB-004 - Self-hosted RSS/feed system for offline reading
-101 - status: `ready`
+101 - status: `researching`
 102 - goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
 103 - why: better control, better offline use on flights, and integration with local portal/nginx improvements
 104 - open_questions:
-105   - which current reader/app is being replaced, and which pain is worst today: offline failure, UX, or sync?
-106   - how much full-text extraction is needed vs headline+summary+open-original?
-107   - is a web-only reader enough at first, or is share-to-read-later/import flow required immediately?
+105   - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
+106   - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
+107   - should feed management stay in FreshRSS sidecar form, or move into the custom reader later?
 108 - next_step:
-109   - evaluate one existing self-hosted reader first against the minimal product bar, before considering custom build
+109   - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
 110 - notes:
 111   - should be evaluated together with BB-003 service-worker / offline-cache strategy
 112   - constraint: no paid options
+113   - FreshRSS trial is still available at `/rss/`, but it is no longer the primary offline reader path
+114   - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
+115   - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
+116   - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
 113   - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
 114   - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion
-115   - build-vs-buy bias should be buy-first: existing OSS/self-hosted reader plus local offline shell is lower effort than custom reader core


# BB-004 FreshRSS trial on ballbox-first

> Status note: FreshRSS is still running and reachable at `/rss/`, but it is now a side tool/trial, not the primary offline reader path. Primary path is documented in `bb-004-rss-offline-status-2026-05-14.md`.


Validation failed for tool "edit":
  - edits.0.newText: must have required properties newText
  - edits.0: must not have additional properties

Received arguments:
{
  "path": "/home/sebas/pi-config/docs/bb-004-freshrss-trial.md",
  "edits": [
    {
      "oldText": "# BB-004 FreshRSS trial on ballbox-first\n",
      "# newText": "# BB-004 FreshRSS trial on ballbox-first\n\n> Status note: FreshRSS is still running and reachable at `/rss/`, but it is now a side tool/trial, not the primary offline reader path. Primary path is documented in `bb-004-rss-offline-status-2026-05-14.md`.\n\n"
    }
  ]
}


Successfully replaced 1 block(s) in /home/sebas/pi-config/docs/bb-004-freshrss-trial.md.

  1 # BB-004 FreshRSS trial on ballbox-first
  2
+ 3 > Status note: FreshRSS is still running and reachable at `/rss/`, but it is now a side tool/trial, not the primary offline reader path. Primary path is documented in `bb-004-rss-offline-status-2026-05-14.md`.
+ 4
+ 5
  3 ## Verdict
  4
  5 Use Docker Compose.
  6
    ...


Done.

Documented current state in:
- `/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`

Updated:
- `/home/sebas/pi-config/docs/BACKLOG.md`
- `/home/sebas/pi-config/docs/bb-004-freshrss-trial.md`

What’s captured:
- `rss-offline` is now the primary path
- `FreshRSS` is sidecar/trial only
- why the pivot happened
- current files, routes, cron, limits
- what still works vs what is unreliable

Main URL documented:
- `https://ballbox-first.emperor-ratio.ts.net/rss-offline/`

We can treat this as closed for now.


/new


queued new Telegram Pi session: pi-telegram