Compare commits
4
Commits
cb16c77bad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4f46a7d1 | ||
|
|
f4100af8ec | ||
|
|
c883fbc47f | ||
|
|
651c6de392 |
@@ -19,6 +19,7 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -10,10 +10,10 @@ Use this skill when asked to tag or publish a Faerro KB image. The release proce
|
||||
## Procedure
|
||||
|
||||
1. Require a clean worktree and identify the exact committed `HEAD` being released. Staged, unstaged, or untracked changes must be committed or removed before running the script. Do not tag unrelated or unreviewed changes.
|
||||
2. The script selects the next patch version by default. Use `--minor` or `--major` only when the user requests that increment; lower components are reset. Tags must be exact `MAJOR.MINOR.PATCH`.
|
||||
2. The script requires at least one commit after the latest exact `MAJOR.MINOR.PATCH` tag, then selects the next patch version by default. Use `--minor` or `--major` only when the user requests that increment; lower components are reset.
|
||||
3. The script creates an unused 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.
|
||||
@@ -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
|
||||
@@ -20,6 +20,8 @@ cp .env.example .env
|
||||
|
||||
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.
|
||||
|
||||
In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen**. Sign in to the VPN before opening the app if the server is only reachable there.
|
||||
@@ -27,6 +29,7 @@ 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.
|
||||
|
||||
+36
-5
@@ -199,6 +199,36 @@ async def create_note(
|
||||
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:
|
||||
@@ -255,13 +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")
|
||||
if transcript:
|
||||
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": "transcribed" if transcript else "awaiting_transcription"}
|
||||
return {"id": note_id, "status": status}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
+144
-16
@@ -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', '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,6 +51,92 @@ 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));
|
||||
|
||||
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)' : '';
|
||||
@@ -231,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('✓', false, true);
|
||||
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;
|
||||
@@ -250,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;
|
||||
@@ -259,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');
|
||||
@@ -279,7 +391,8 @@ async function renderQueue() {
|
||||
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);
|
||||
@@ -306,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';
|
||||
@@ -319,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 {
|
||||
@@ -344,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;
|
||||
@@ -369,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;
|
||||
@@ -380,6 +499,14 @@ 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 = '';
|
||||
@@ -393,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(() => {});
|
||||
+15
-2
@@ -19,6 +19,11 @@
|
||||
|
||||
<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>
|
||||
@@ -35,10 +40,18 @@
|
||||
<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 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">
|
||||
@@ -52,7 +65,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
+19
-4
@@ -24,6 +24,17 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(
|
||||
.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,9 +54,10 @@ 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.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; }
|
||||
.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%); }
|
||||
@@ -81,12 +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: flex-start; flex-direction: column; }
|
||||
.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"
|
||||
|
||||
@@ -50,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)
|
||||
@@ -96,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
|
||||
@@ -105,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