Compare commits
6
Commits
eb83b16b54
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4f46a7d1 | ||
|
|
f4100af8ec | ||
|
|
c883fbc47f | ||
|
|
651c6de392 | ||
|
|
cb16c77bad | ||
|
|
0f7d5e950c |
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- Never claim that iOS guarantees background execution or that browser storage is permanent.
|
||||
@@ -9,11 +9,11 @@ Use this skill when asked to tag or publish a Faerro KB image. The release proce
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Check the worktree and identify the exact commit being released. 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`.
|
||||
3. The script creates an unused local tag on the checked-out commit; do not push Git tags to a remote unless requested.
|
||||
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 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
+4
-1
@@ -2,7 +2,10 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /srv/faerro-kb
|
||||
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 frontend ./frontend
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ docker compose up --build
|
||||
|
||||
## 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
|
||||
cp .env.example .env
|
||||
@@ -18,7 +18,9 @@ cp .env.example .env
|
||||
./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.
|
||||
|
||||
@@ -27,21 +29,24 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
|
||||
## Capture and offline behavior
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
+112
-11
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -44,10 +47,26 @@ def initialize_db() -> None:
|
||||
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
||||
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()
|
||||
|
||||
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:
|
||||
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:
|
||||
if not WHISPER_SERVER_URL:
|
||||
return
|
||||
@@ -76,9 +112,12 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
||||
)
|
||||
response.raise_for_status()
|
||||
transcript = response.json().get("text", "").strip()
|
||||
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
|
||||
except Exception:
|
||||
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")
|
||||
@@ -102,15 +141,39 @@ async def create_note(
|
||||
if existing:
|
||||
return {"id": note_id, "status": existing["status"]}
|
||||
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"
|
||||
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:
|
||||
with audio_path.open("wb") as destination:
|
||||
with original_audio_path.open("wb") as destination:
|
||||
while chunk := await audio.read(1024 * 1024):
|
||||
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:
|
||||
original_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
|
||||
finally:
|
||||
await audio.close()
|
||||
@@ -126,14 +189,46 @@ async def create_note(
|
||||
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
|
||||
with connect_db() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), status),
|
||||
"""INSERT OR IGNORE INTO notes
|
||||
(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:
|
||||
background_tasks.add_task(transcribe_note, note_id, audio_path)
|
||||
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")
|
||||
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
||||
with connect_db() as connection:
|
||||
@@ -173,13 +268,16 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
||||
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,)
|
||||
"SELECT audio_path, original_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,))
|
||||
if note["audio_path"]:
|
||||
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"}
|
||||
|
||||
|
||||
@@ -187,11 +285,14 @@ def delete_note(note_id: str) -> dict[str, str]:
|
||||
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
||||
transcript = payload.get("transcript", "").strip()
|
||||
with connect_db() as connection:
|
||||
exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if not exists:
|
||||
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
|
||||
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
|
||||
status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription"
|
||||
update_transcript(note_id, transcript, status)
|
||||
if transcript and note["status"] != "written":
|
||||
remove_transcribed_audio(note_id)
|
||||
return {"id": note_id, "status": status}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
faerro-kb:
|
||||
image: gitea.faerro.it/andrea/faerro-kb:0.0.1
|
||||
image: gitea.faerro.it/andrea/faerro-kb:latest
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
environment:
|
||||
|
||||
+178
-20
@@ -1,10 +1,12 @@
|
||||
const DB_NAME = 'faerro-kb-local';
|
||||
const STORE_NAME = 'recordings';
|
||||
const TEXT_STORE_NAME = 'text-notes';
|
||||
const DRAFT_ID = 'current-draft';
|
||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||
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',
|
||||
'network-state', 'storage-status',
|
||||
'network-state', 'storage-status', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
|
||||
let databasePromise;
|
||||
@@ -16,12 +18,17 @@ let recordingChunks = [];
|
||||
let recordingStartedAt = 0;
|
||||
let recordingTimer;
|
||||
let syncing = false;
|
||||
let draftSaveTimer;
|
||||
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
const request = indexedDB.open(DB_NAME, 2);
|
||||
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.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -44,17 +51,107 @@ const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
|
||||
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
|
||||
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';
|
||||
elements['voice-mode'].classList.toggle('active', !writing);
|
||||
elements['write-mode'].classList.toggle('active', writing);
|
||||
elements['voice-mode'].setAttribute('aria-selected', String(!writing));
|
||||
elements['write-mode'].setAttribute('aria-selected', String(writing));
|
||||
elements['voice-panel'].hidden = writing;
|
||||
elements['write-panel'].hidden = !writing;
|
||||
}
|
||||
|
||||
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'].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() {
|
||||
const online = navigator.onLine;
|
||||
elements['network-state'].classList.toggle('online', online);
|
||||
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.';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
@@ -170,7 +267,7 @@ async function saveRecording() {
|
||||
setMessage('No audio was captured. Please try again.', true);
|
||||
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 recording = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -190,6 +287,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() {
|
||||
if (syncing || !navigator.onLine) return;
|
||||
syncing = true;
|
||||
@@ -206,12 +324,28 @@ async function syncQueue() {
|
||||
const response = await fetch('/api/notes', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||
await deleteQueued(recording.id);
|
||||
setMessage('Server confirmed the recording is stored.');
|
||||
setMessage('Upload complete', false, true);
|
||||
} catch {
|
||||
setMessage('Upload paused. Your recording is still saved on this device.');
|
||||
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 {
|
||||
syncing = false;
|
||||
elements['sync-button'].disabled = false;
|
||||
@@ -225,7 +359,9 @@ function prettyDate(value) {
|
||||
}
|
||||
|
||||
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-count'].textContent = recordings.length;
|
||||
elements['queue-empty'].hidden = recordings.length > 0;
|
||||
@@ -234,7 +370,8 @@ async function renderQueue() {
|
||||
item.className = 'queue-item';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'queue-icon';
|
||||
icon.textContent = '◖';
|
||||
const isTextNote = 'text' in recording;
|
||||
icon.textContent = isTextNote ? 'T' : '◖';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'item-body';
|
||||
const top = document.createElement('div');
|
||||
@@ -247,14 +384,15 @@ async function renderQueue() {
|
||||
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
|
||||
const meta = document.createElement('div');
|
||||
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}`);
|
||||
remove.disabled = syncing;
|
||||
remove.addEventListener('click', async () => {
|
||||
if (syncing) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await deleteQueued(recording.id);
|
||||
if (isTextNote) await deleteTextRecord(recording.id);
|
||||
else await deleteQueued(recording.id);
|
||||
await renderQueue();
|
||||
} catch {
|
||||
setMessage('Could not remove the queued recording.', true);
|
||||
@@ -281,8 +419,8 @@ function createDeleteButton(label) {
|
||||
function renderArchive(notes) {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = notes.length > 0;
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.';
|
||||
else elements['archive-empty'].textContent = 'No notes awaiting transcription.';
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
|
||||
else elements['archive-empty'].textContent = 'No saved notes yet.';
|
||||
for (const note of notes) {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'archive-item';
|
||||
@@ -294,15 +432,16 @@ function renderArchive(notes) {
|
||||
const name = document.createElement('div');
|
||||
name.className = 'item-name';
|
||||
name.textContent = note.filename;
|
||||
const isWrittenNote = note.status === 'written';
|
||||
const transcript = document.createElement('textarea');
|
||||
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.setAttribute('aria-label', `Transcript for ${note.filename}`);
|
||||
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
|
||||
const save = document.createElement('button');
|
||||
save.className = 'save-transcript';
|
||||
save.type = 'button';
|
||||
save.textContent = 'SAVE TRANSCRIPT';
|
||||
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT';
|
||||
save.addEventListener('click', async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
@@ -319,7 +458,7 @@ function renderArchive(notes) {
|
||||
});
|
||||
const status = document.createElement('div');
|
||||
status.className = 'archive-status';
|
||||
status.textContent = 'AWAITING TRANSCRIPTION';
|
||||
status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : 'AWAITING TRANSCRIPTION';
|
||||
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
@@ -344,9 +483,14 @@ async function loadAwaitingNotes() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/notes?status=awaiting_transcription');
|
||||
if (!response.ok) throw new Error('Archive unavailable');
|
||||
renderArchive(await response.json());
|
||||
const responses = await Promise.all([
|
||||
fetch('/api/notes?status=awaiting_transcription'),
|
||||
fetch('/api/notes?status=written'),
|
||||
]);
|
||||
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 {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = false;
|
||||
@@ -355,6 +499,19 @@ async function loadAwaitingNotes() {
|
||||
}
|
||||
|
||||
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['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);
|
||||
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
|
||||
window.addEventListener('offline', updateNetwork);
|
||||
@@ -363,5 +520,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
|
||||
|
||||
updateNetwork();
|
||||
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
||||
restoreDraft();
|
||||
loadAwaitingNotes();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 295 B |
+21
-3
@@ -13,12 +13,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<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>
|
||||
</div>
|
||||
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
|
||||
<div class="recorder-row">
|
||||
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
|
||||
<span class="record-dot"></span>
|
||||
@@ -32,8 +37,21 @@
|
||||
</div>
|
||||
<time id="record-time">00:00</time>
|
||||
</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 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 class="queue-section" aria-labelledby="queue-title">
|
||||
@@ -47,14 +65,14 @@
|
||||
|
||||
<section class="archive-section" aria-labelledby="archive-title">
|
||||
<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 id="archive-list" class="archive-list"></div>
|
||||
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><p>Faerro KB - Copyright Andrea Ferronato © 2026</p></footer>
|
||||
<footer><p>Faerro KB © 2026</p></footer>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+24
-2
@@ -18,12 +18,23 @@ 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; }
|
||||
.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-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 i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); }
|
||||
.network-state.online i { background: #57986b; }
|
||||
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-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); }
|
||||
.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; }
|
||||
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; }
|
||||
@@ -43,7 +54,14 @@ h1 em { color: var(--moss); font-weight: 500; }
|
||||
#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 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: 14px auto 6px; 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); }
|
||||
.section-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 18px; text-align: center; }
|
||||
.section-heading .section-kicker { justify-content: center; }
|
||||
@@ -75,11 +93,15 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
|
||||
.topbar { height: 58px; padding: 0 18px; }
|
||||
main { padding: 0 18px; }
|
||||
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
|
||||
.capture-mode { min-width: 100px; }
|
||||
.write-actions { align-items: flex-start; }
|
||||
h1 { font-size: 48px; }
|
||||
.recorder-row { gap: 12px; padding-right: 12px; }
|
||||
.record-button { width: 46px; height: 46px; }
|
||||
.record-copy strong { font-size: 12px; }
|
||||
.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; }
|
||||
.archive-date { width: 60px; font-size: 9px; }
|
||||
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'faerro-kb-shell-v2';
|
||||
const CACHE_NAME = 'faerro-kb-shell-v3';
|
||||
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
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)"
|
||||
bump="patch"
|
||||
|
||||
@@ -32,6 +33,11 @@ if ! command -v git >/dev/null 2>&1; then
|
||||
exit 1
|
||||
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=""
|
||||
while IFS= read -r candidate; do
|
||||
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
|
||||
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"
|
||||
case "$bump" in
|
||||
major)
|
||||
@@ -67,7 +79,7 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/tags/$version"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$repo_root" tag "$version"
|
||||
git -C "$repo_root" tag --annotate "$version" --message "Release $version" HEAD
|
||||
|
||||
cd "$repo_root"
|
||||
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
|
||||
exit 1
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
@@ -100,4 +112,9 @@ fi
|
||||
docker push "$image:$version"
|
||||
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"
|
||||
Reference in New Issue
Block a user