Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenFlow

Current version: `1.3.044`
Current version: `1.3.045`

OpenFlow is a desktop voice dictation app for Windows and macOS built with Electron on the UI layer and Faster-Whisper for local transcription.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openflow",
"productName": "OpenFlow",
"version": "1.3.044",
"version": "1.3.045",
"description": "Cross-platform desktop dictation app OpenFlow using Electron and Faster-Whisper.",
"main": "src/main/main.js",
"scripts": {
Expand Down
152 changes: 151 additions & 1 deletion src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
Menu,
Tray,
clipboard,
dialog,
globalShortcut,
ipcMain,
nativeImage,
Expand All @@ -28,6 +29,7 @@ const DEFAULT_INTERFACE_LANGUAGE = 'en';
const DEFAULT_SHOW_OVERLAY_BAR = true;
const DEFAULT_SOUND_EFFECTS_ENABLED = true;
const DEFAULT_LAUNCH_AT_LOGIN = false;
const DEFAULT_START_HIDDEN = false;
const DEFAULT_KEEP_ALL_TRANSCRIPTIONS = false;
const DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS = false;
const DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE = false;
Expand Down Expand Up @@ -1080,6 +1082,7 @@ function getDefaultsFromEnv() {
transcribeCancelledRecordings: DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS,
autoEnableHandsFreeMode: DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE,
duckAudioEnabled: DEFAULT_DUCK_AUDIO,
startHidden: DEFAULT_START_HIDDEN,
overlayOpacity: DEFAULT_OVERLAY_OPACITY,
overlayScale: DEFAULT_OVERLAY_SCALE,
overlayDynamicSize: DEFAULT_OVERLAY_DYNAMIC_SIZE,
Expand Down Expand Up @@ -1140,6 +1143,7 @@ const state = {
transcribeCancelledRecordings: defaults.transcribeCancelledRecordings,
autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode,
duckAudioEnabled: defaults.duckAudioEnabled,
startHidden: defaults.startHidden,
overlayOpacity: defaults.overlayOpacity,
overlayScale: defaults.overlayScale,
overlayDynamicSize: defaults.overlayDynamicSize,
Expand Down Expand Up @@ -1384,6 +1388,7 @@ function createEmptyPersistedState() {
transcribeCancelledRecordings: defaults.transcribeCancelledRecordings,
autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode,
duckAudioEnabled: defaults.duckAudioEnabled,
startHidden: defaults.startHidden,
overlayOpacity: defaults.overlayOpacity,
overlayScale: defaults.overlayScale,
overlayDynamicSize: defaults.overlayDynamicSize,
Expand Down Expand Up @@ -1456,6 +1461,7 @@ function normalizePersistedState(payload) {
preferencesSource.duckAudioEnabled,
defaults.duckAudioEnabled,
),
startHidden: normalizeBooleanPreference(preferencesSource.startHidden, defaults.startHidden),
overlayOpacity: normalizeOverlayOpacity(preferencesSource.overlayOpacity),
overlayScale: normalizeOverlayScale(preferencesSource.overlayScale),
overlayDynamicSize: normalizeBooleanPreference(
Expand Down Expand Up @@ -1851,6 +1857,7 @@ function savePersistentState() {
transcribeCancelledRecordings: state.transcribeCancelledRecordings,
autoEnableHandsFreeMode: state.autoEnableHandsFreeMode,
duckAudioEnabled: state.duckAudioEnabled,
startHidden: state.startHidden,
overlayOpacity: state.overlayOpacity,
overlayScale: state.overlayScale,
overlayDynamicSize: state.overlayDynamicSize,
Expand Down Expand Up @@ -4377,6 +4384,8 @@ async function applySettings(patch) {
: state.pasteLastShortcut;
const nextDuckAudioEnabled =
typeof patch.duckAudioEnabled === 'boolean' ? patch.duckAudioEnabled : state.duckAudioEnabled;
const nextStartHidden =
typeof patch.startHidden === 'boolean' ? patch.startHidden : state.startHidden;
const nextOverlayOpacity = Object.prototype.hasOwnProperty.call(patch, 'overlayOpacity')
? normalizeOverlayOpacity(patch.overlayOpacity)
: state.overlayOpacity;
Expand Down Expand Up @@ -4532,6 +4541,7 @@ async function applySettings(patch) {
transcribeCancelledRecordings: nextTranscribeCancelledRecordings,
autoEnableHandsFreeMode: nextAutoEnableHandsFreeMode,
duckAudioEnabled: nextDuckAudioEnabled,
startHidden: nextStartHidden,
overlayOpacity: nextOverlayOpacity,
overlayScale: nextOverlayScale,
overlayDynamicSize: nextOverlayDynamicSize,
Expand Down Expand Up @@ -5152,8 +5162,145 @@ function scheduleStartupUpdateCheck() {
}, 8000);
}

function deleteHistoryEntry(entryKey) {
const key = entryKey && typeof entryKey === 'object' ? entryKey : {};
const timestamp = String(key.timestamp || '');
const text = String(key.text || '');
const index = state.history.findIndex(
(entry) => entry.timestamp === timestamp && entry.text === text,
);
if (index === -1) {
return snapshotState();
}

const nextHistory = [...state.history];
nextHistory.splice(index, 1);
setState({ history: nextHistory });
savePersistentState();
return snapshotState();
}

function formatHistoryExportText(entries) {
return entries
.map((entry) => {
const timestamp = new Date(entry.timestamp);
const stamp = Number.isNaN(timestamp.getTime())
? String(entry.timestamp || '')
: timestamp.toLocaleString();
return `[${stamp}] ${entry.text}`;
})
.join('\n\n');
}

async function exportHistoryToFile() {
const entries = Array.isArray(state.history) ? state.history : [];
if (entries.length === 0) {
return { status: 'empty' };
}

const dateSuffix = new Date().toISOString().slice(0, 10);
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: `openflow-history-${dateSuffix}.txt`,
filters: [
{ name: 'Text', extensions: ['txt'] },
{ name: 'JSON', extensions: ['json'] },
],
});
if (result.canceled || !result.filePath) {
return { status: 'canceled' };
}

const isJson = path.extname(result.filePath).toLowerCase() === '.json';
const contents = isJson
? `${JSON.stringify(entries, null, 2)}\n`
: `${formatHistoryExportText(entries)}\n`;
try {
fs.writeFileSync(result.filePath, contents, 'utf8');
} catch (error) {
return { status: 'error', message: error?.message || String(error) };
}

return { status: 'saved', filePath: result.filePath, count: entries.length };
}

async function exportDictionaryToFile() {
const entries = Array.isArray(state.dictionaryEntries) ? state.dictionaryEntries : [];
if (entries.length === 0) {
return { status: 'empty' };
}

const dateSuffix = new Date().toISOString().slice(0, 10);
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: `openflow-dictionary-${dateSuffix}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (result.canceled || !result.filePath) {
return { status: 'canceled' };
}

try {
fs.writeFileSync(
result.filePath,
`${JSON.stringify({ dictionaryEntries: entries }, null, 2)}\n`,
'utf8',
);
} catch (error) {
return { status: 'error', message: error?.message || String(error) };
}

return { status: 'saved', filePath: result.filePath, count: entries.length };
}

async function importDictionaryFromFile() {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { status: 'canceled' };
}

let payload = null;
try {
payload = JSON.parse(fs.readFileSync(result.filePaths[0], 'utf8'));
} catch (error) {
return { status: 'invalid', message: error?.message || String(error) };
}

const rawEntries = Array.isArray(payload) ? payload : payload?.dictionaryEntries;
if (!Array.isArray(rawEntries)) {
return { status: 'invalid' };
}

// Drop imported ids so merged rules always get fresh ids and cannot collide
// with existing ones. normalizeDictionaryEntries dedupes identical rules.
const imported = rawEntries.map((entry) =>
entry && typeof entry === 'object' ? { ...entry, id: undefined } : entry,
);
const before = state.dictionaryEntries.length;
const merged = normalizeDictionaryEntries([...state.dictionaryEntries, ...imported]);
const added = merged.length - before;
if (added <= 0) {
return { status: 'none' };
}

await applySettings({ dictionaryEntries: merged });
return { status: 'imported', count: added };
}

function resetOverlayPosition() {
state.overlayPosition = null;
positionOverlayWindow(null, true);
return snapshotState();
}

ipcMain.handle('get-state', async () => snapshotState());
ipcMain.handle('update-settings', async (_event, patch) => applySettings(patch || {}));
ipcMain.handle('delete-history-entry', async (_event, entryKey) => deleteHistoryEntry(entryKey));
ipcMain.handle('export-history', async () => exportHistoryToFile());
ipcMain.handle('export-dictionary', async () => exportDictionaryToFile());
ipcMain.handle('import-dictionary', async () => importDictionaryFromFile());
ipcMain.handle('reset-overlay-position', async () => resetOverlayPosition());
ipcMain.handle('reset-model-stats', async () => resetModelStats());
ipcMain.handle('save-openrouter-api-key', async (_event, apiKey) => saveOpenRouterApiKey(apiKey));
ipcMain.handle('clear-openrouter-api-key', async () => clearOpenRouterApiKey());
Expand Down Expand Up @@ -5223,6 +5370,7 @@ app.whenReady().then(() => {
transcribeCancelledRecordings: persistedState.preferences.transcribeCancelledRecordings,
autoEnableHandsFreeMode: persistedState.preferences.autoEnableHandsFreeMode,
duckAudioEnabled: persistedState.preferences.duckAudioEnabled,
startHidden: persistedState.preferences.startHidden,
overlayOpacity: persistedState.preferences.overlayOpacity,
overlayScale: persistedState.preferences.overlayScale,
overlayDynamicSize: persistedState.preferences.overlayDynamicSize,
Expand All @@ -5239,7 +5387,9 @@ app.whenReady().then(() => {
savePersistentState();
}
shouldStartHiddenOnLaunch =
shouldStartHiddenOnLaunch || Boolean(app.getLoginItemSettings().wasOpenedAsHidden);
shouldStartHiddenOnLaunch ||
Boolean(app.getLoginItemSettings().wasOpenedAsHidden) ||
Boolean(persistedState.preferences.startHidden);
rebuildDictionaryReplacementIndex(persistedState.preferences.dictionaryEntries);
syncLaunchAtLoginSetting();
registerPasteLastShortcut();
Expand Down
5 changes: 5 additions & 0 deletions src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('flowLocal', {
previewOverlayStyle: (patch) => ipcRenderer.send('preview-overlay-style', patch),
resetModelStats: () => ipcRenderer.invoke('reset-model-stats'),
copyText: (text) => ipcRenderer.invoke('copy-text', text),
deleteHistoryEntry: (entryKey) => ipcRenderer.invoke('delete-history-entry', entryKey),
exportHistory: () => ipcRenderer.invoke('export-history'),
exportDictionary: () => ipcRenderer.invoke('export-dictionary'),
importDictionary: () => ipcRenderer.invoke('import-dictionary'),
resetOverlayPosition: () => ipcRenderer.invoke('reset-overlay-position'),
onStateUpdate: (callback) => {
const listener = (_event, state) => callback(state);
ipcRenderer.on('app-state', listener);
Expand Down
40 changes: 39 additions & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ <h2 data-i18n="latestTranscriptions">Latest transcriptions</h2>
</div>

<div class="history-toolbar">
<label class="search-field" for="history-search">
<label class="search-field search-field--clearable" for="history-search">
<span data-i18n="search">Search</span>
<input
id="history-search"
Expand All @@ -93,7 +93,16 @@ <h2 data-i18n="latestTranscriptions">Latest transcriptions</h2>
data-i18n-placeholder="historySearchPlaceholder"
placeholder="Search words in transcriptions..."
/>
<button
class="search-clear hidden"
id="history-search-clear"
type="button"
aria-label="Clear search"
>&times;</button>
</label>
<button class="secondary-button" id="export-history" type="button" data-i18n="exportHistory">
Export
</button>
</div>

<div class="history-list" id="history-list">
Expand Down Expand Up @@ -220,6 +229,18 @@ <h2 data-i18n="floatingBar">Floating bar</h2>
<span></span>
</span>
</label>
<label class="toggle-card" for="start-hidden">
<div>
<strong data-i18n="startHidden">Start minimized to tray</strong>
<p data-i18n="startHiddenCopy">
Keep the main window hidden when OpenFlow starts. Use the tray icon to open it.
</p>
</div>
<span class="toggle-switch">
<input id="start-hidden" type="checkbox" />
<span></span>
</span>
</label>
<label class="toggle-card" for="sound-effects-enabled">
<div>
<strong data-i18n="soundEffects">Sound feedback</strong>
Expand Down Expand Up @@ -431,6 +452,14 @@ <h2 data-i18n="floatingBarCustomization">Floating bar appearance</h2>
<span></span>
</span>
</label>
<div class="reset-position-row">
<p data-i18n="resetOverlayPositionCopy">
Moved the pill somewhere awkward? Bring it back to the default spot.
</p>
<button class="secondary-button" id="reset-overlay-position" type="button" data-i18n="resetOverlayPosition">
Reset position
</button>
</div>
</div>

<div class="panel--settings">
Expand Down Expand Up @@ -685,6 +714,15 @@ <h2 data-i18n="activeRules">Your rules</h2>
autocomplete="off"
/>
</div>

<div class="dictionary-toolbar__actions">
<button class="secondary-button" id="import-dictionary" type="button" data-i18n="importDictionary">
Import
</button>
<button class="secondary-button" id="export-dictionary" type="button" data-i18n="exportDictionary">
Export
</button>
</div>
</div>

<div class="dictionary-list" id="dictionary-list"></div>
Expand Down
Loading
Loading