Compare commits

...
2 Commits
11 changed files with 150 additions and 28 deletions
+1 -1
View File
@@ -25,4 +25,4 @@ Faerro KB is a self-hosted voice-note inbox designed for an iPhone-installed web
## 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.
+2 -2
View File
@@ -9,9 +9,9 @@ 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 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`.
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`.
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.
+4 -1
View File
@@ -2,7 +2,10 @@ 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
+6 -4
View File
@@ -18,7 +18,7 @@ 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.
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 +27,23 @@ 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.
- 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 `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` until a non-empty transcript is entered and saved. Saving that 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. 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.
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.
## Development ## Development
+77 -7
View File
@@ -1,6 +1,9 @@
import asyncio
import os import os
import re import re
import sqlite3 import sqlite3
import subprocess
import tempfile
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -44,10 +47,26 @@ 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)"""
) )
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")
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:
@@ -63,6 +82,23 @@ def update_transcript(note_id: str, transcript: str, status: str) -> None:
) )
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:
if not WHISPER_SERVER_URL: if not WHISPER_SERVER_URL:
return return
@@ -76,9 +112,12 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
) )
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)
@app.get("/api/health") @app.get("/api/health")
@@ -102,15 +141,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,8 +189,10 @@ 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)
@@ -173,13 +238,16 @@ 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"}
@@ -191,6 +259,8 @@ def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
if not exists: if not exists:
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") update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
if transcript:
remove_transcribed_audio(note_id)
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"} return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
+1 -1
View File
@@ -1,6 +1,6 @@
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:
+35 -5
View File
@@ -2,7 +2,7 @@ const DB_NAME = 'faerro-kb-local';
const STORE_NAME = 'recordings'; const STORE_NAME = 'recordings';
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',
].map((id) => [id, document.getElementById(id)])); ].map((id) => [id, document.getElementById(id)]));
@@ -44,17 +44,21 @@ 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) { 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.';
} }
}
function formatDuration(milliseconds) { function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1000); const seconds = Math.floor(milliseconds / 1000);
@@ -170,7 +174,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 +194,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,7 +231,7 @@ 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('✓', 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;
@@ -247,7 +272,7 @@ 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 () => {
@@ -355,6 +380,11 @@ async function loadAwaitingNotes() {
} }
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
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);
+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

+7 -2
View File
@@ -13,7 +13,7 @@
</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>
<span id="network-state" class="network-state"><i></i> Checking</span> <span id="network-state" class="network-state"><i></i> Checking</span>
</header> </header>
@@ -32,6 +32,11 @@
</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 />
<span>Files stay on this device until the server confirms storage.</span>
</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>
</section> </section>
@@ -54,7 +59,7 @@
</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>
+8 -1
View File
@@ -18,7 +18,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(
.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; }
.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; }
@@ -44,6 +44,12 @@ h1 em { color: var(--moss); font-weight: 500; }
.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 { height: 20px; max-width: 590px; margin-inline: auto; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
.capture-message.upload-confirmed { color: forestgreen; font-size: 16px; }
.audio-upload-row { max-width: 590px; margin: 14px auto 0; 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; }
@@ -80,6 +86,7 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
.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: flex-start; flex-direction: column; }
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; }
+6 -1
View File
@@ -32,6 +32,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
@@ -67,7 +72,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" .