Merge pull request #932 from timmy/931-recover-mobile-voice-transcripts
Recover interrupted mobile voice transcripts privately Closes #931
This commit is contained in:
commit
785fb990b4
|
|
@ -748,9 +748,11 @@
|
||||||
root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'),
|
root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'),
|
||||||
stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'),
|
stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'),
|
||||||
transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'),
|
transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'),
|
||||||
replace:qs('#replace-with-voice-issue-transcript'), status:qs('#voice-issue-status'),
|
replace:qs('#replace-with-voice-issue-transcript'), discard:qs('#discard-voice-issue-transcript'),
|
||||||
title:qs('#create-issue-title'), body:qs('#create-issue-body'),
|
status:qs('#voice-issue-status'), title:qs('#create-issue-title'), body:qs('#create-issue-body'),
|
||||||
},
|
},
|
||||||
|
transcriptStore:createVoiceTranscriptStore(),
|
||||||
|
getLogin:()=>confirmedOwnerLogin,
|
||||||
});
|
});
|
||||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||||
|
|
|
||||||
|
|
@ -833,6 +833,7 @@
|
||||||
<div class="voice-issue-review-actions">
|
<div class="voice-issue-review-actions">
|
||||||
<button id="append-voice-issue-transcript" type="button">Append to draft</button>
|
<button id="append-voice-issue-transcript" type="button">Append to draft</button>
|
||||||
<button id="replace-with-voice-issue-transcript" type="button">Replace draft</button>
|
<button id="replace-with-voice-issue-transcript" type="button">Replace draft</button>
|
||||||
|
<button id="discard-voice-issue-transcript" type="button" class="secondary">Discard transcript</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1369,6 +1370,7 @@
|
||||||
<script src="static/issue-attachment.js"></script>
|
<script src="static/issue-attachment.js"></script>
|
||||||
<script src="static/issue-filing-review.js"></script>
|
<script src="static/issue-filing-review.js"></script>
|
||||||
<script src="static/issue-sheet.js"></script>
|
<script src="static/issue-sheet.js"></script>
|
||||||
|
<script src="static/voice-transcript-store.js"></script>
|
||||||
<script src="static/voice-issue-capture.js"></script>
|
<script src="static/voice-issue-capture.js"></script>
|
||||||
<script src="static/create-issue-sheet.js"></script>
|
<script src="static/create-issue-sheet.js"></script>
|
||||||
<script src="static/create-and-start.js"></script>
|
<script src="static/create-and-start.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
'stackchain-background-outbox-v1',
|
'stackchain-background-outbox-v1',
|
||||||
'stackchain-offline-work-v2',
|
'stackchain-offline-work-v2',
|
||||||
'stackchain-unfiled-captures-v1',
|
'stackchain-unfiled-captures-v1',
|
||||||
|
'stackchain-voice-transcripts-v1',
|
||||||
]);
|
]);
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||||
else root.stackchainPrivateDatabases = databases;
|
else root.stackchainPrivateDatabases = databases;
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ const SHELL = [
|
||||||
BASE + 'static/issue-filing-review.js',
|
BASE + 'static/issue-filing-review.js',
|
||||||
BASE + 'static/issue-sheet.js',
|
BASE + 'static/issue-sheet.js',
|
||||||
BASE + 'static/checklist-conflict.js',
|
BASE + 'static/checklist-conflict.js',
|
||||||
|
BASE + 'static/voice-transcript-store.js',
|
||||||
BASE + 'static/voice-issue-capture.js',
|
BASE + 'static/voice-issue-capture.js',
|
||||||
BASE + 'static/create-issue-sheet.js',
|
BASE + 'static/create-issue-sheet.js',
|
||||||
BASE + 'static/create-and-start.js',
|
BASE + 'static/create-and-start.js',
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,34 @@ function mapVoiceTranscript(value) {
|
||||||
return {title, body};
|
return {title, body};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createVoiceIssueCapture({Recognition, elements, createEvent = name => new Event(name, {bubbles:true})}) {
|
function createVoiceIssueCapture({
|
||||||
|
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
|
||||||
|
transcriptStore = null, getLogin = () => '',
|
||||||
|
}) {
|
||||||
const supported = typeof Recognition === 'function';
|
const supported = typeof Recognition === 'function';
|
||||||
|
const login = String(getLogin() || '').trim();
|
||||||
|
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
|
||||||
|
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||||
|
if (recovered) showReview(recovered, {persist:false, recovered:true});
|
||||||
|
}) : Promise.resolve();
|
||||||
elements.root.hidden = !supported;
|
elements.root.hidden = !supported;
|
||||||
if (!supported) {
|
if (!supported) {
|
||||||
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
|
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
|
||||||
return {supported:false, cancel() {}};
|
return {supported:false, ready, cancel() {}};
|
||||||
}
|
}
|
||||||
let recognition = null;
|
let recognition = null;
|
||||||
|
|
||||||
function showReview(value) {
|
function showReview(value, {persist = true, recovered = false} = {}) {
|
||||||
elements.transcript.value = value;
|
elements.transcript.value = value;
|
||||||
|
const activeLogin = String(getLogin() || '').trim();
|
||||||
|
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
|
||||||
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
|
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
|
||||||
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
|
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
|
||||||
elements.replace.hidden = !hasDraft;
|
elements.replace.hidden = !hasDraft;
|
||||||
elements.review.hidden = false;
|
elements.review.hidden = false;
|
||||||
elements.status.textContent = 'Transcript ready. Review it before using it.';
|
elements.status.textContent = recovered ?
|
||||||
|
'Recovered transcript. Review it before using or discarding it.' :
|
||||||
|
'Transcript ready. Review it before using it.';
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.start.addEventListener('click', () => {
|
elements.start.addEventListener('click', () => {
|
||||||
|
|
@ -33,7 +45,11 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n
|
||||||
const finalText = Array.from(event.results || [])
|
const finalText = Array.from(event.results || [])
|
||||||
.filter(result => result.isFinal)
|
.filter(result => result.isFinal)
|
||||||
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
||||||
if (finalText) showReview(finalText);
|
if (finalText) {
|
||||||
|
const accumulated = [elements.transcript.value.trim(), finalText]
|
||||||
|
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||||
|
showReview(accumulated);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
recognition.onerror = event => {
|
recognition.onerror = event => {
|
||||||
elements.start.hidden = false;
|
elements.start.hidden = false;
|
||||||
|
|
@ -56,6 +72,11 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n
|
||||||
elements.status.textContent = 'Finishing transcript…';
|
elements.status.textContent = 'Finishing transcript…';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function clearCheckpoint() {
|
||||||
|
const activeLogin = String(getLogin() || '').trim();
|
||||||
|
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
|
||||||
|
}
|
||||||
|
|
||||||
function commit(mode) {
|
function commit(mode) {
|
||||||
const mapped = mapVoiceTranscript(elements.transcript.value);
|
const mapped = mapVoiceTranscript(elements.transcript.value);
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
|
|
@ -71,13 +92,20 @@ function createVoiceIssueCapture({Recognition, elements, createEvent = name => n
|
||||||
}
|
}
|
||||||
elements.title.dispatchEvent(createEvent('input'));
|
elements.title.dispatchEvent(createEvent('input'));
|
||||||
elements.body.dispatchEvent(createEvent('input'));
|
elements.body.dispatchEvent(createEvent('input'));
|
||||||
|
clearCheckpoint();
|
||||||
elements.review.hidden = true;
|
elements.review.hidden = true;
|
||||||
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
|
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
|
||||||
}
|
}
|
||||||
elements.append.addEventListener('click', () => commit('append'));
|
elements.append.addEventListener('click', () => commit('append'));
|
||||||
elements.replace.addEventListener('click', () => commit('replace'));
|
elements.replace.addEventListener('click', () => commit('replace'));
|
||||||
|
elements.discard?.addEventListener('click', () => {
|
||||||
|
clearCheckpoint();
|
||||||
|
elements.transcript.value = '';
|
||||||
|
elements.review.hidden = true;
|
||||||
|
elements.status.textContent = 'Recovered transcript discarded.';
|
||||||
|
});
|
||||||
|
|
||||||
return {supported:true, cancel() { recognition?.abort(); recognition = null; }};
|
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
|
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
|
||||||
|
|
|
||||||
87
frontend/voice-transcript-store.js
Normal file
87
frontend/voice-transcript-store.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const createVoiceTranscriptStore = factory(root.indexedDB);
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
|
||||||
|
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
|
||||||
|
const dbName = 'stackchain-voice-transcripts-v1';
|
||||||
|
const STORE_NAME = 'checkpoints';
|
||||||
|
const MAX_TRANSCRIPT_LENGTH = 10255;
|
||||||
|
|
||||||
|
function requestResult(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIndexedDbTransaction(indexedDB) {
|
||||||
|
if (!indexedDB) return null;
|
||||||
|
let databasePromise = null;
|
||||||
|
function openDatabase() {
|
||||||
|
if (databasePromise) return databasePromise;
|
||||||
|
databasePromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(dbName, 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
|
||||||
|
});
|
||||||
|
return databasePromise;
|
||||||
|
}
|
||||||
|
return async (work, mode = 'readonly') => {
|
||||||
|
const database = await openDatabase();
|
||||||
|
const transaction = database.transaction(STORE_NAME, mode);
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const completed = new Promise((resolve, reject) => {
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
|
||||||
|
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
|
||||||
|
});
|
||||||
|
const result = await work({
|
||||||
|
get:key => requestResult(store.get(key)),
|
||||||
|
put:value => requestResult(store.put(value)),
|
||||||
|
delete:key => requestResult(store.delete(key)),
|
||||||
|
});
|
||||||
|
await completed;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLogin(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTranscript(value) {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
|
||||||
|
async function load(login) {
|
||||||
|
const key = normalizeLogin(login);
|
||||||
|
if (!key || !transaction) return '';
|
||||||
|
const record = await transaction(store => store.get(key));
|
||||||
|
return normalizeTranscript(record?.transcript);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(login, transcript) {
|
||||||
|
const key = normalizeLogin(login);
|
||||||
|
const value = normalizeTranscript(transcript);
|
||||||
|
if (!key || !value || !transaction) return;
|
||||||
|
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear(login) {
|
||||||
|
const key = normalizeLogin(login);
|
||||||
|
if (!key || !transaction) return;
|
||||||
|
await transaction(store => store.delete(key), 'readwrite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {load, save, clear};
|
||||||
|
}
|
||||||
|
|
||||||
|
createVoiceTranscriptStore.databaseName = dbName;
|
||||||
|
return createVoiceTranscriptStore;
|
||||||
|
});
|
||||||
|
|
@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
FEATURE_SOURCES = {
|
FEATURE_SOURCES = {
|
||||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||||
"issue-capture": (
|
"issue-capture": (
|
||||||
"static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||||
),
|
),
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["deletedAtIdle"] == []
|
assert result["deletedAtIdle"] == []
|
||||||
assert result["deletedDatabases"] == [
|
assert result["deletedDatabases"] == [
|
||||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["remaining"] == ["gitea.preference"]
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
assert result["replaced"] == [
|
assert result["replaced"] == [
|
||||||
|
|
@ -375,7 +375,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["remaining"] == ["gitea.preference"]
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
assert result["deletedDatabases"] == [
|
assert result["deletedDatabases"] == [
|
||||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||||
|
|
@ -506,7 +506,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["remaining"] == ["gitea.preference"]
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
assert result["deletedDatabases"] == [
|
assert result["deletedDatabases"] == [
|
||||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||||
|
|
@ -529,7 +529,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["remaining"] == ["gitea.preference"]
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
assert result["deletedDatabases"] == [
|
assert result["deletedDatabases"] == [
|
||||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||||
|
|
@ -625,7 +625,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["remaining"] == ["gitea.preference"]
|
assert result["remaining"] == ["gitea.preference"]
|
||||||
assert result["deletedDatabases"] == [
|
assert result["deletedDatabases"] == [
|
||||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
assert result["assigned"] == "/dashboard/login"
|
assert result["assigned"] == "/dashboard/login"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ process.stdout.write(JSON.stringify(databases));
|
||||||
"stackchain-background-outbox-v1",
|
"stackchain-background-outbox-v1",
|
||||||
"stackchain-offline-work-v2",
|
"stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1",
|
||||||
|
"stackchain-voice-transcripts-v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||||
"stackchain-background-outbox-v1",
|
"stackchain-background-outbox-v1",
|
||||||
"stackchain-offline-work-v2",
|
"stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1",
|
||||||
|
"stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||||
"stackchain-background-outbox-v1",
|
"stackchain-background-outbox-v1",
|
||||||
"stackchain-offline-work-v2",
|
"stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1",
|
||||||
|
"stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||||
assert result["state"]["clientMessages"] == [
|
assert result["state"]["clientMessages"] == [
|
||||||
|
|
@ -504,6 +505,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||||
"stackchain-background-outbox-v1",
|
"stackchain-background-outbox-v1",
|
||||||
"stackchain-offline-work-v2",
|
"stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1",
|
||||||
|
"stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["replies"] == [{"ok": True}]
|
assert result["replies"] == [{"ok": True}]
|
||||||
|
|
||||||
|
|
@ -973,6 +975,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/issue-filing-review.js",
|
"/dashboard/static/issue-filing-review.js",
|
||||||
"/dashboard/static/issue-sheet.js",
|
"/dashboard/static/issue-sheet.js",
|
||||||
"/dashboard/static/checklist-conflict.js",
|
"/dashboard/static/checklist-conflict.js",
|
||||||
|
"/dashboard/static/voice-transcript-store.js",
|
||||||
"/dashboard/static/voice-issue-capture.js",
|
"/dashboard/static/voice-issue-capture.js",
|
||||||
"/dashboard/static/create-issue-sheet.js",
|
"/dashboard/static/create-issue-sheet.js",
|
||||||
"/dashboard/static/create-and-start.js",
|
"/dashboard/static/create-and-start.js",
|
||||||
|
|
@ -1104,6 +1107,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||||
"stackchain-background-outbox-v1",
|
"stackchain-background-outbox-v1",
|
||||||
"stackchain-offline-work-v2",
|
"stackchain-offline-work-v2",
|
||||||
"stackchain-unfiled-captures-v1",
|
"stackchain-unfiled-captures-v1",
|
||||||
|
"stackchain-voice-transcripts-v1",
|
||||||
]
|
]
|
||||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||||
assert "private cached dashboard" not in result["body"]
|
assert "private cached dashboard" not in result["body"]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from src.frontend_bundle import build_frontend
|
||||||
|
|
||||||
|
|
||||||
VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
|
VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
|
||||||
|
VOICE_STORE = Path(__file__).parents[1] / "frontend" / "voice-transcript-store.js"
|
||||||
FRONTEND = VOICE_CAPTURE.parent
|
FRONTEND = VOICE_CAPTURE.parent
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,6 +80,159 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_transcript_is_checkpointed_for_the_confirmed_account():
|
||||||
|
script = f"""
|
||||||
|
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||||
|
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ this.listeners.click?.({{currentTarget:this}}); }}
|
||||||
|
dispatchEvent() {{}}
|
||||||
|
}}
|
||||||
|
let recognition;
|
||||||
|
class Recognition {{
|
||||||
|
constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}}
|
||||||
|
}}
|
||||||
|
const elements=Object.fromEntries(
|
||||||
|
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||||
|
);
|
||||||
|
elements.review.hidden=true;
|
||||||
|
const saved=[];
|
||||||
|
const transcriptStore={{
|
||||||
|
async load() {{ return ''; }},
|
||||||
|
async save(login, transcript) {{ saved.push({{login, transcript}}); }},
|
||||||
|
async clear() {{}},
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const controller=createVoiceIssueCapture({{
|
||||||
|
Recognition,elements,transcriptStore,getLogin:()=> 'timmy'
|
||||||
|
}});
|
||||||
|
await controller.ready;
|
||||||
|
elements.start.click();
|
||||||
|
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}], {{isFinal:true}})]}});
|
||||||
|
await Promise.resolve();
|
||||||
|
process.stdout.write(JSON.stringify({{saved, transcript:elements.transcript.value}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"saved": [{"login": "timmy", "transcript": "Lift outage."}],
|
||||||
|
"transcript": "Lift outage.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_transcript_recovers_only_for_the_confirmed_account():
|
||||||
|
script = f"""
|
||||||
|
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||||
|
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||||
|
dispatchEvent() {{}}
|
||||||
|
}}
|
||||||
|
class Recognition {{ start() {{}} stop() {{}} abort() {{}} }}
|
||||||
|
const elements=Object.fromEntries(
|
||||||
|
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||||
|
);
|
||||||
|
elements.review.hidden=true;
|
||||||
|
const loads=[];
|
||||||
|
(async () => {{
|
||||||
|
const controller=createVoiceIssueCapture({{
|
||||||
|
Recognition,elements,getLogin:()=> 'timmy',
|
||||||
|
transcriptStore:{{
|
||||||
|
async load(login) {{ loads.push(login); return 'Recovered lift outage.'; }},
|
||||||
|
async save() {{}}, async clear() {{}},
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
await controller.ready;
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
loads, transcript:elements.transcript.value, reviewHidden:elements.review.hidden,
|
||||||
|
status:elements.status.textContent
|
||||||
|
}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"loads": ["timmy"],
|
||||||
|
"transcript": "Recovered lift outage.",
|
||||||
|
"reviewHidden": False,
|
||||||
|
"status": "Recovered transcript. Review it before using or discarding it.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_final_segments_accumulate_in_one_bounded_checkpoint():
|
||||||
|
script = f"""
|
||||||
|
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||||
|
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||||
|
}}
|
||||||
|
let recognition;
|
||||||
|
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
|
||||||
|
const elements=Object.fromEntries(
|
||||||
|
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||||
|
);
|
||||||
|
const saved=[];
|
||||||
|
(async () => {{
|
||||||
|
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||||
|
async load() {{ return ''; }}, async save(login,text) {{ saved.push(text); }}, async clear() {{}},
|
||||||
|
}}}});
|
||||||
|
await controller.ready; elements.start.click();
|
||||||
|
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}],{{isFinal:true}})]}});
|
||||||
|
recognition.onresult({{results:[Object.assign([{{transcript:'Elevator B is trapped.'}}],{{isFinal:true}})]}});
|
||||||
|
await Promise.resolve();
|
||||||
|
process.stdout.write(JSON.stringify({{transcript:elements.transcript.value,saved}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"transcript": "Lift outage. Elevator B is trapped.",
|
||||||
|
"saved": ["Lift outage.", "Lift outage. Elevator B is trapped."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_and_discard_each_clear_the_consumed_checkpoint():
|
||||||
|
script = f"""
|
||||||
|
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
|
||||||
|
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||||
|
}}
|
||||||
|
class Recognition {{ start(){{}} stop(){{}} abort(){{}} }}
|
||||||
|
async function setup() {{
|
||||||
|
const elements=Object.fromEntries(
|
||||||
|
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||||
|
);
|
||||||
|
const cleared=[];
|
||||||
|
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||||
|
async load() {{ return 'Recovered lift outage.'; }}, async save() {{}},
|
||||||
|
async clear(login) {{ cleared.push(login); }},
|
||||||
|
}}}});
|
||||||
|
await controller.ready;
|
||||||
|
return {{elements,cleared}};
|
||||||
|
}}
|
||||||
|
(async () => {{
|
||||||
|
const applied=await setup(); applied.elements.append.click(); await Promise.resolve();
|
||||||
|
const discarded=await setup(); discarded.elements.discard.click(); await Promise.resolve();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
applied:{{cleared:applied.cleared,reviewHidden:applied.elements.review.hidden,title:applied.elements.title.value}},
|
||||||
|
discarded:{{cleared:discarded.cleared,reviewHidden:discarded.elements.review.hidden,transcript:discarded.elements.transcript.value,status:discarded.elements.status.textContent}}
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"applied": {
|
||||||
|
"cleared": ["timmy"],
|
||||||
|
"reviewHidden": True,
|
||||||
|
"title": "Recovered lift outage.",
|
||||||
|
},
|
||||||
|
"discarded": {
|
||||||
|
"cleared": ["timmy"],
|
||||||
|
"reviewHidden": True,
|
||||||
|
"transcript": "",
|
||||||
|
"status": "Recovered transcript discarded.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
|
def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
|
||||||
script = f"""
|
script = f"""
|
||||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||||
|
|
@ -181,6 +335,37 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_store_isolates_bounded_transcripts_by_normalized_account():
|
||||||
|
script = f"""
|
||||||
|
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
|
||||||
|
const records=new Map();
|
||||||
|
const transaction=async work=>work({{
|
||||||
|
get:async key=>records.get(key),
|
||||||
|
put:async value=>records.set(value.login,{{...value}}),
|
||||||
|
delete:async key=>records.delete(key),
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
const store=createVoiceTranscriptStore({{transaction}});
|
||||||
|
await store.save(' Timmy ', ' Lift outage. ' + 'x'.repeat(11000));
|
||||||
|
await store.save('alexander', 'Private second account');
|
||||||
|
const before={{timmy:await store.load('timmy'),alexander:await store.load('alexander')}};
|
||||||
|
await store.clear('TIMMY');
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
before,after:await store.load('timmy'),keys:[...records.keys()],length:before.timmy.length
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"before": {
|
||||||
|
"timmy": "Lift outage. " + "x" * 10242,
|
||||||
|
"alexander": "Private second account",
|
||||||
|
},
|
||||||
|
"after": "",
|
||||||
|
"keys": ["alexander"],
|
||||||
|
"length": 10255,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
|
def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
|
||||||
build = build_frontend(FRONTEND)
|
build = build_frontend(FRONTEND)
|
||||||
html = build.dashboard_html
|
html = build.dashboard_html
|
||||||
|
|
@ -194,7 +379,15 @@ def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_fea
|
||||||
assert 'id="voice-issue-status" class="small" aria-live="polite"' in html
|
assert 'id="voice-issue-status" class="small" aria-live="polite"' in html
|
||||||
assert 'id="append-voice-issue-transcript"' in html
|
assert 'id="append-voice-issue-transcript"' in html
|
||||||
assert 'id="replace-with-voice-issue-transcript"' in html
|
assert 'id="replace-with-voice-issue-transcript"' in html
|
||||||
|
assert 'id="discard-voice-issue-transcript"' in html
|
||||||
assert b"function createVoiceIssueCapture" in capture_bundle
|
assert b"function createVoiceIssueCapture" in capture_bundle
|
||||||
|
assert b"function createVoiceTranscriptStore" in capture_bundle
|
||||||
assert b"function createVoiceIssueCapture" not in build.runtime_bytes
|
assert b"function createVoiceIssueCapture" not in build.runtime_bytes
|
||||||
assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
|
assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
|
||||||
|
assert "transcriptStore:createVoiceTranscriptStore()" in dashboard
|
||||||
|
assert "getLogin:()=>confirmedOwnerLogin" in dashboard
|
||||||
assert "voiceIssueCapture.cancel();" in dashboard
|
assert "voiceIssueCapture.cancel();" in dashboard
|
||||||
|
registry = run_node(
|
||||||
|
f"process.stdout.write(JSON.stringify(require({json.dumps(str(FRONTEND / 'private-data-registry.js'))})))"
|
||||||
|
)
|
||||||
|
assert "stackchain-voice-transcripts-v1" in registry
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user