Compare commits

..
11 Commits
Author SHA1 Message Date
andrea 422a3548fd Enhance note management: add corrected transcript field, update UI for reading mode, and improve archive display 2026-09-28 17:17:58 +02:00
andrea bc60c16079 Enhance WebDAV backup functionality: implement backup rotation for snapshots and update UI description for backup settings 2026-09-28 17:05:07 +02:00
andrea 229b8d1ab1 Implement WebDAV backup feature: add configuration options, API endpoints, and UI for managing database backups 2026-09-28 16:37:37 +02:00
andrea 01f2a92a84 Refactor code structure for improved readability and maintainability 2026-09-28 15:54:49 +02:00
andrea 0fb66f2cee Update README and API integration: specify local ASR server details and adjust audio upload parameters 2026-09-28 14:05:39 +02:00
andrea 8d4f46a7d1 Enhance text note functionality: add API for creating and managing written notes, implement draft saving, and improve UI for note capture modes 2026-09-28 12:36:44 +02:00
andrea f4100af8ec change registry url 2026-09-28 11:31:17 +02:00
andrea c883fbc47f Update upload confirmation message and enhance capture message styles 2026-09-28 11:29:52 +02:00
andrea 651c6de392 Enhance release process: require new commits for publishing, improve local tag cleanup after successful pushes 2026-09-28 10:58:13 +02:00
andrea cb16c77bad Enhance release process: require clean worktree for publishing, update tagging behavior, and improve UI for audio file uploads 2026-09-28 08:14:27 +02:00
andrea 0f7d5e950c Enhance audio processing capabilities: add FFmpeg for audio transcoding, update database schema, and improve UI for audio file uploads 2026-09-26 21:51:50 +02:00
14 changed files with 1015 additions and 70 deletions
+2 -1
View File
@@ -19,10 +19,11 @@ Faerro KB is a self-hosted voice-note inbox designed for an iPhone-installed web
- Preserve the local-first capture and retry behavior when changing the PWA, API, or service worker. Prefer additive, recoverable data changes. - Preserve the local-first capture and retry behavior when changing the PWA, API, or service worker. Prefer additive, recoverable data changes.
- Follow the existing small stack: FastAPI, SQLite/FTS5, and the vanilla JavaScript PWA. Avoid new dependencies unless they solve a real need and can run on the user's infrastructure. - Follow the existing small stack: FastAPI, SQLite/FTS5, and the vanilla JavaScript PWA. Avoid new dependencies unless they solve a real need and can run on the user's infrastructure.
- Keep changes scoped. Inspect the owning code path, add or update focused tests for behavior changes, and run the narrowest relevant checks available. - Keep changes scoped. Inspect the owning code path, add or update focused tests for behavior changes, and run the narrowest relevant checks available.
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it on the local machine; do not silently skip the check.
- Update README/UI documentation when behavior, deployment, security, backup, or platform limitations change. Do not claim a capability that iOS or browsers cannot guarantee. - Update README/UI documentation when behavior, deployment, security, backup, or platform limitations change. Do not claim a capability that iOS or browsers cannot guarantee.
- Preserve user data and existing local changes. Do not perform destructive migrations or remove stored recordings without explicit confirmation and a recovery path. - Preserve user data and existing local changes. Do not perform destructive migrations or remove stored recordings without explicit confirmation and a recovery path.
- At the end of each chat, check whether it introduced durable project requirements, decisions, workflows, or corrections missing from these instructions or a relevant skill. Update the narrowest relevant guidance file when the change is clearly reusable and within the user's authorized scope; otherwise note the gap and ask before expanding scope. Do not record transient task details or sensitive information. - At the end of each chat, check whether it introduced durable project requirements, decisions, workflows, or corrections missing from these instructions or a relevant skill. Update the narrowest relevant guidance file when the change is clearly reusable and within the user's authorized scope; otherwise note the gap and ask before expanding scope. Do not record transient task details or sensitive information.
## Releases ## Releases
Git tags are the source of truth for image versions. The publish script requires the checked-out commit to have an exact `MAJOR.MINOR.PATCH` tag, optionally prefixed with `v`. Do not invent a version for a future release; use the version explicitly selected by the user or ask. Publishing pushes both the versioned image and `latest` to the configured private Gitea registry, so confirm registry authentication is available before treating a release as complete. Git tags are the source of truth for image versions. The publish script requires a clean worktree, chooses the next version from existing `MAJOR.MINOR.PATCH` tags, and creates an annotated local tag on the checked-out commit. Do not invent a version for a future release; use the script's next-version behavior or the version explicitly selected by the user. The script does not push Git tags. Publishing pushes both the versioned image and `latest` to the configured private Gitea registry, so confirm registry authentication is available before treating a release as complete.
@@ -26,5 +26,6 @@ Use this skill for feature work, bug fixes, and reviews in this repository. The
- Add or update focused tests for changed behavior, especially queue acknowledgement, retries, and data-preserving transcript workflows. - Add or update focused tests for changed behavior, especially queue acknowledgement, retries, and data-preserving transcript workflows.
- Run the narrowest relevant test, compile, or lint check available, then document any important limitation or unverified check. - Run the narrowest relevant test, compile, or lint check available, then document any important limitation or unverified check.
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it locally instead of silently skipping the check.
- Update `README.md` when setup, deployment, backups, privacy, or platform behavior changes. - Update `README.md` when setup, deployment, backups, privacy, or platform behavior changes.
- Never claim that iOS guarantees background execution or that browser storage is permanent. - Never claim that iOS guarantees background execution or that browser storage is permanent.
+4 -4
View File
@@ -9,11 +9,11 @@ Use this skill when asked to tag or publish a Faerro KB image. The release proce
## Procedure ## Procedure
1. Check the worktree and identify the exact commit being released. Do not tag unrelated or unreviewed changes. 1. Require a clean worktree and identify the exact committed `HEAD` being released. Staged, unstaged, or untracked changes must be committed or removed before running the script. Do not tag unrelated or unreviewed changes.
2. The script selects the next patch version by default. Use `--minor` or `--major` only when the user requests that increment; lower components are reset. Tags must be exact `MAJOR.MINOR.PATCH`. 2. The script requires at least one commit after the latest exact `MAJOR.MINOR.PATCH` tag, then selects the next patch version by default. Use `--minor` or `--major` only when the user requests that increment; lower components are reset.
3. The script creates an unused local tag on the checked-out commit; do not push Git tags to a remote unless requested. 3. The script creates an unused annotated local tag on the checked-out commit; do not push Git tags to a remote unless requested.
4. Confirm Docker is available. For non-interactive authentication, put `GITEA_USERNAME` and a package-write `GITEA_TOKEN` in the ignored `.env` file. The script parses only these keys and sends the token through Docker's `--password-stdin`; never put credentials in tracked files, command-line arguments, or chat. Without these settings it relies on Docker's existing login credentials. 4. Confirm Docker is available. For non-interactive authentication, put `GITEA_USERNAME` and a package-write `GITEA_TOKEN` in the ignored `.env` file. The script parses only these keys and sends the token through Docker's `--password-stdin`; never put credentials in tracked files, command-line arguments, or chat. Without these settings it relies on Docker's existing login credentials.
5. Run `./scripts/publish-image.sh` from the repository. It builds, logs in when `.env` credentials are set, and pushes both the versioned tag and `latest`. 5. Run `./scripts/publish-image.sh` from the repository. It builds, logs in when `.env` credentials are set, and pushes both the versioned tag and `latest`. After both pushes succeed, it removes older local image tags for this repository, keeping the new version and `latest`; it does not delete packages from the registry.
6. Report the tagged commit, image tags, and actual build/push result. A successful local build is not a successful release if either registry push fails. 6. Report the tagged commit, image tags, and actual build/push result. A successful local build is not a successful release if either registry push fails.
Do not force-move an existing tag, create or push Git tags to a remote unless requested, or claim publication succeeded without successful registry pushes. Do not force-move an existing tag, create or push Git tags to a remote unless requested, or claim publication succeeded without successful registry pushes.
+5 -1
View File
@@ -2,9 +2,13 @@ FROM python:3.12-slim
WORKDIR /srv/faerro-kb WORKDIR /srv/faerro-kb
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY frontend ./frontend COPY frontend ./frontend
COPY ["audio test.wav", "./audio test.wav"]
ENV DATA_DIR=/data ENV DATA_DIR=/data
EXPOSE 80 EXPOSE 80
+17 -6
View File
@@ -10,7 +10,7 @@ docker compose up --build
## Releases ## Releases
Git tags are the source of truth for release versions. From the repository root, run the publish script to create the next patch tag and publish it: Git tags are the source of truth for release versions. From the repository root, run the publish script to create the next patch tag and publish it. A release requires at least one commit after the latest version tag; repeated runs without new commits are rejected:
```sh ```sh
cp .env.example .env cp .env.example .env
@@ -18,7 +18,9 @@ cp .env.example .env
./scripts/publish-image.sh ./scripts/publish-image.sh
``` ```
Pass `--minor` to increment the minor version and reset patch to zero, or `--major` to increment the major version and reset minor and patch to zero. The options cannot be combined. The script requires at least one existing `MAJOR.MINOR.PATCH` Git tag, creates the next local tag on the checked-out commit, and publishes both the versioned image and `latest`. `.env` is excluded from Git; the script reads the Gitea credentials from it and passes the token to Docker through standard input. If those credentials are unset, the script uses Docker's existing login credentials. Pass `--minor` to increment the minor version and reset patch to zero, or `--major` to increment the major version and reset minor and patch to zero. The options cannot be combined. Run the script from a clean worktree: staged, unstaged, and untracked changes must be committed or removed first. It creates an annotated local Git tag on the checked-out commit, then publishes both the versioned image and `latest`. The commit does not need to be pushed before creating the local tag, but push the commit to the Git server first when it should be shared as part of the release; the script does not push Git tags. `.env` is excluded from Git; the script reads the Gitea credentials from it and passes the token to Docker through standard input. If those credentials are unset, the script uses Docker's existing login credentials.
After both pushes succeed, the script removes older local Docker tags for this image while keeping the newly published version and `latest`. This cleanup affects the local Docker image cache, not previously published packages in the Gitea registry.
Open `http://localhost` for a desktop smoke test. Set `HTTP_PORT` to publish the app on a different host port (for example, `HTTP_PORT=8000 docker compose up`). For iPhone use, serve the app over HTTPS through your VPN/reverse proxy. iOS only grants microphone access in a secure context. Keep the service reachable only through your VPN and firewall; this first version has no login layer. Open `http://localhost` for a desktop smoke test. Set `HTTP_PORT` to publish the app on a different host port (for example, `HTTP_PORT=8000 docker compose up`). For iPhone use, serve the app over HTTPS through your VPN/reverse proxy. iOS only grants microphone access in a secure context. Keep the service reachable only through your VPN and firewall; this first version has no login layer.
@@ -27,21 +29,30 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
## Capture and offline behavior ## Capture and offline behavior
- Recording data and its timestamp are committed to IndexedDB before upload is attempted. - Recording data and its timestamp are committed to IndexedDB before upload is attempted.
- Written-note drafts and queued text notes are stored in IndexedDB on the device. A draft is restored when the app is reopened, and queued text is removed only after the server confirms storage.
- Microphone recordings use the audio format supported by the browser (commonly M4A/AAC on iPhone Safari or Opus/WebM elsewhere). You can also add an audio file from the device; both sources remain queued locally until the server confirms storage.
- The server keeps each uploaded original and creates a mono PCM WAV copy at 16-bit / 22.05 kHz for transcription. FFmpeg performs this conversion locally in the container. After a non-empty transcript is stored, both audio files are deleted; failed or empty transcriptions keep the audio for recovery.
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored. - Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
- Service worker caching keeps the app shell available offline. Searching the server-side archive requires a connection. - Service worker caching keeps the app shell available offline. Searching the server-side archive requires a connection.
- iOS may suspend a PWA and can evict website data under storage pressure. Background uploads are not guaranteed; reopen the PWA while online to resume. Keep the phone powered and avoid clearing Safari website data for stronger practical retention. - iOS may suspend a PWA and can evict website data under storage pressure. Background uploads are not guaranteed; reopen the PWA while online to resume. Keep the phone powered and avoid clearing Safari website data for stronger practical retention.
## Local speech-to-text ## Local speech-to-text
The API can use a local `whisper.cpp` server when `WHISPER_SERVER_URL` is configured. Audio is sent only to that local endpoint; with the variable unset, uploads remain stored as `awaiting_transcription` and can still be searched after a transcript is edited or populated. The API can use a local `onerahmet/openai-whisper-asr-webservice` server when `WHISPER_SERVER_URL` is configured. Set it to the service's `/asr` endpoint, including its listening port, for example `http://whisper:9000/asr` or `http://192.168.1.102:9000/asr`. Faerro KB sends the converted WAV as the `audio_file` multipart field with `task=transcribe` and `output=json`; audio is sent only to that local endpoint. At startup, the API also sends the bundled `audio test.wav` to the endpoint as a connection test before resuming queued jobs. Logs report the HTTP result, detected language, segment count, and elapsed time, but never log the transcript. With the variable unset, uploads remain stored as `awaiting_transcription` until a non-empty transcript is entered and saved. When the API starts with a successful Whisper test, it resumes awaiting, interrupted, and failed transcription jobs whose audio is still stored. Restart/recreate the API container after changing this environment variable; configuration is read at startup. Saving a non-empty transcript deletes both server-side audio files.
For NVIDIA GPU transcription, run a CUDA-enabled `whisper.cpp` server with a model on the same machine, then set `WHISPER_SERVER_URL` to its local inference endpoint, for example `http://whisper:8080/inference`. Use a model that fits the RTX 3070's VRAM; start with `small` or a quantized `medium` model and measure with your audio/language. Do not expose the transcription server outside the private Docker network. See the [whisper.cpp NVIDIA instructions](https://github.com/ggml-org/whisper.cpp#nvidia-gpu-support) for CUDA builds. Run the ASR web service with a model that fits the available hardware, then set `WHISPER_SERVER_URL` to its `/asr` endpoint. Keep the transcription server private to the Docker network and do not expose it publicly.
This version keeps raw audio and transcript separate. A future local correction model can propose punctuation and name fixes without replacing the original transcription. Proper-name matching and offline geolocation data are not enabled yet. The transcript remains in SQLite after the audio files are deleted. A future local correction model can propose punctuation and name fixes without replacing the stored transcription. Proper-name matching and offline geolocation data are not enabled yet.
## Data and backups ## Data and backups
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and original audio in `./data/audio/`. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed. Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and audio awaiting transcription in `./data/audio/`. After a non-empty transcript is saved, its audio files are removed. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
Faerro KB can upload a consistent SQLite snapshot to a Nextcloud or other WebDAV folder whenever a transcript is saved. Open **Backup settings** in the app and enter the folder's WebDAV URL, username, and an app password. The destination file is `faerro-kb.sqlite3`; the folder must already exist. For Nextcloud, the URL commonly starts with `https://<host>/remote.php/dav/files/<username>/`. Use HTTPS and an app password rather than your account password.
Alternatively, set `BACKUP_WEBDAV_URL`, `BACKUP_WEBDAV_USERNAME`, and `BACKUP_WEBDAV_PASSWORD` in `.env`. A non-empty `BACKUP_WEBDAV_URL` makes those settings environment-managed and read-only in the UI. When configured in the UI, credentials are stored on the server in `./data/webdav-backup.json` with owner-only file permissions; include the data directory in your own local backups if you need to preserve that configuration.
The API records pending backup work in SQLite and retries it at startup or after a later transcript save; a failed upload does not undo the saved transcript. The settings dialog shows failures and offers a retry. Rotation keeps the current `faerro-kb.sqlite3` latest file plus timestamped archives: the newest snapshot for each of the last 7 UTC dates, 4 ISO weeks, 12 calendar months, and 5 years. Overlapping periods share an archive. Only files matching Faerro KB's timestamped archive naming pattern in the configured folder are eligible for deletion; other files are left alone. WebDAV `PUT` replaces the complete database file: WebDAV does not provide safe incremental SQLite page syncing. Configure Nextcloud file versioning separately if you want server-side historical versions. This feature backs up the database only, not audio files, and does not replace a backup of the full `./data` directory. The app has no login layer, so keep it behind your VPN/firewall; anyone who can reach it can alter backup settings, as well as access the notes API.
## Development ## Development
+532 -19
View File
@@ -1,9 +1,17 @@
import asyncio
import json
import logging
import os import os
import re import re
import sqlite3 import sqlite3
import subprocess
import tempfile
import time
import uuid import uuid
from datetime import datetime, timezone import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import unquote, urlsplit
import httpx import httpx
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
@@ -13,8 +21,18 @@ from fastapi.staticfiles import StaticFiles
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
AUDIO_DIR = DATA_DIR / "audio" AUDIO_DIR = DATA_DIR / "audio"
DB_PATH = DATA_DIR / "faerro-kb.sqlite3" DB_PATH = DATA_DIR / "faerro-kb.sqlite3"
BACKUP_SETTINGS_PATH = DATA_DIR / "webdav-backup.json"
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip() WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
BACKUP_WEBDAV_URL = os.environ.get("BACKUP_WEBDAV_URL", "").strip()
BACKUP_WEBDAV_USERNAME = os.environ.get("BACKUP_WEBDAV_USERNAME", "")
BACKUP_WEBDAV_PASSWORD = os.environ.get("BACKUP_WEBDAV_PASSWORD", "")
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
WHISPER_TEST_AUDIO_PATH = Path(__file__).resolve().parent.parent / "audio test.wav"
TRANSCRIPTION_TASKS: set[asyncio.Task[None]] = set()
BACKUP_TASK: asyncio.Task[None] | None = None
BACKUP_LOCK = asyncio.Lock()
logger = logging.getLogger(__name__)
logging.getLogger("httpx").setLevel(logging.WARNING)
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True) AUDIO_DIR.mkdir(parents=True, exist_ok=True)
@@ -37,6 +55,7 @@ def initialize_db() -> None:
filename TEXT NOT NULL, filename TEXT NOT NULL,
audio_path TEXT NOT NULL, audio_path TEXT NOT NULL,
transcript TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '',
corrected_transcript TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL status TEXT NOT NULL
)""" )"""
) )
@@ -44,10 +63,37 @@ def initialize_db() -> None:
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search """CREATE VIRTUAL TABLE IF NOT EXISTS note_search
USING fts5(note_id UNINDEXED, transcript)""" USING fts5(note_id UNINDEXED, transcript)"""
) )
connection.execute(
"""CREATE TABLE IF NOT EXISTS backup_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL DEFAULT 0,
completed_revision INTEGER NOT NULL DEFAULT 0,
last_error TEXT
)"""
)
connection.execute("INSERT OR IGNORE INTO backup_state (id) VALUES (1)")
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
if "original_audio_path" not in columns:
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
if "corrected_transcript" not in columns:
connection.execute("ALTER TABLE notes ADD COLUMN corrected_transcript TEXT NOT NULL DEFAULT ''")
initialize_db() initialize_db()
def transcode_audio(source_path: Path, output_path: Path) -> None:
subprocess.run(
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source_path), "-vn", "-ac", "1", "-ar", "22050",
"-c:a", "pcm_s16le", str(output_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=300,
)
def update_transcript(note_id: str, transcript: str, status: str) -> None: def update_transcript(note_id: str, transcript: str, status: str) -> None:
with connect_db() as connection: with connect_db() as connection:
@@ -61,6 +107,245 @@ def update_transcript(note_id: str, transcript: str, status: str) -> None:
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", "INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
(note_id, transcript.strip()), (note_id, transcript.strip()),
) )
if result.rowcount and status != "transcription_failed":
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
if result.rowcount and status != "transcription_failed":
schedule_backup()
def get_backup_settings() -> dict[str, str]:
if BACKUP_WEBDAV_URL:
return {
"url": BACKUP_WEBDAV_URL,
"username": BACKUP_WEBDAV_USERNAME,
"password": BACKUP_WEBDAV_PASSWORD,
}
try:
settings = json.loads(BACKUP_SETTINGS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
settings = {}
return {
"url": settings.get("url", ""),
"username": settings.get("username", ""),
"password": settings.get("password", ""),
}
def save_backup_settings(settings: dict[str, str]) -> None:
temporary_path = BACKUP_SETTINGS_PATH.with_suffix(".tmp")
temporary_path.write_text(json.dumps(settings), encoding="utf-8")
temporary_path.chmod(0o600)
temporary_path.replace(BACKUP_SETTINGS_PATH)
def backup_state() -> dict[str, object]:
with connect_db() as connection:
row = connection.execute(
"SELECT revision, completed_revision, last_error FROM backup_state WHERE id = 1"
).fetchone()
settings = get_backup_settings()
return {
"enabled": bool(settings["url"]),
"managed_by_environment": bool(BACKUP_WEBDAV_URL),
"url": settings["url"],
"username": settings["username"],
"password_set": bool(settings["password"]),
"revision": row["revision"],
"completed_revision": row["completed_revision"],
"pending": row["revision"] > row["completed_revision"],
"last_error": row["last_error"],
}
def schedule_backup() -> None:
global BACKUP_TASK
if BACKUP_TASK and not BACKUP_TASK.done():
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
BACKUP_TASK = loop.create_task(run_backup())
async def file_chunks(path: Path):
with path.open("rb") as backup_file:
while chunk := await asyncio.to_thread(backup_file.read, 1024 * 1024):
yield chunk
async def create_database_snapshot(path: Path) -> None:
def copy_database() -> None:
source = sqlite3.connect(DB_PATH)
destination = sqlite3.connect(path)
try:
source.backup(destination)
finally:
destination.close()
source.close()
await asyncio.to_thread(copy_database)
def webdav_file_url(folder_url: str) -> str:
parsed = urlsplit(folder_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
raise ValueError("Enter a valid WebDAV folder URL")
if parsed.username or parsed.password:
raise ValueError("Put WebDAV credentials in the username and password fields")
return folder_url.rstrip("/") + "/faerro-kb.sqlite3"
def retained_backup_archives(names: list[str], now: datetime) -> set[str]:
archives = []
for name in names:
match = re.fullmatch(r"faerro-kb-(\d{8}T\d{6}Z)-r\d+\.sqlite3", name)
if not match:
continue
timestamp = datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
if timestamp <= now:
archives.append((name, timestamp))
daily: dict[object, tuple[str, datetime]] = {}
weekly: dict[object, tuple[str, datetime]] = {}
monthly: dict[object, tuple[str, datetime]] = {}
yearly: dict[object, tuple[str, datetime]] = {}
today = now.date()
current_week = today - timedelta(days=today.weekday())
current_month = now.year * 12 + now.month
for name, timestamp in archives:
date = timestamp.date()
age_days = (today - date).days
week = date - timedelta(days=date.weekday())
age_weeks = (current_week - week).days // 7
age_months = current_month - (timestamp.year * 12 + timestamp.month)
age_years = now.year - timestamp.year
for buckets, key, age, limit in (
(daily, date, age_days, 7),
(weekly, (week.isocalendar().year, week.isocalendar().week), age_weeks, 4),
(monthly, (timestamp.year, timestamp.month), age_months, 12),
(yearly, timestamp.year, age_years, 5),
):
if 0 <= age < limit and (key not in buckets or timestamp > buckets[key][1]):
buckets[key] = (name, timestamp)
return {
name
for buckets in (daily, weekly, monthly, yearly)
for name, _timestamp in buckets.values()
}
async def rotate_remote_backups(client: httpx.AsyncClient, folder_url: str) -> None:
request_body = b"""<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"""
response = await client.request(
"PROPFIND",
folder_url,
headers={"Depth": "1", "Content-Type": "application/xml"},
content=request_body,
)
response.raise_for_status()
root = ET.fromstring(response.content)
folder_path = urlsplit(folder_url).path.rstrip("/")
remote_archives = []
for item in root.findall("{DAV:}response"):
href = item.findtext("{DAV:}href")
if not href:
continue
item_url = httpx.URL(folder_url).join(href)
item_path = item_url.path
if item_path.rsplit("/", 1)[0].rstrip("/") != folder_path:
continue
name = unquote(item_path.rsplit("/", 1)[-1])
if re.fullmatch(r"faerro-kb-\d{8}T\d{6}Z-r\d+\.sqlite3", name):
remote_archives.append((name, str(item_url)))
retained = retained_backup_archives([name for name, _url in remote_archives], datetime.now(timezone.utc))
for name, item_url in remote_archives:
if name not in retained:
response = await client.delete(item_url)
response.raise_for_status()
async def run_backup() -> None:
async with BACKUP_LOCK:
while True:
settings = get_backup_settings()
if not settings["url"]:
return
with connect_db() as connection:
state = connection.execute(
"SELECT revision, completed_revision FROM backup_state WHERE id = 1"
).fetchone()
if state["revision"] <= state["completed_revision"]:
return
revision = state["revision"]
snapshot_path = None
try:
target_url = webdav_file_url(settings["url"])
with tempfile.NamedTemporaryFile(suffix=".sqlite3", dir=DATA_DIR, delete=False) as snapshot:
snapshot_path = Path(snapshot.name)
await create_database_snapshot(snapshot_path)
with sqlite3.connect(snapshot_path) as snapshot_connection:
snapshot_connection.execute(
"UPDATE backup_state SET completed_revision = ?, last_error = NULL WHERE id = 1",
(revision,),
)
auth = (settings["username"], settings["password"]) if settings["username"] else None
archive_name = (
f"faerro-kb-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
f"-r{revision:010d}.sqlite3"
)
async with httpx.AsyncClient(timeout=120, auth=auth) as client:
response = await client.put(
f"{settings['url'].rstrip('/')}/{archive_name}",
content=file_chunks(snapshot_path),
)
response.raise_for_status()
response = await client.put(target_url, content=file_chunks(snapshot_path))
response.raise_for_status()
await rotate_remote_backups(client, settings["url"])
with connect_db() as connection:
connection.execute(
"UPDATE backup_state SET completed_revision = ?, last_error = NULL WHERE id = 1",
(revision,),
)
except httpx.HTTPStatusError as error:
error_message = f"WebDAV returned HTTP {error.response.status_code}"
with connect_db() as connection:
connection.execute("UPDATE backup_state SET last_error = ? WHERE id = 1", (error_message,))
logger.warning("WebDAV database backup failed: HTTP %s", error.response.status_code)
return
except Exception as error:
error_message = "WebDAV backup failed; check the folder URL and credentials"
if isinstance(error, ValueError):
error_message = str(error)
with connect_db() as connection:
connection.execute("UPDATE backup_state SET last_error = ? WHERE id = 1", (error_message,))
logger.warning("WebDAV database backup failed: %s", type(error).__name__)
return
finally:
if snapshot_path:
snapshot_path.unlink(missing_ok=True)
def remove_transcribed_audio(note_id: str) -> None:
with connect_db() as connection:
note = connection.execute(
"SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
return
for audio_path in (note["audio_path"], note["original_audio_path"]):
if audio_path:
Path(audio_path).unlink(missing_ok=True)
with connect_db() as connection:
connection.execute(
"UPDATE notes SET audio_path = '', original_audio_path = NULL WHERE id = ?",
(note_id,),
)
async def transcribe_note(note_id: str, audio_path: Path) -> None: async def transcribe_note(note_id: str, audio_path: Path) -> None:
@@ -71,14 +356,102 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
with audio_path.open("rb") as audio_file: with audio_path.open("rb") as audio_file:
response = await client.post( response = await client.post(
WHISPER_SERVER_URL, WHISPER_SERVER_URL,
files={"file": (audio_path.name, audio_file, "application/octet-stream")}, params={"task": "transcribe", "output": "json"},
data={"response_format": "json"}, files={"audio_file": (audio_path.name, audio_file, "application/octet-stream")},
) )
response.raise_for_status() response.raise_for_status()
transcript = response.json().get("text", "").strip() transcript = response.json().get("text", "").strip()
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
except Exception: except Exception:
update_transcript(note_id, "", "transcription_failed") update_transcript(note_id, "", "transcription_failed")
return
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
if transcript:
remove_transcribed_audio(note_id)
async def test_whisper_connection() -> bool:
if not WHISPER_SERVER_URL:
logger.warning("Whisper startup test skipped: WHISPER_SERVER_URL is not configured")
return False
if not WHISPER_TEST_AUDIO_PATH.is_file():
logger.error("Whisper startup test failed: bundled test audio is missing")
return False
started_at = time.monotonic()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=5)) as client:
with WHISPER_TEST_AUDIO_PATH.open("rb") as audio_file:
response = await client.post(
WHISPER_SERVER_URL,
params={"encode": "true", "task": "transcribe", "output": "json"},
files={"audio_file": (WHISPER_TEST_AUDIO_PATH.name, audio_file, "audio/wav")},
)
response.raise_for_status()
result = response.json()
transcript = result.get("text") if isinstance(result, dict) else None
if not isinstance(transcript, str) or not transcript.strip():
logger.error(
"Whisper startup test failed: HTTP %s returned no transcript (%.1fs)",
response.status_code,
time.monotonic() - started_at,
)
return False
language = result.get("language", "unknown")
if not isinstance(language, str) or not re.fullmatch(r"[A-Za-z-]{1,12}", language):
language = "unknown"
segments = result.get("segments", [])
segment_count = len(segments) if isinstance(segments, list) else 0
logger.info(
"Whisper startup test passed: HTTP %s, language=%s, segments=%d, elapsed=%.1fs",
response.status_code,
language,
segment_count,
time.monotonic() - started_at,
)
return True
except httpx.HTTPStatusError as error:
logger.error("Whisper startup test failed: HTTP %s", error.response.status_code)
except Exception as error:
logger.error("Whisper startup test failed: %s", type(error).__name__)
return False
@app.on_event("startup")
async def resume_pending_transcriptions() -> None:
if BACKUP_WEBDAV_URL:
with connect_db() as connection:
connection.execute(
"UPDATE backup_state SET revision = 1 WHERE id = 1 AND revision = 0 AND completed_revision = 0"
)
schedule_backup()
with connect_db() as connection:
connection.execute(
"UPDATE notes SET status = 'awaiting_transcription' WHERE status = 'transcribing'"
)
if not await test_whisper_connection():
return
with connect_db() as connection:
notes = connection.execute(
"""SELECT id, audio_path FROM notes
WHERE status IN ('awaiting_transcription', 'transcribing', 'transcription_failed')"""
).fetchall()
for note in notes:
audio_path = Path(note["audio_path"])
if audio_path.is_file():
with connect_db() as connection:
connection.execute(
"UPDATE notes SET status = 'transcribing' WHERE id = ?",
(note["id"],),
)
task = asyncio.create_task(transcribe_note(note["id"], audio_path))
TRANSCRIPTION_TASKS.add(task)
task.add_done_callback(TRANSCRIPTION_TASKS.discard)
else:
with connect_db() as connection:
connection.execute(
"UPDATE notes SET status = 'transcription_failed' WHERE id = ?",
(note["id"],),
)
@app.get("/api/health") @app.get("/api/health")
@@ -86,6 +459,74 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/backup")
def get_backup_status() -> dict[str, object]:
return backup_state()
@app.put("/api/backup/config")
async def configure_backup(payload: dict[str, str]) -> dict[str, object]:
if BACKUP_WEBDAV_URL:
raise HTTPException(status_code=409, detail="WebDAV backup is managed by environment variables")
url = payload.get("url", "").strip()
username = payload.get("username", "").strip()
current = get_backup_settings()
password = "" if payload.get("clear_password") == "true" else current["password"]
if payload.get("password"):
password = payload["password"]
if url:
try:
webdav_file_url(url)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
save_backup_settings({"url": url, "username": username, "password": password})
if url:
with connect_db() as connection:
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
schedule_backup()
return backup_state()
@app.post("/api/backup/test")
async def test_backup_connection() -> dict[str, str]:
settings = get_backup_settings()
if not settings["url"]:
raise HTTPException(status_code=400, detail="Configure a WebDAV folder first")
try:
folder_url = settings["url"].rstrip("/")
webdav_file_url(folder_url)
test_url = f"{folder_url}/faerro-kb-test-{uuid.uuid4().hex}"
auth = (settings["username"], settings["password"]) if settings["username"] else None
async with httpx.AsyncClient(timeout=30, auth=auth) as client:
response = await client.request(
"PROPFIND",
folder_url,
headers={"Depth": "1", "Content-Type": "application/xml"},
content=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
)
response.raise_for_status()
response = await client.put(test_url, content=b"Faerro KB WebDAV test")
response.raise_for_status()
response = await client.delete(test_url)
response.raise_for_status()
return {"status": "ok", "message": "WebDAV listing, write, and delete test passed."}
except httpx.HTTPStatusError as error:
raise HTTPException(
status_code=502,
detail=f"WebDAV returned HTTP {error.response.status_code} during the test",
) from error
except Exception as error:
raise HTTPException(status_code=502, detail="Could not connect to the WebDAV folder") from error
@app.post("/api/backup/retry", status_code=202)
async def retry_backup() -> dict[str, object]:
if not get_backup_settings()["url"]:
raise HTTPException(status_code=400, detail="Configure a WebDAV folder first")
schedule_backup()
return backup_state()
@app.post("/api/notes", status_code=201) @app.post("/api/notes", status_code=201)
async def create_note( async def create_note(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@@ -102,15 +543,39 @@ async def create_note(
if existing: if existing:
return {"id": note_id, "status": existing["status"]} return {"id": note_id, "status": existing["status"]}
suffix = Path(audio.filename or "recording.webm").suffix.lower() suffix = Path(audio.filename or "recording.webm").suffix.lower()
if not suffix or len(suffix) > 10: if not re.fullmatch(r"\.[a-z0-9]{1,9}", suffix):
suffix = ".audio" suffix = ".audio"
audio_path = AUDIO_DIR / f"{note_id}{suffix}" original_audio_path = AUDIO_DIR / f"{note_id}-original{suffix}"
audio_path = AUDIO_DIR / f"{note_id}.wav"
temporary_output = None
try: try:
with audio_path.open("wb") as destination: with original_audio_path.open("wb") as destination:
while chunk := await audio.read(1024 * 1024): while chunk := await audio.read(1024 * 1024):
destination.write(chunk) destination.write(chunk)
with tempfile.NamedTemporaryFile(suffix=".wav", dir=AUDIO_DIR, delete=False) as temporary_file:
temporary_output = Path(temporary_file.name)
await asyncio.to_thread(transcode_audio, original_audio_path, temporary_output)
temporary_output.replace(audio_path)
except FileNotFoundError as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Audio conversion is unavailable") from error
except subprocess.CalledProcessError as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Unsupported or invalid audio file") from error
except subprocess.TimeoutExpired as error:
original_audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=422, detail="Audio conversion timed out") from error
except Exception as error: except Exception as error:
original_audio_path.unlink(missing_ok=True)
audio_path.unlink(missing_ok=True) audio_path.unlink(missing_ok=True)
if temporary_output:
temporary_output.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Could not store audio") from error raise HTTPException(status_code=500, detail="Could not store audio") from error
finally: finally:
await audio.close() await audio.close()
@@ -126,14 +591,46 @@ async def create_note(
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing" status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
with connect_db() as connection: with connect_db() as connection:
connection.execute( connection.execute(
"INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)", """INSERT OR IGNORE INTO notes
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), status), (id, created_at, filename, audio_path, original_audio_path, status)
VALUES (?, ?, ?, ?, ?, ?)""",
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), str(original_audio_path), status),
) )
if WHISPER_SERVER_URL: if WHISPER_SERVER_URL:
background_tasks.add_task(transcribe_note, note_id, audio_path) background_tasks.add_task(transcribe_note, note_id, audio_path)
return {"id": note_id, "status": status} return {"id": note_id, "status": status}
@app.post("/api/text-notes", status_code=201)
def create_text_note(payload: dict[str, str]) -> dict[str, str]:
text = payload.get("text", "").strip()
if not text:
raise HTTPException(status_code=400, detail="Note text is required")
try:
note_id = str(uuid.UUID(payload.get("id", "")))
except ValueError as error:
raise HTTPException(status_code=400, detail="Invalid note ID") from error
try:
parsed_date = datetime.fromisoformat(payload.get("created_at", "").replace("Z", "+00:00"))
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
created_at = parsed_date.astimezone(timezone.utc).isoformat()
except ValueError:
created_at = datetime.now(timezone.utc).isoformat()
with connect_db() as connection:
existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
if existing:
return {"id": note_id, "status": existing["status"]}
connection.execute(
"""INSERT INTO notes (id, created_at, filename, audio_path, transcript, status)
VALUES (?, ?, ?, '', ?, 'written')""",
(note_id, created_at, "Written note", text),
)
connection.execute("INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, text))
return {"id": note_id, "status": "written"}
@app.get("/api/notes") @app.get("/api/notes")
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]: def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
with connect_db() as connection: with connect_db() as connection:
@@ -142,7 +639,7 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms) query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
if status: if status:
rows = connection.execute( rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status """SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? AND n.status = ? WHERE note_search MATCH ? AND n.status = ?
ORDER BY n.created_at DESC LIMIT 100""", ORDER BY n.created_at DESC LIMIT 100""",
@@ -150,20 +647,20 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
).fetchall() ).fetchall()
else: else:
rows = connection.execute( rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status """SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""", WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
(query,), (query,),
).fetchall() ).fetchall()
elif status: elif status:
rows = connection.execute( rows = connection.execute(
"""SELECT id, created_at, filename, transcript, status """SELECT id, created_at, filename, transcript, corrected_transcript, status
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""", FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
(status,), (status,),
).fetchall() ).fetchall()
else: else:
rows = connection.execute( rows = connection.execute(
"""SELECT id, created_at, filename, transcript, status """SELECT id, created_at, filename, transcript, corrected_transcript, status
FROM notes ORDER BY created_at DESC LIMIT 100""" FROM notes ORDER BY created_at DESC LIMIT 100"""
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
@@ -173,25 +670,41 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
def delete_note(note_id: str) -> dict[str, str]: def delete_note(note_id: str) -> dict[str, str]:
with connect_db() as connection: with connect_db() as connection:
note = connection.execute( note = connection.execute(
"SELECT audio_path FROM notes WHERE id = ?", (note_id,) "SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
).fetchone() ).fetchone()
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,)) connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,)) connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
if note["audio_path"]:
Path(note["audio_path"]).unlink(missing_ok=True) Path(note["audio_path"]).unlink(missing_ok=True)
if note["original_audio_path"]:
Path(note["original_audio_path"]).unlink(missing_ok=True)
return {"id": note_id, "status": "deleted"} return {"id": note_id, "status": "deleted"}
@app.put("/api/notes/{note_id}/transcript") @app.put("/api/notes/{note_id}/transcript")
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]: async def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
transcript = payload.get("transcript", "").strip() transcript = payload.get("transcript", "").strip()
with connect_db() as connection: with connect_db() as connection:
exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone() note = connection.execute("SELECT transcript FROM notes WHERE id = ?", (note_id,)).fetchone()
if not exists: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription") with connect_db() as connection:
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"} connection.execute(
"UPDATE notes SET corrected_transcript = ? WHERE id = ?",
(transcript, note_id),
)
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
searchable_text = transcript or note["transcript"]
if searchable_text.strip():
connection.execute(
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
(note_id, searchable_text.strip()),
)
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
schedule_backup()
return {"id": note_id, "status": "saved"}
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -1,11 +1,14 @@
services: services:
faerro-kb: faerro-kb:
image: gitea.faerro.it/andrea/faerro-kb:0.0.1 image: gitea.faerro.it/andrea/faerro-kb:latest
ports: ports:
- "${HTTP_PORT:-80}:80" - "${HTTP_PORT:-80}:80"
environment: environment:
DATA_DIR: /data DATA_DIR: /data
WHISPER_SERVER_URL: ${WHISPER_SERVER_URL:-} WHISPER_SERVER_URL: ${WHISPER_SERVER_URL:-}
BACKUP_WEBDAV_URL: ${BACKUP_WEBDAV_URL:-}
BACKUP_WEBDAV_USERNAME: ${BACKUP_WEBDAV_USERNAME:-}
BACKUP_WEBDAV_PASSWORD: ${BACKUP_WEBDAV_PASSWORD:-}
volumes: volumes:
- ./data:/data - ./data:/data
restart: unless-stopped restart: unless-stopped
+317 -23
View File
@@ -1,10 +1,15 @@
const DB_NAME = 'faerro-kb-local'; const DB_NAME = 'faerro-kb-local';
const STORE_NAME = 'recordings'; const STORE_NAME = 'recordings';
const TEXT_STORE_NAME = 'text-notes';
const DRAFT_ID = 'current-draft';
const MAX_RECORDING_MS = 30 * 60 * 1000; const MAX_RECORDING_MS = 30 * 60 * 1000;
const elements = Object.fromEntries([ const elements = Object.fromEntries([
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
'network-state', 'storage-status', 'network-state', 'storage-status', 'main', 'voice-mode', 'write-mode', 'read-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
'queue-section', 'archive-section', 'archive-title',
'settings-button', 'settings-dialog', 'backup-form', 'backup-fields', 'backup-url', 'backup-username', 'backup-password',
'clear-backup-password', 'close-settings', 'test-backup', 'backup-status', 'retry-backup',
].map((id) => [id, document.getElementById(id)])); ].map((id) => [id, document.getElementById(id)]));
let databasePromise; let databasePromise;
@@ -16,12 +21,18 @@ let recordingChunks = [];
let recordingStartedAt = 0; let recordingStartedAt = 0;
let recordingTimer; let recordingTimer;
let syncing = false; let syncing = false;
let draftSaveTimer;
let transcriptionPoll;
function openDatabase() { function openDatabase() {
if (databasePromise) return databasePromise; if (databasePromise) return databasePromise;
databasePromise = new Promise((resolve, reject) => { databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1); const request = indexedDB.open(DB_NAME, 2);
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'id' });
if (!db.objectStoreNames.contains(TEXT_STORE_NAME)) db.createObjectStore(TEXT_STORE_NAME, { keyPath: 'id' });
};
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@@ -44,18 +55,202 @@ const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording)); const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id)); const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id));
function setMessage(message, isError = false) { async function textTransaction(mode, operation) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(TEXT_STORE_NAME, mode);
const request = operation(transaction.objectStore(TEXT_STORE_NAME));
transaction.oncomplete = () => resolve(request?.result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
const getTextRecords = () => textTransaction('readonly', (store) => store.getAll());
const putTextRecord = (record) => textTransaction('readwrite', (store) => store.put(record));
const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.delete(id));
function setCaptureMode(mode) {
const writing = mode === 'write';
const reading = mode === 'read';
elements['voice-mode'].classList.toggle('active', !writing && !reading);
elements['write-mode'].classList.toggle('active', writing);
elements['read-mode'].classList.toggle('active', reading);
elements['voice-mode'].setAttribute('aria-selected', String(!writing && !reading));
elements['write-mode'].setAttribute('aria-selected', String(writing));
elements['read-mode'].setAttribute('aria-selected', String(reading));
elements['voice-panel'].hidden = writing || reading;
elements['write-panel'].hidden = !writing || reading;
elements['main'].classList.toggle('reading', reading);
elements['archive-title'].textContent = reading ? 'Stored transcriptions' : 'Saved notes';
loadAwaitingNotes();
}
async function saveDraft() {
const text = elements['written-note'].value;
try {
await putTextRecord({ id: DRAFT_ID, text, updatedAt: Date.now() });
elements['draft-status'].textContent = 'Draft saved on this device.';
elements['save-written-note'].disabled = !text.trim();
} catch {
elements['draft-status'].textContent = 'Could not save draft in browser storage. Keep this page open.';
elements['save-written-note'].disabled = !text.trim();
}
}
function scheduleDraftSave() {
elements['save-written-note'].disabled = !elements['written-note'].value.trim();
elements['draft-status'].textContent = 'Saving draft…';
clearTimeout(draftSaveTimer);
draftSaveTimer = window.setTimeout(saveDraft, 250);
}
async function queueWrittenNote() {
const text = elements['written-note'].value.trim();
if (!text || elements['save-written-note'].disabled) return;
clearTimeout(draftSaveTimer);
const createdAt = new Date().toISOString();
const note = { id: crypto.randomUUID(), text, createdAt, createdAtMs: Date.now(), filename: 'Written note' };
try {
const db = await openDatabase();
await new Promise((resolve, reject) => {
const transaction = db.transaction(TEXT_STORE_NAME, 'readwrite');
const store = transaction.objectStore(TEXT_STORE_NAME);
store.put(note);
store.delete(DRAFT_ID);
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
elements['written-note'].value = '';
elements['draft-status'].textContent = navigator.onLine ? 'Saved on this device. Sending to the server…' : 'Saved on this device. It will send when you reconnect.';
elements['save-written-note'].disabled = true;
await renderQueue();
await syncQueue();
} catch {
elements['draft-status'].textContent = 'Could not queue this note. Your draft is still here; try again.';
}
}
async function restoreDraft() {
try {
const records = await getTextRecords();
const draft = records.find((record) => record.id === DRAFT_ID);
if (draft?.text) {
elements['written-note'].value = draft.text;
elements['draft-status'].textContent = 'Draft restored from this device.';
elements['save-written-note'].disabled = false;
setCaptureMode('write');
}
} catch {
elements['draft-status'].textContent = 'Browser storage is unavailable. This draft may not survive leaving the page.';
}
}
function setMessage(message, isError = false, isSuccess = false) {
elements['capture-message'].textContent = message; elements['capture-message'].textContent = message;
elements['capture-message'].style.color = isError ? 'var(--coral)' : ''; elements['capture-message'].style.color = isError ? 'var(--coral)' : '';
elements['capture-message'].classList.toggle('upload-confirmed', isSuccess);
elements['capture-message'].setAttribute('aria-label', isSuccess ? 'Server confirmed the recording is stored.' : message);
} }
function updateNetwork() { function updateNetwork() {
const online = navigator.onLine; const online = navigator.onLine;
elements['network-state'].classList.toggle('online', online); elements['network-state'].classList.toggle('online', online);
elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`; elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`;
if (elements['storage-status']) {
elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.'; elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.';
}
} }
async function refreshBackupStatus() {
try {
const response = await fetch('/api/backup');
if (!response.ok) throw new Error('Backup status unavailable');
const backup = await response.json();
elements['backup-fields'].disabled = backup.managed_by_environment;
const managedNotice = backup.managed_by_environment ? 'Settings are managed by environment variables. ' : '';
if (backup.last_error) {
elements['backup-status'].textContent = `${managedNotice}Backup issue: ${backup.last_error}`;
} else if (backup.pending && backup.enabled) {
elements['backup-status'].textContent = `${managedNotice}A database snapshot is waiting to upload.`;
} else if (backup.pending) {
elements['backup-status'].textContent = 'Backup is waiting; configure a WebDAV folder to upload it.';
} else if (backup.managed_by_environment) {
elements['backup-status'].textContent = `${managedNotice}Latest database snapshot is current.`;
} else if (backup.enabled) {
elements['backup-status'].textContent = 'WebDAV backup is enabled. Latest database snapshot is current.';
} else {
elements['backup-status'].textContent = 'WebDAV backup is not configured.';
}
elements['retry-backup'].hidden = !backup.enabled || !backup.pending;
if (!backup.managed_by_environment) {
elements['backup-url'].value = backup.url;
elements['backup-username'].value = backup.username;
}
return backup;
} catch {
elements['backup-status'].textContent = 'Could not load backup status.';
return null;
}
}
async function saveBackupConfiguration() {
const response = await fetch('/api/backup/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: elements['backup-url'].value.trim(),
username: elements['backup-username'].value.trim(),
password: elements['backup-password'].value,
clear_password: String(elements['clear-backup-password'].checked),
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Could not save backup settings.');
elements['backup-password'].value = '';
elements['clear-backup-password'].checked = false;
await refreshBackupStatus();
}
elements['settings-button'].addEventListener('click', () => {
elements['settings-dialog'].showModal();
refreshBackupStatus();
});
elements['close-settings'].addEventListener('click', () => elements['settings-dialog'].close());
elements['backup-form'].addEventListener('submit', async (event) => {
event.preventDefault();
elements['backup-status'].textContent = 'Saving settings…';
try {
await saveBackupConfiguration();
} catch (error) {
elements['backup-status'].textContent = error.message;
}
});
elements['test-backup'].addEventListener('click', async () => {
elements['backup-status'].textContent = 'Testing WebDAV…';
try {
await saveBackupConfiguration();
const response = await fetch('/api/backup/test', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'WebDAV test failed.');
elements['backup-status'].textContent = result.message;
} catch (error) {
elements['backup-status'].textContent = error.message;
}
});
elements['retry-backup'].addEventListener('click', async () => {
elements['backup-status'].textContent = 'Queuing backup retry…';
try {
const response = await fetch('/api/backup/retry', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Could not retry backup.');
await refreshBackupStatus();
} catch (error) {
elements['backup-status'].textContent = error.message;
}
});
function formatDuration(milliseconds) { function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1000); const seconds = Math.floor(milliseconds / 1000);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
@@ -170,7 +365,7 @@ async function saveRecording() {
setMessage('No audio was captured. Please try again.', true); setMessage('No audio was captured. Please try again.', true);
return; return;
} }
const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : 'm4a'; const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : blob.type.includes('mp4') ? 'm4a' : 'audio';
const createdAt = new Date(recordingStartedAt).toISOString(); const createdAt = new Date(recordingStartedAt).toISOString();
const recording = { const recording = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -190,6 +385,27 @@ async function saveRecording() {
} }
} }
async function queueAudioFile(file) {
const createdAtMs = Date.now();
const createdAt = new Date(createdAtMs).toISOString();
const recording = {
id: crypto.randomUUID(),
blob: file,
createdAt,
filename: file.name,
durationMs: 0,
createdAtMs,
};
try {
await putQueued(recording);
setMessage(navigator.onLine ? 'Audio saved on device. Uploading now…' : 'Audio saved on device. It will upload when you reconnect.');
await renderQueue();
await syncQueue();
} catch {
setMessage('Could not save this file in browser storage. Check available device storage and try again.', true);
}
}
async function syncQueue() { async function syncQueue() {
if (syncing || !navigator.onLine) return; if (syncing || !navigator.onLine) return;
syncing = true; syncing = true;
@@ -206,12 +422,28 @@ async function syncQueue() {
const response = await fetch('/api/notes', { method: 'POST', body: form }); const response = await fetch('/api/notes', { method: 'POST', body: form });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`); if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
await deleteQueued(recording.id); await deleteQueued(recording.id);
setMessage('Server confirmed the recording is stored.'); setMessage('Upload complete', false, true);
} catch { } catch {
setMessage('Upload paused. Your recording is still saved on this device.'); setMessage('Upload paused. Your recording is still saved on this device.');
break; break;
} }
} }
const textNotes = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID).sort((a, b) => a.createdAtMs - b.createdAtMs);
for (const note of textNotes) {
try {
const response = await fetch('/api/text-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: note.id, text: note.text, created_at: note.createdAt }),
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
await deleteTextRecord(note.id);
elements['draft-status'].textContent = 'Note saved on the server.';
} catch {
elements['draft-status'].textContent = 'Upload paused. Your note is still saved on this device.';
break;
}
}
} finally { } finally {
syncing = false; syncing = false;
elements['sync-button'].disabled = false; elements['sync-button'].disabled = false;
@@ -225,7 +457,9 @@ function prettyDate(value) {
} }
async function renderQueue() { async function renderQueue() {
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs); const audioRecords = await getQueued();
const textRecords = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID);
const recordings = [...audioRecords, ...textRecords].sort((a, b) => b.createdAtMs - a.createdAtMs);
elements['queue-list'].replaceChildren(); elements['queue-list'].replaceChildren();
elements['queue-count'].textContent = recordings.length; elements['queue-count'].textContent = recordings.length;
elements['queue-empty'].hidden = recordings.length > 0; elements['queue-empty'].hidden = recordings.length > 0;
@@ -234,7 +468,8 @@ async function renderQueue() {
item.className = 'queue-item'; item.className = 'queue-item';
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.className = 'queue-icon'; icon.className = 'queue-icon';
icon.textContent = '◖'; const isTextNote = 'text' in recording;
icon.textContent = isTextNote ? 'T' : '◖';
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'item-body'; body.className = 'item-body';
const top = document.createElement('div'); const top = document.createElement('div');
@@ -247,14 +482,15 @@ async function renderQueue() {
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE'; status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'item-meta'; meta.className = 'item-meta';
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`; meta.textContent = `${prettyDate(recording.createdAt)}${recording.durationMs ? ` · ${formatDuration(recording.durationMs)}` : ''}`;
const remove = createDeleteButton(`Delete queued note ${recording.filename}`); const remove = createDeleteButton(`Delete queued note ${recording.filename}`);
remove.disabled = syncing; remove.disabled = syncing;
remove.addEventListener('click', async () => { remove.addEventListener('click', async () => {
if (syncing) return; if (syncing) return;
remove.disabled = true; remove.disabled = true;
try { try {
await deleteQueued(recording.id); if (isTextNote) await deleteTextRecord(recording.id);
else await deleteQueued(recording.id);
await renderQueue(); await renderQueue();
} catch { } catch {
setMessage('Could not remove the queued recording.', true); setMessage('Could not remove the queued recording.', true);
@@ -281,8 +517,18 @@ function createDeleteButton(label) {
function renderArchive(notes) { function renderArchive(notes) {
elements['archive-list'].replaceChildren(); elements['archive-list'].replaceChildren();
elements['archive-empty'].hidden = notes.length > 0; elements['archive-empty'].hidden = notes.length > 0;
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.'; if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
else elements['archive-empty'].textContent = 'No notes awaiting transcription.'; else elements['archive-empty'].textContent = 'No saved notes yet.';
if (notes.some((note) => note.status === 'transcribing')) {
if (!transcriptionPoll) {
transcriptionPoll = window.setInterval(() => {
if (navigator.onLine && document.visibilityState === 'visible') loadAwaitingNotes();
}, 5000);
}
} else {
clearInterval(transcriptionPoll);
transcriptionPoll = undefined;
}
for (const note of notes) { for (const note of notes) {
const item = document.createElement('article'); const item = document.createElement('article');
item.className = 'archive-item'; item.className = 'archive-item';
@@ -294,15 +540,23 @@ function renderArchive(notes) {
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'item-name'; name.className = 'item-name';
name.textContent = note.filename; name.textContent = note.filename;
const isWrittenNote = note.status === 'written';
const transcript = document.createElement('textarea'); const transcript = document.createElement('textarea');
transcript.className = 'archive-transcript'; transcript.className = 'archive-transcript';
transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.'; transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
transcript.value = note.transcript; transcript.value = note.corrected_transcript || note.transcript;
transcript.setAttribute('aria-label', `Transcript for ${note.filename}`); transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Edited transcript'} for ${note.filename}`);
const original = document.createElement('details');
original.className = 'original-transcript';
const originalLabel = document.createElement('summary');
originalLabel.textContent = 'Original transcript';
const originalText = document.createElement('p');
originalText.textContent = note.transcript || 'No original transcript is available.';
original.append(originalLabel, originalText);
const save = document.createElement('button'); const save = document.createElement('button');
save.className = 'save-transcript'; save.className = 'save-transcript';
save.type = 'button'; save.type = 'button';
save.textContent = 'SAVE TRANSCRIPT'; save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
save.disabled = true; save.disabled = true;
try { try {
@@ -310,7 +564,10 @@ function renderArchive(notes) {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }), method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
}); });
if (!response.ok) throw new Error('Save failed'); if (!response.ok) throw new Error('Save failed');
await loadAwaitingNotes(); save.textContent = 'SAVED';
window.setTimeout(() => {
if (save.isConnected) save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
}, 1500);
} catch { } catch {
save.textContent = 'RETRY SAVE'; save.textContent = 'RETRY SAVE';
} finally { } finally {
@@ -319,7 +576,20 @@ function renderArchive(notes) {
}); });
const status = document.createElement('div'); const status = document.createElement('div');
status.className = 'archive-status'; status.className = 'archive-status';
status.textContent = 'AWAITING TRANSCRIPTION'; status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : note.status === 'transcription_failed' ? 'TRANSCRIPTION FAILED; AUDIO RETAINED' : note.status === 'empty_transcript' ? 'NO SPEECH DETECTED; AUDIO RETAINED' : 'AWAITING TRANSCRIPTION';
if (note.status === 'transcribing') {
const progress = document.createElement('div');
progress.className = 'transcription-progress';
progress.setAttribute('role', 'progressbar');
progress.setAttribute('aria-label', 'Transcription in progress');
progress.setAttribute('aria-valuemin', '0');
progress.setAttribute('aria-valuemax', '100');
progress.setAttribute('aria-valuetext', 'In progress');
progress.append(document.createElement('span'));
body.append(name, original, transcript, save, status, progress);
} else {
body.append(name, original, transcript, save, status);
}
const remove = createDeleteButton(`Delete note ${note.filename}`); const remove = createDeleteButton(`Delete note ${note.filename}`);
remove.addEventListener('click', async () => { remove.addEventListener('click', async () => {
remove.disabled = true; remove.disabled = true;
@@ -332,7 +602,6 @@ function renderArchive(notes) {
setMessage('Could not delete this note. Please try again.', true); setMessage('Could not delete this note. Please try again.', true);
} }
}); });
body.append(name, transcript, save, status);
item.append(date, body, remove); item.append(date, body, remove);
elements['archive-list'].append(item); elements['archive-list'].append(item);
} }
@@ -344,9 +613,19 @@ async function loadAwaitingNotes() {
return; return;
} }
try { try {
const response = await fetch('/api/notes?status=awaiting_transcription'); const responses = elements['main'].classList.contains('reading')
if (!response.ok) throw new Error('Archive unavailable'); ? [await fetch('/api/notes')]
renderArchive(await response.json()); : await Promise.all([
fetch('/api/notes?status=awaiting_transcription'),
fetch('/api/notes?status=written'),
fetch('/api/notes?status=transcribing'),
fetch('/api/notes?status=transcription_failed'),
fetch('/api/notes?status=empty_transcript'),
]);
if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
const notes = (await Promise.all(responses.map((response) => response.json()))).flat();
notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
renderArchive(notes);
} catch { } catch {
elements['archive-list'].replaceChildren(); elements['archive-list'].replaceChildren();
elements['archive-empty'].hidden = false; elements['archive-empty'].hidden = false;
@@ -355,6 +634,20 @@ async function loadAwaitingNotes() {
} }
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
elements['read-mode'].addEventListener('click', () => setCaptureMode('read'));
elements['written-note'].addEventListener('input', scheduleDraftSave);
elements['save-written-note'].addEventListener('click', queueWrittenNote);
window.addEventListener('pagehide', () => {
clearTimeout(draftSaveTimer);
saveDraft();
});
elements['audio-file-input'].addEventListener('change', async (event) => {
for (const file of event.target.files) await queueAudioFile(file);
event.target.value = '';
});
elements['sync-button'].addEventListener('click', syncQueue);
elements['sync-button'].addEventListener('click', syncQueue); elements['sync-button'].addEventListener('click', syncQueue);
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); }); window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
window.addEventListener('offline', updateNetwork); window.addEventListener('offline', updateNetwork);
@@ -363,5 +656,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
updateNetwork(); updateNetwork();
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true)); renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
restoreDraft();
loadAwaitingNotes(); loadAwaitingNotes();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="32" fill="#355a43" /> <circle cx="32" cy="32" r="32" fill="#355a43" />
<text x="32" y="45" fill="#fffefa" font-family="Georgia, serif" font-size="28" text-anchor="middle">KB</text> <text x="32" y="43" fill="#fff" font-family="Iowan Old Style, Palatino Linotype, Book Antiqua, Georgia, serif" font-size="34" font-weight="500" text-anchor="middle">KB</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 295 B

+52 -5
View File
@@ -13,12 +13,46 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">V</span><span>Faerro <b>KB</b></span></a> <a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">KB</span><span>Faerro <b>KB</b></span></a>
<div class="topbar-actions">
<button id="settings-button" class="icon-button settings-button" type="button" title="Backup settings" aria-label="Backup settings">⚙</button>
<span id="network-state" class="network-state"><i></i> Checking</span> <span id="network-state" class="network-state"><i></i> Checking</span>
</div>
</header> </header>
<dialog id="settings-dialog" class="settings-dialog" aria-labelledby="settings-title">
<form id="backup-form" method="dialog">
<div class="dialog-heading">
<h2 id="settings-title">Database backup</h2>
<button id="close-settings" class="icon-button dialog-close" type="button" title="Close settings" aria-label="Close settings">×</button>
</div>
<p class="settings-copy">A consistent SQLite snapshot is uploaded after each saved transcript. Keeps latest, 7 daily, 4 weekly, 12 monthly, and 5 yearly snapshots. WebDAV receives the full database file each time.</p>
<fieldset id="backup-fields">
<label for="backup-url">WebDAV folder URL</label>
<input id="backup-url" name="url" type="url" placeholder="https://cloud.example/remote.php/dav/files/name/Faerro" autocomplete="url" />
<label for="backup-username">Username</label>
<input id="backup-username" name="username" type="text" autocomplete="username" />
<label for="backup-password">App password</label>
<input id="backup-password" name="password" type="password" autocomplete="new-password" placeholder="Leave blank to keep the saved password" />
<label class="clear-password"><input id="clear-backup-password" type="checkbox" /> Remove saved password</label>
<div class="settings-actions">
<button class="save-written-note" type="submit">Save settings</button>
<button id="test-backup" class="secondary-button" type="button">Test WebDAV</button>
</div>
</fieldset>
<p id="backup-status" class="backup-status" role="status">Loading backup status…</p>
<button id="retry-backup" class="secondary-button retry-backup" type="button" hidden>Retry backup</button>
</form>
</dialog>
<main> <main>
<section class="capture" aria-labelledby="capture-title"> <section class="capture" aria-labelledby="capture-title">
<div class="capture-modes" role="tablist" aria-label="Note type">
<button id="voice-mode" class="capture-mode active" type="button" role="tab" aria-selected="true" aria-controls="voice-panel">Voice</button>
<button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
<button id="read-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="archive-section">Read</button>
</div>
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
<div class="recorder-row"> <div class="recorder-row">
<button id="record-button" class="record-button" type="button" aria-label="Start recording"> <button id="record-button" class="record-button" type="button" aria-label="Start recording">
<span class="record-dot"></span> <span class="record-dot"></span>
@@ -32,11 +66,24 @@
</div> </div>
<time id="record-time">00:00</time> <time id="record-time">00:00</time>
</div> </div>
<div class="audio-upload-row">
<label class="audio-upload-button" for="audio-file-input">↑ Upload audio</label>
<input id="audio-file-input" type="file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.opus,.flac,.wav" multiple />
</div>
<div class="record-meter"><span id="record-meter-fill"></span></div> <div class="record-meter"><span id="record-meter-fill"></span></div>
<div id="capture-message" class="capture-message" role="status"></div> <div id="capture-message" class="capture-message" role="status"></div>
</div>
<div id="write-panel" class="write-panel" role="tabpanel" aria-labelledby="write-mode" hidden>
<label class="write-label" for="written-note">Write a note</label>
<textarea id="written-note" class="written-note" placeholder="What’s on your mind?" rows="7"></textarea>
<div class="write-actions">
<span id="draft-status" class="draft-status" role="status">Draft stays on this device until you save it.</span>
<button id="save-written-note" class="save-written-note" type="button" disabled>Save note</button>
</div>
</div>
</section> </section>
<section class="queue-section" aria-labelledby="queue-title"> <section id="queue-section" class="queue-section" aria-labelledby="queue-title">
<div class="section-heading"> <div class="section-heading">
<div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div> <div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div>
<button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button> <button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button>
@@ -45,16 +92,16 @@
<p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p> <p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p>
</section> </section>
<section class="archive-section" aria-labelledby="archive-title"> <section id="archive-section" class="archive-section" aria-labelledby="archive-title">
<div class="section-heading archive-heading"> <div class="section-heading archive-heading">
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Awaiting transcription</h2></div> <div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
</div> </div>
<div id="archive-list" class="archive-list"></div> <div id="archive-list" class="archive-list"></div>
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p> <p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
</section> </section>
</main> </main>
<footer><p>Faerro KB - Copyright Andrea Ferronato © 2026</p></footer> <footer><p>Faerro KB © 2026</p></footer>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>
+56 -2
View File
@@ -16,14 +16,35 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); } body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); }
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; } .topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
.topbar-actions { display: flex; align-items: center; gap: 18px; }
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; } .brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
.brand b { color: var(--moss); } .brand b { color: var(--moss); }
.brand-mark { width: 28px; height: 28px; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); } .brand-mark { width: 28px; height: 28px; flex: 0 0 auto; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
.network-state { font: 11px var(--mono); color: var(--muted); display: flex; align-items: center; gap: 8px; } .network-state { font: 11px var(--mono); color: var(--muted); display: flex; align-items: center; gap: 8px; }
.network-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); } .network-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); }
.network-state.online i { background: #57986b; } .network-state.online i { background: #57986b; }
main { max-width: 1040px; margin: auto; padding: 0 24px; } main { max-width: 1040px; margin: auto; padding: 0 24px; }
.capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; } .capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; }
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); }
.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; }
.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
.reading .capture { padding-bottom: 0; border-bottom: 0; background-image: none; }
.reading #queue-section { display: none; }
.reading #archive-section { min-height: calc(100vh - 190px); padding-top: 24px; border-top: 1px solid var(--line); }
.reading .archive-list { margin-top: 24px; }
.reading .archive-item { padding: 22px 0; }
.reading .archive-transcript { min-height: 180px; resize: vertical; border: 1px solid var(--line); padding: 14px; background: #fffefa; }
.original-transcript { margin: 0 0 12px; color: var(--muted); font-size: 11px; }
.original-transcript summary { width: fit-content; font: 10px var(--mono); cursor: pointer; }
.original-transcript p { margin: 9px 0 0; color: var(--ink); font: 13px/1.55 var(--sans); white-space: pre-wrap; overflow-wrap: anywhere; }
.write-panel { max-width: 590px; margin: auto; }
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); }
.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); }
.written-note:focus { outline: 2px solid var(--moss); outline-offset: 2px; }
.write-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; }
.draft-status { color: var(--muted); font-size: 11px; }
.save-written-note { min-height: 40px; flex: 0 0 auto; border: 1px solid var(--moss-dark); padding: 0 16px; color: white; background: var(--moss-dark); font: 12px var(--mono); cursor: pointer; }
.save-written-note:disabled { opacity: .45; cursor: default; }
.section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; } .section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; }
h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; } h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; }
h1 em { color: var(--moss); font-weight: 500; } h1 em { color: var(--moss); font-weight: 500; }
@@ -43,13 +64,37 @@ h1 em { color: var(--moss); font-weight: 500; }
#record-time { color: var(--muted); font: 12px var(--mono); font-variant-numeric: tabular-nums; } #record-time { color: var(--muted); font: 12px var(--mono); font-variant-numeric: tabular-nums; }
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; } .record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; } .record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
.capture-message { height: 20px; max-width: 590px; margin-inline: auto; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; } .capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; }
.capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; }
.audio-upload-row { max-width: 590px; margin: 10px auto 10 px; display: flex; align-items: center; justify-content: center; gap: 12px; }
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
.audio-upload-button:hover { border-color: var(--moss); }
#audio-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
.audio-upload-row > span { color: var(--muted); font-size: 10px; }
.queue-section, .archive-section { padding: 30px 0 34px; border-bottom: 1px solid var(--line); } .queue-section, .archive-section { padding: 30px 0 34px; border-bottom: 1px solid var(--line); }
.section-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 18px; text-align: center; } .section-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 18px; text-align: center; }
.section-heading .section-kicker { justify-content: center; } .section-heading .section-kicker { justify-content: center; }
h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); } h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
#queue-count { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 5px; border-radius: 50%; background: #e6e7df; color: var(--moss-dark); font: 11px var(--mono); vertical-align: 3px; } #queue-count { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 5px; border-radius: 50%; background: #e6e7df; color: var(--moss-dark); font: 11px var(--mono); vertical-align: 3px; }
.icon-button { position: absolute; right: 0; width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; color: var(--moss-dark); font-size: 22px; cursor: pointer; } .icon-button { position: absolute; right: 0; width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; color: var(--moss-dark); font-size: 22px; cursor: pointer; }
.settings-button { position: static; flex: 0 0 auto; font-size: 18px; }
.settings-dialog { width: min(520px, calc(100% - 32px)); max-height: min(720px, calc(100% - 32px)); border: 1px solid var(--line); padding: 24px; color: var(--ink); background: var(--paper); }
.settings-dialog::backdrop { background: rgb(32 39 36 / 45%); }
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.dialog-heading h2 { margin: 0; }
.dialog-close { position: static; flex: 0 0 auto; font-size: 24px; }
.settings-copy { margin: 16px 0; color: #606b63; font-size: 12px; line-height: 1.5; }
#backup-fields { display: grid; gap: 8px; margin: 0; border: 0; padding: 0; }
#backup-fields:disabled { opacity: .62; }
#backup-fields > label { margin-top: 6px; font: 10px var(--mono); color: var(--muted); }
#backup-fields > input { width: 100%; min-width: 0; height: 40px; border: 1px solid var(--line); padding: 0 10px; color: var(--ink); background: #fffefa; font: 12px var(--sans); }
#backup-fields > input:focus { outline: 2px solid var(--moss); outline-offset: 1px; }
.clear-password { display: flex; align-items: center; gap: 8px; }
.settings-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 8px; }
.secondary-button { min-height: 40px; border: 1px solid var(--line); padding: 0 13px; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
.backup-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
.retry-backup { margin-top: 8px; }
.icon-button:disabled { opacity: .4; cursor: wait; } .icon-button:disabled { opacity: .4; cursor: wait; }
.queue-list, .archive-list { list-style: none; margin: 16px 0 0; padding: 0; } .queue-list, .archive-list { list-style: none; margin: 16px 0 0; padding: 0; }
.queue-item, .archive-item { display: flex; align-items: flex-start; gap: 15px; padding: 14px 0; border-top: 1px solid #e3e4dd; animation: reveal .35s ease both; } .queue-item, .archive-item { display: flex; align-items: flex-start; gap: 15px; padding: 14px 0; border-top: 1px solid #e3e4dd; animation: reveal .35s ease both; }
@@ -69,19 +114,28 @@ h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
.archive-transcript:focus { border-bottom-color: var(--moss); } .archive-transcript:focus { border-bottom-color: var(--moss); }
.save-transcript { margin-top: 7px; border: 0; padding: 0; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; } .save-transcript { margin-top: 7px; border: 0; padding: 0; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
.archive-status { color: var(--muted); font: 10px var(--mono); margin-top: 7px; } .archive-status { color: var(--muted); font: 10px var(--mono); margin-top: 7px; }
.transcription-progress { width: min(240px, 100%); height: 3px; margin-top: 9px; overflow: hidden; background: #e4e3dc; }
.transcription-progress span { display: block; width: 34%; height: 100%; background: var(--moss); animation: transcription-progress 1.4s ease-in-out infinite; }
footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font: 9px var(--mono); } footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font: 9px var(--mono); }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } @keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
@keyframes transcription-progress { from { transform: translateX(-110%); } to { transform: translateX(300%); } }
@media (max-width: 600px) { @media (max-width: 600px) {
.topbar { height: 58px; padding: 0 18px; } .topbar { height: 58px; padding: 0 18px; }
.topbar-actions { gap: 10px; }
main { padding: 0 18px; } main { padding: 0 18px; }
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; } .capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
.capture-mode { min-width: 86px; padding-inline: 12px; }
.write-actions { align-items: flex-start; }
h1 { font-size: 48px; } h1 { font-size: 48px; }
.recorder-row { gap: 12px; padding-right: 12px; } .recorder-row { gap: 12px; padding-right: 12px; }
.record-button { width: 46px; height: 46px; } .record-button { width: 46px; height: 46px; }
.record-copy strong { font-size: 12px; } .record-copy strong { font-size: 12px; }
.record-copy span { font-size: 10px; } .record-copy span { font-size: 10px; }
.audio-upload-row { align-items: center; flex-direction: column; }
.audio-upload-row > span { text-align: center; }
h2 { font-size: 23px; } h2 { font-size: 23px; }
.archive-date { width: 60px; font-size: 9px; } .archive-date { width: 60px; font-size: 9px; }
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; } footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
.settings-dialog { padding: 18px; }
} }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'faerro-kb-shell-v2'; const CACHE_NAME = 'faerro-kb-shell-v4';
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg']; const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
+20 -3
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
image="gitea.faerro.it/andrea/faerro-kb" domain="registry.gitea.faerro.it"
image="$domain/andrea/faerro-kb"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bump="patch" bump="patch"
@@ -32,6 +33,11 @@ if ! command -v git >/dev/null 2>&1; then
exit 1 exit 1
fi fi
if [[ -n "$(git -C "$repo_root" status --porcelain)" ]]; then
echo "Commit or discard all staged, unstaged, and untracked changes before publishing." >&2
exit 1
fi
latest_version="" latest_version=""
while IFS= read -r candidate; do while IFS= read -r candidate; do
if [[ "$candidate" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then if [[ "$candidate" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
@@ -45,6 +51,12 @@ if [[ -z "$latest_version" ]]; then
exit 1 exit 1
fi fi
commits_since_release="$(git -C "$repo_root" rev-list --count "$latest_version..HEAD")"
if [[ "$commits_since_release" -eq 0 ]]; then
echo "No new commits since Git tag $latest_version; nothing to publish." >&2
exit 1
fi
IFS=. read -r major minor patch <<< "$latest_version" IFS=. read -r major minor patch <<< "$latest_version"
case "$bump" in case "$bump" in
major) major)
@@ -67,7 +79,7 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/tags/$version"; then
exit 1 exit 1
fi fi
git -C "$repo_root" tag "$version" git -C "$repo_root" tag --annotate "$version" --message "Release $version" HEAD
cd "$repo_root" cd "$repo_root"
docker build --tag "$image:$version" --tag "$image:latest" . docker build --tag "$image:$version" --tag "$image:latest" .
@@ -91,7 +103,7 @@ if [[ -n "$registry_username" || -n "$registry_token" ]]; then
echo "Set both GITEA_USERNAME and GITEA_TOKEN in .env to log in automatically." >&2 echo "Set both GITEA_USERNAME and GITEA_TOKEN in .env to log in automatically." >&2
exit 1 exit 1
fi fi
if ! printf '%s' "$registry_token" | docker login --username "$registry_username" --password-stdin gitea.faerro.it; then if ! printf '%s' "$registry_token" | docker login --username "$registry_username" --password-stdin "$domain"; then
unset registry_token unset registry_token
exit 1 exit 1
fi fi
@@ -100,4 +112,9 @@ fi
docker push "$image:$version" docker push "$image:$version"
docker push "$image:latest" docker push "$image:latest"
while IFS= read -r local_image; do
[[ -z "$local_image" || "$local_image" == "$image:$version" || "$local_image" == "$image:latest" ]] && continue
docker image rm "$local_image"
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' "$image")
echo "Published $image:$version and $image:latest" echo "Published $image:$version and $image:latest"