Refactor project name from "Voice KB" to "Faerro KB" across all files and update related configurations

This commit is contained in:
2026-09-26 16:54:29 +02:00
parent 3034d76682
commit 13fcb70a27
10 changed files with 102 additions and 50 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Voice KB project notes
# Faerro KB project notes
- Keep audio, transcripts, and indexes on infrastructure controlled by the user.
- Never add a cloud speech, LLM, or geocoding dependency.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM python:3.12-slim
WORKDIR /srv/voice-kb
WORKDIR /srv/faerro-kb
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
+1 -1
View File
@@ -1,4 +1,4 @@
# Voice KB
# Faerro KB
A self-hosted voice-note inbox for an iPhone-installed web app. Recordings are saved in the browser first, uploaded when connectivity is available, and stored with transcripts on your own server.
+38 -9
View File
@@ -12,14 +12,14 @@ from fastapi.staticfiles import StaticFiles
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
AUDIO_DIR = DATA_DIR / "audio"
DB_PATH = DATA_DIR / "voice-kb.sqlite3"
DB_PATH = DATA_DIR / "faerro-kb.sqlite3"
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
DATA_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Voice KB")
app = FastAPI(title="Faerro KB")
def connect_db() -> sqlite3.Connection:
@@ -51,12 +51,12 @@ initialize_db()
def update_transcript(note_id: str, transcript: str, status: str) -> None:
with connect_db() as connection:
connection.execute(
result = connection.execute(
"UPDATE notes SET transcript = ?, status = ? WHERE id = ?",
(transcript, status, note_id),
)
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
if transcript.strip():
if result.rowcount and transcript.strip():
connection.execute(
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
(note_id, transcript.strip()),
@@ -135,16 +135,31 @@ async def create_note(
@app.get("/api/notes")
def list_notes(q: str = "") -> list[dict[str, str]]:
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
with connect_db() as connection:
search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE)
if search_terms:
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
if status:
rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? AND n.status = ?
ORDER BY n.created_at DESC LIMIT 100""",
(query, status),
).fetchall()
else:
rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
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""",
(query,),
).fetchall()
elif status:
rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
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""",
(query,),
"""SELECT id, created_at, filename, transcript, status
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
(status,),
).fetchall()
else:
rows = connection.execute(
@@ -154,6 +169,20 @@ def list_notes(q: str = "") -> list[dict[str, str]]:
return [dict(row) for row in rows]
@app.delete("/api/notes/{note_id}")
def delete_note(note_id: str) -> dict[str, str]:
with connect_db() as connection:
note = connection.execute(
"SELECT audio_path FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
Path(note["audio_path"]).unlink(missing_ok=True)
return {"id": note_id, "status": "deleted"}
@app.put("/api/notes/{note_id}/transcript")
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
transcript = payload.get("transcript", "").strip()
+1 -1
View File
@@ -1,5 +1,5 @@
services:
voice-kb:
faerro-kb:
image: gitea.faerro.it/andrea/faerro-kb:0.0.1
ports:
- "${HTTP_PORT:-80}:80"
+50 -22
View File
@@ -1,9 +1,9 @@
const DB_NAME = 'voice-kb-local';
const DB_NAME = 'faerro-kb-local';
const STORE_NAME = 'recordings';
const MAX_RECORDING_MS = 30 * 60 * 1000;
const elements = Object.fromEntries([
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'search-input',
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
'network-state', 'date-label', 'storage-status',
].map((id) => [id, document.getElementById(id)]));
@@ -13,7 +13,6 @@ let activeStream;
let recordingChunks = [];
let recordingStartedAt = 0;
let recordingTimer;
let searchTimer;
let syncing = false;
function openDatabase() {
@@ -138,6 +137,7 @@ async function syncQueue() {
if (syncing || !navigator.onLine) return;
syncing = true;
elements['sync-button'].disabled = true;
elements['queue-list'].querySelectorAll('.delete-note').forEach((button) => { button.disabled = true; });
try {
const recordings = await getQueued();
for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) {
@@ -159,7 +159,7 @@ async function syncQueue() {
syncing = false;
elements['sync-button'].disabled = false;
await renderQueue();
await loadArchive(elements['search-input'].value.trim());
await loadAwaitingNotes();
}
}
@@ -191,18 +191,41 @@ async function renderQueue() {
const meta = document.createElement('div');
meta.className = 'item-meta';
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
const remove = createDeleteButton(`Delete queued note ${recording.filename}`);
remove.disabled = syncing;
remove.addEventListener('click', async () => {
if (syncing) return;
remove.disabled = true;
try {
await deleteQueued(recording.id);
await renderQueue();
} catch {
setMessage('Could not remove the queued recording.', true);
remove.disabled = false;
}
});
top.append(name, status);
body.append(top, meta);
item.append(icon, body);
item.append(icon, body, remove);
elements['queue-list'].append(item);
}
}
function createDeleteButton(label) {
const button = document.createElement('button');
button.className = 'delete-note';
button.type = 'button';
button.textContent = '×';
button.title = label;
button.setAttribute('aria-label', label);
return button;
}
function renderArchive(notes) {
elements['archive-list'].replaceChildren();
elements['archive-empty'].hidden = notes.length > 0;
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to browse or search stored notes.';
else elements['archive-empty'].textContent = elements['search-input'].value.trim() ? 'No matching transcripts.' : 'Your stored notes will appear here.';
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.';
else elements['archive-empty'].textContent = 'No notes awaiting transcription.';
for (const note of notes) {
const item = document.createElement('article');
item.className = 'archive-item';
@@ -230,8 +253,7 @@ function renderArchive(notes) {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
});
if (!response.ok) throw new Error('Save failed');
save.textContent = 'SAVED';
await loadArchive(elements['search-input'].value.trim());
await loadAwaitingNotes();
} catch {
save.textContent = 'RETRY SAVE';
} finally {
@@ -240,22 +262,32 @@ function renderArchive(notes) {
});
const status = document.createElement('div');
status.className = 'archive-status';
status.textContent = note.status.replaceAll('_', ' ').toUpperCase();
status.textContent = 'AWAITING TRANSCRIPTION';
const remove = createDeleteButton(`Delete note ${note.filename}`);
remove.addEventListener('click', async () => {
remove.disabled = true;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Delete failed');
await loadAwaitingNotes();
} catch {
remove.disabled = false;
setMessage('Could not delete this note. Please try again.', true);
}
});
body.append(name, transcript, save, status);
item.append(date, body);
item.append(date, body, remove);
elements['archive-list'].append(item);
}
}
async function loadArchive(query = '') {
async function loadAwaitingNotes() {
if (!navigator.onLine) {
renderArchive([]);
return;
}
try {
const url = new URL('/api/notes', window.location.origin);
if (query) url.searchParams.set('q', query);
const response = await fetch(url);
const response = await fetch('/api/notes?status=awaiting_transcription');
if (!response.ok) throw new Error('Archive unavailable');
renderArchive(await response.json());
} catch {
@@ -267,17 +299,13 @@ async function loadArchive(query = '') {
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
elements['sync-button'].addEventListener('click', syncQueue);
elements['search-input'].addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadArchive(elements['search-input'].value.trim()), 250);
});
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
window.addEventListener('offline', updateNetwork);
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); });
elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase();
updateNetwork();
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
loadArchive();
loadAwaitingNotes();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
+5 -8
View File
@@ -6,21 +6,19 @@
<meta name="theme-color" content="#f5f3ed" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>Voice KB</title>
<title>Faerro KB</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Voice KB home"><span class="brand-mark">V</span><span>voice <b>kb</b></span></a>
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">V</span><span>voice <b>kb</b></span></a>
<span id="network-state" class="network-state"><i></i> Checking</span>
</header>
<main>
<section class="capture" aria-labelledby="capture-title">
<div class="section-kicker"><span>PERSONAL ARCHIVE</span><span id="date-label"></span></div>
<h1 id="capture-title">Catch the thought<br /><em>before it goes.</em></h1>
<p class="intro">Private voice notes, kept on your own server.</p>
<div class="recorder-row">
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
<span class="record-dot"></span>
@@ -46,15 +44,14 @@
<section class="archive-section" aria-labelledby="archive-title">
<div class="section-heading archive-heading">
<div><span class="section-kicker">YOUR LIBRARY</span><h2 id="archive-title">Recent notes</h2></div>
<label class="search-box"><span aria-hidden="true">⌕</span><input id="search-input" type="search" placeholder="Search transcripts" autocomplete="off" /></label>
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Awaiting transcription</h2></div>
</div>
<div id="archive-list" class="archive-list"></div>
<p id="archive-empty" class="empty-state">Your stored notes will appear here.</p>
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
</section>
</main>
<footer><span>LOCAL FIRST</span><span id="storage-status">Audio stays on this device until uploaded.</span></footer>
<footer><p>Faerro KB - Copyright Andrea Ferronato © 2026</p></footer>
<script src="/app.js" defer></script>
</body>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Voice KB",
"short_name": "Voice KB",
"name": "Faerro KB",
"short_name": "Faerro KB",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f3ed",
+2 -4
View File
@@ -56,12 +56,10 @@ h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
.item-name { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.item-meta { margin-top: 5px; color: var(--muted); font: 10px var(--mono); }
.item-status { color: var(--moss-dark); font: 10px var(--mono); white-space: nowrap; }
.delete-note { width: 32px; height: 32px; flex: 0 0 auto; border: 1px solid var(--line); background: transparent; color: var(--coral); font: 20px/1 var(--sans); cursor: pointer; }
.delete-note:disabled { opacity: .45; cursor: wait; }
.empty-state { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
.archive-heading { align-items: end; }
.search-box { width: min(280px, 52vw); height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--line); background: #fffefa; color: var(--muted); }
.search-box span { font-size: 22px; line-height: 1; }
.search-box input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font: 12px var(--sans); }
.search-box input::placeholder { color: #899088; }
.archive-item { padding: 16px 0; }
.archive-date { width: 72px; flex: 0 0 auto; color: var(--muted); font: 10px/1.5 var(--mono); }
.archive-transcript { width: 100%; min-height: 56px; resize: vertical; border: 0; border-bottom: 1px solid #e2e3dc; outline: 0; padding: 0 0 9px; background: transparent; color: var(--ink); font: 13px/1.55 var(--sans); }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'voice-kb-shell-v1';
const CACHE_NAME = 'faerro-kb-shell-v1';
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {