Files
bwf-analyser/build/tauri-bridge.js
T
Vincent 2d0b7fe8b5 BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a
single self-contained page and a native Tauri app with a Rust audio engine and
WAV writer. Around 370 checks across seven test suites.

First commit of the existing state, so that from here every change can be
seen and undone.
2026-08-17 22:50:39 +08:00

2670 lines
88 KiB
JavaScript

/**
* BWF Analyser — native bridge for the macOS app.
*
* The analyser itself is untouched browser code: it reads File objects, slices
* them, and (when editing) drives FileSystemFileHandle. WKWebView gives it
* neither a usable folder picker nor any way to write back to disk. So instead
* of forking the app, this file supplies those objects, backed by the Rust
* commands in src-tauri/src/main.rs.
*
* Four things happen here:
*
* 1. NativeFile — a File look-alike. Only what the analyser actually touches
* is implemented: name, size, lastModified, webkitRelativePath, slice()
* and arrayBuffer(). Slices are lazy, so reading metadata off a 4 GB take
* reads a few KB of chunk headers rather than the whole file.
*
* 2. The Select Folder / Select Files buttons are intercepted during the
* capture phase, before the app's own handlers see the click, and answered
* with the native macOS dialog. The resulting files are handed back
* through the file input the app already listens to, so its pipeline is
* none the wiser.
*
* 3. showDirectoryPicker + FileSystemFileHandle are defined on window, which
* is what the app feature-detects before revealing "Edit Metadata in a
* Folder…". Writes are chunked and go through one Rust command.
*
* 4. Tauri swallows DOM drag-and-drop and re-emits it as its own event with
* real paths, so that gets forwarded into the same pipeline.
*/
( function () {
"use strict";
var TAURI = window.__TAURI__;
if ( ! TAURI && ! window.__TAURI_INTERNALS__ ) {
return; // Running in a plain browser: leave the web behaviour alone.
}
/**
* Resolved per call rather than captured once. The global API script is
* injected by Tauri and this file makes no assumption about whether that
* has happened yet; __TAURI_INTERNALS__ is the lower-level fallback and is
* present from the very first script.
*/
function invoke( command, payload, options ) {
var api = window.__TAURI__;
var fn = ( api && api.core && api.core.invoke ) ||
( window.__TAURI_INTERNALS__ && window.__TAURI_INTERNALS__.invoke );
if ( typeof fn !== "function" ) {
return Promise.reject( new Error( "Tauri IPC is unavailable" ) );
}
return fn( command, payload, options );
}
/*
* Transfer sizes. Overridable so the test harness can shrink them and
* exercise the multi-chunk paths against small files.
*
* WRITE_CHUNK: big enough to keep IPC round trips down on a full file
* rebuild, small enough that no single call blocks the main thread long enough
* to be felt.
*
* Reads above LARGE_READ_THRESHOLD are fetched in READ_CHUNK pieces rather
* than in one call, so a multi-gigabyte take never exists in triplicate.
* The threshold sits far above any chunk header, so metadata parsing never
* takes that path.
*/
var LIMITS = window.BWFA_BRIDGE_LIMITS || {};
var WRITE_CHUNK = LIMITS.writeChunk || 8 * 1024 * 1024;
var LARGE_READ_THRESHOLD = LIMITS.largeRead || 64 * 1024 * 1024;
var READ_CHUNK = LIMITS.readChunk || 16 * 1024 * 1024;
var RECORDING_EXTENSIONS = [ "wav", "bwf", "broadcastwave" ];
function hexEncode( text ) {
var bytes = new TextEncoder().encode( text );
var out = "";
for ( var i = 0; i < bytes.length; i++ ) {
out += ( bytes[ i ] < 16 ? "0" : "" ) + bytes[ i ].toString( 16 );
}
return out;
}
/* ------------------------------------------------------------------ */
/* A File, as far as the analyser is concerned */
/* ------------------------------------------------------------------ */
/**
* @param {object} entry Result of bwf_scan / bwf_stat.
* @param {number} [sliceStart] Byte offset this view starts at.
* @param {number} [sliceEnd] Byte offset this view ends at (exclusive).
*/
function NativeFile( entry, sliceStart, sliceEnd ) {
this.path = entry.path;
this.name = entry.name;
this.webkitRelativePath = entry.relativePath || entry.name;
this.lastModified = entry.lastModified || Date.now();
this.type = "audio/wav";
this._entry = entry;
this._start = sliceStart || 0;
this._end = sliceEnd === undefined ? ( entry.size || 0 ) : sliceEnd;
this.size = Math.max( 0, this._end - this._start );
}
NativeFile.prototype.slice = function ( start, end ) {
var from = this._start + Math.max( 0, start || 0 );
var to = end === undefined ? this._end : this._start + end;
// Clamp to this view, the way Blob.slice() does.
from = Math.min( from, this._end );
to = Math.min( Math.max( to, from ), this._end );
return new NativeFile( this._entry, from, to );
};
NativeFile.prototype.arrayBuffer = function () {
var self = this;
// Past a certain size, one big read would exist three times over at
// peak — in Rust, in the IPC response, and in JS. Reading in ranges
// straight into the destination buffer keeps it to roughly one.
if ( self.size > LARGE_READ_THRESHOLD ) {
return readInRanges( self );
}
// A whole-file read has its own command: one path through Rust rather
// than a range read that happens to cover everything.
if ( self._start === 0 && self._end >= ( self._entry.size || 0 ) ) {
return invoke( "bwf_read_all", { path: self.path } ).then( toArrayBuffer );
}
return invoke( "bwf_read_range", {
path: self.path,
offset: self._start,
length: self.size
} ).then( toArrayBuffer );
};
function readInRanges( file ) {
var out = new Uint8Array( file.size );
var written = 0;
function step() {
if ( written >= file.size ) {
return out.buffer;
}
var length = Math.min( READ_CHUNK, file.size - written );
var at = written;
return invoke( "bwf_read_range", {
path: file.path,
offset: file._start + at,
length: length
} ).then( toArrayBuffer ).then( function ( buffer ) {
var chunk = new Uint8Array( buffer );
if ( ! chunk.length ) {
// Short read means end of file; stop rather than spin.
written = file.size;
return out.buffer;
}
out.set( chunk, at );
written += chunk.length;
return step();
} );
}
return Promise.resolve().then( step );
}
NativeFile.prototype.text = function () {
return this.arrayBuffer().then( function ( buffer ) {
return new TextDecoder().decode( new Uint8Array( buffer ) );
} );
};
/** Raw command responses arrive as ArrayBuffer, but be tolerant of a
* JSON-array fallback so a future Tauri change can't silently corrupt
* reads. */
function toArrayBuffer( response ) {
if ( response instanceof ArrayBuffer ) {
return response;
}
if ( ArrayBuffer.isView( response ) ) {
return response.buffer.slice( response.byteOffset, response.byteOffset + response.byteLength );
}
if ( Array.isArray( response ) ) {
return new Uint8Array( response ).buffer;
}
return new ArrayBuffer( 0 );
}
function filesFromEntries( entries ) {
return entries.map( function ( entry ) { return new NativeFile( entry ); } );
}
/* ------------------------------------------------------------------ */
/* Handing files to the app through its own file inputs */
/* ------------------------------------------------------------------ */
/** The app reads `input.files` on change and treats it as a FileList, so
* an array with a matching shape is enough. */
function deliver( input, files ) {
if ( ! input || ! files.length ) {
return;
}
var list = files.slice();
list.item = function ( index ) { return list[ index ] || null; };
try {
Object.defineProperty( input, "files", { value: list, configurable: true, writable: true } );
} catch ( e ) {
input.files = list;
}
input.dispatchEvent( new Event( "change", { bubbles: true } ) );
}
function nativeOpen( options ) {
return invoke( "plugin:dialog|open", { options: options } );
}
function scan( paths ) {
return invoke( "bwf_scan", { paths: paths } );
}
/**
* The app's root, or nothing.
*
* Nothing is a real answer: the combine planner and the probe passes are
* round trips to Rust, and a reply can arrive after the window holding the
* page has gone. Every caller already copes with a missing panel, so the
* one thing that mustn't happen is this throwing on the way there.
*/
function currentApp() {
if ( typeof document === "undefined" || ! document ) {
return null;
}
return document.querySelector( "[data-bwfa-app]" );
}
/**
* Where to hand files back to the app. Normally the folder input, but the
* app removes that one (and its change listener with it) on any engine
* without webkitdirectory support, so fall back to the plain file input,
* which is always wired up.
*/
function inputFor( preferred ) {
var app = currentApp();
if ( ! app ) {
return null;
}
return app.querySelector( preferred ) || app.querySelector( "[data-bwfa-files-input]" );
}
function setStatus( text, isError ) {
var app = currentApp();
var statusEl = app && app.querySelector( "[data-bwfa-status]" );
if ( statusEl ) {
statusEl.textContent = text || "";
statusEl.classList.toggle( "has-error", !! isError );
}
}
function reportEmpty() {
setStatus( ( window.bwfaL10n && window.bwfaL10n.noFilesFound ) ||
"No BWF/WAV files were found in that selection.", true );
}
function pickFolder() {
var input = inputFor( "[data-bwfa-file-input]" );
return nativeOpen( { directory: true, multiple: false, title: "Choose a folder of recordings" } )
.then( function ( selected ) {
if ( ! selected ) {
return null;
}
setStatus( ( window.bwfaL10n && window.bwfaL10n.statusScanning ) || "Scanning folder…" );
return scan( [].concat( selected ) );
} )
.then( function ( entries ) {
if ( ! entries ) {
return;
}
if ( ! entries.length ) {
return reportEmpty();
}
deliver( input, filesFromEntries( entries ) );
} )
.catch( function ( err ) { setStatus( String( err ), true ); } );
}
function pickFiles() {
var input = inputFor( "[data-bwfa-files-input]" );
return nativeOpen( {
directory: false,
multiple: true,
title: "Choose recordings",
filters: [ { name: "Broadcast Wave", extensions: RECORDING_EXTENSIONS } ]
} )
.then( function ( selected ) {
if ( ! selected ) {
return null;
}
return scan( [].concat( selected ) );
} )
.then( function ( entries ) {
if ( ! entries ) {
return;
}
if ( ! entries.length ) {
return reportEmpty();
}
deliver( input, filesFromEntries( entries ) );
} )
.catch( function ( err ) { setStatus( String( err ), true ); } );
}
// Capture phase, so this runs before the app's own click handler on the
// button and can stop it from opening the (unusable) webview file dialog.
document.addEventListener( "click", function ( e ) {
var target = e.target;
if ( ! target || ! target.closest ) {
return;
}
if ( target.closest( "[data-bwfa-select-folder]" ) ) {
e.preventDefault();
e.stopPropagation();
pickFolder();
} else if ( target.closest( "[data-bwfa-select-files]" ) ) {
e.preventDefault();
e.stopPropagation();
pickFiles();
}
}, true );
/* ------------------------------------------------------------------ */
/* File System Access API, backed by Rust */
/* ------------------------------------------------------------------ */
/** Collects the app's writes and flushes them through bwf_write. */
function NativeWritable( path, keepExistingData, create ) {
this._path = path;
this._keep = !! keepExistingData;
this._create = !! create;
}
NativeWritable.prototype._send = function ( bytes, position, truncate ) {
return invoke( "bwf_write", bytes, {
headers: {
"x-bwf-path": hexEncode( this._path ),
"x-bwf-position": String( position ),
"x-bwf-truncate": truncate ? "1" : "0",
"x-bwf-create": this._create ? "1" : "0"
}
} );
};
NativeWritable.prototype._sendChunked = function ( bytes, position, truncateAtEnd ) {
var self = this;
var offset = 0;
function step() {
if ( offset >= bytes.length ) {
return Promise.resolve();
}
var slice = bytes.subarray( offset, Math.min( offset + WRITE_CHUNK, bytes.length ) );
var isLast = offset + slice.length >= bytes.length;
// Copy: subarray shares its buffer, and the IPC layer wants a
// standalone view it can hand off as a request body.
var chunk = new Uint8Array( slice.length );
chunk.set( slice );
var at = position + offset;
offset += slice.length;
return self._send( chunk, at, truncateAtEnd && isLast ).then( step );
}
return step();
};
function asBytes( data ) {
if ( data instanceof ArrayBuffer ) {
return new Uint8Array( data );
}
if ( ArrayBuffer.isView( data ) ) {
return new Uint8Array( data.buffer, data.byteOffset, data.byteLength );
}
if ( Array.isArray( data ) ) {
return new Uint8Array( data );
}
return new Uint8Array( 0 );
}
/**
* Mirrors FileSystemWritableFileStream.write(): either a bare buffer
* (replace the file) or a { type: "write", position, data } command
* (patch in place).
*/
NativeWritable.prototype.write = function ( data ) {
if ( data && typeof data === "object" && data.type === "write" ) {
return this._sendChunked( asBytes( data.data ), data.position || 0, false );
}
// No keepExistingData means the app is replacing the file wholesale,
// so the last chunk truncates whatever used to be past it.
return this._sendChunked( asBytes( data ), 0, ! this._keep );
};
NativeWritable.prototype.truncate = function () {
return Promise.resolve();
};
NativeWritable.prototype.close = function () {
return Promise.resolve();
};
function NativeFileHandle( entry ) {
this.kind = "file";
this.name = entry.name;
this.path = entry.path;
this._entry = entry;
}
NativeFileHandle.prototype.getFile = function () {
var self = this;
// Re-stat rather than trusting the scan: the editor saves against the
// file as it is now, and its size may have changed since.
return invoke( "bwf_stat", { path: self.path } ).then( function ( fresh ) {
fresh.relativePath = self._entry.relativePath || fresh.name;
self._entry = fresh;
return new NativeFile( fresh );
} );
};
NativeFileHandle.prototype.createWritable = function ( options ) {
return Promise.resolve( new NativeWritable( this.path, options && options.keepExistingData ) );
};
function NativeDirectoryHandle( path, name ) {
this.kind = "directory";
this.name = name;
this.path = path;
}
NativeDirectoryHandle.prototype.entries = function () {
var self = this;
var pending = null;
var index = 0;
function load() {
if ( ! pending ) {
pending = invoke( "bwf_list_dir", { path: self.path } );
}
return pending;
}
var iterator = {
next: function () {
return load().then( function ( items ) {
if ( index >= items.length ) {
return { done: true, value: undefined };
}
var item = items[ index++ ];
var handle = item.kind === "directory"
? new NativeDirectoryHandle( item.path, item.name )
: new NativeFileHandle( { path: item.path, name: item.name, relativePath: item.name, size: 0 } );
return { done: false, value: [ item.name, handle ] };
} );
}
};
// Usable both as an async iterator and via bare .next(), which is how
// the app walks it.
iterator[ Symbol.asyncIterator ] = function () { return iterator; };
return iterator;
};
NativeDirectoryHandle.prototype.values = function () {
var inner = this.entries();
var wrapper = {
next: function () {
return inner.next().then( function ( res ) {
return res.done ? res : { done: false, value: res.value[ 1 ] };
} );
}
};
wrapper[ Symbol.asyncIterator ] = function () { return wrapper; };
return wrapper;
};
// The app feature-detects all three of these before offering to edit.
window.FileSystemFileHandle = NativeFileHandle;
window.FileSystemDirectoryHandle = NativeDirectoryHandle;
/**
* Set by a drag-and-drop to skip the panel and open that folder instead.
* Consumed once, so a later click still gets a real dialog.
*/
var pendingFolder = null;
function handleFor( path ) {
var name = String( path ).split( "/" ).filter( Boolean ).pop() || path;
announceFolder( name );
rememberFolder( path );
return new NativeDirectoryHandle( path, name );
}
/* ------------------------------------------------------------------ */
/* Reopening where you left off */
/* ------------------------------------------------------------------ */
var LAST_FOLDER_KEY = "bwfa_last_folder";
/** The folder currently open, in full. Exports default to saving here:
* a sound report belongs next to the day it describes, and a bare
* filename would leave the panel wherever it happened to be last. */
var currentFolderPath = null;
function rememberFolder( path ) {
currentFolderPath = String( path );
try {
window.localStorage.setItem( LAST_FOLDER_KEY, String( path ) );
} catch ( e ) {
// Storage disabled: the folder just won't outlive the session.
}
}
function forgetFolder() {
try {
window.localStorage.removeItem( LAST_FOLDER_KEY );
} catch ( e ) {}
}
function lastFolder() {
try {
return window.localStorage.getItem( LAST_FOLDER_KEY ) || null;
} catch ( e ) {
return null;
}
}
/**
* Picking up where you left off, without the picker.
*
* The folder is checked before anything is shown: a card that has been
* ejected, a drive that isn't mounted, a folder that was renamed — all of
* them just leave the launch screen up, and the stale path is dropped so
* it can't fail twice.
*
* Reopening then goes through the app's own button, the same as a drop, so
* there's one code path into edit mode rather than three.
*/
function restoreLastFolder() {
var path = lastFolder();
if ( ! path ) {
return;
}
var app = currentApp();
var button = app && app.querySelector( "[data-bwfa-edit-folder]" );
if ( ! button ) {
return;
}
invoke( "bwf_list_dir", { path: path } ).then( function () {
pendingFolder = path;
button.click();
} ).catch( function () {
forgetFolder();
} );
}
// On load rather than DOMContentLoaded: this file runs before the
// analyser's own script, so its DOMContentLoaded handler would fire first,
// and clicking the button before the app has wired it up does nothing.
if ( document.readyState === "complete" ) {
restoreLastFolder();
} else {
window.addEventListener( "load", restoreLastFolder );
}
/**
* Once a folder is open, the invitation to open one has served its
* purpose: the panel collapses to a single line naming the folder, with
* the button to change it. The name is also what the exports get named
* after, which is why it's parked on window rather than kept local.
*/
function announceFolder( name ) {
window.BWFA_FOLDER_NAME = name;
var app = currentApp();
if ( ! app ) {
return;
}
var entry = app.querySelector( "[data-bwfa-edit-entry]" );
var label = app.querySelector( "[data-bwfa-current-folder]" );
if ( label ) {
label.textContent = name;
label.hidden = false;
}
if ( entry ) {
entry.classList.add( "is-folder-open" );
}
// Until this point the window is a launch screen: one panel, centred,
// with the status line and the empty-state placeholder hidden because
// there is nothing yet to report.
app.classList.add( "has-folder" );
var button = app.querySelector( "[data-bwfa-edit-folder]" );
if ( button ) {
button.textContent = "Change Folder";
}
}
function abortError( message ) {
var error = new Error( message );
error.name = "AbortError"; // The app treats this as "user changed their mind".
return error;
}
/**
* An empty folder is a dead end for the analyser: it reports "no files
* found" and returns without touching anything, which leaves the previous
* folder's rows, its player and its playback on screen under a warning
* about a folder they have nothing to do with.
*
* Checking here instead means the app never gets a handle it can't use, so
* the table is cleared through the app's own Clear button — rows, caches,
* playback and all — and the warning is written afterwards, since clearing
* resets the status too.
*/
/**
* Drops everything loaded, through the app's own Clear button so its state,
* caches and playback all go with it. Clear hides the results but leaves
* the rendered rows in the DOM — invisible, yet still there for anything
* that goes looking — so the table body is emptied too.
*/
function clearLoadedFiles() {
var app = currentApp();
var clear = app && app.querySelector( "[data-bwfa-clear]" );
if ( clear && ! clear.disabled ) {
clear.click();
}
var body = app && app.querySelector( "[data-bwfa-table-body]" );
if ( body ) {
body.textContent = "";
}
}
function openedNothing( path ) {
clearLoadedFiles();
// The folder is still the one you chose, so it's still what's named.
announceFolder( String( path ).split( "/" ).filter( Boolean ).pop() || path );
rememberFolder( path );
reportEmpty();
}
/**
* Back to how the app looks on a first run: nothing loaded, nothing
* remembered, the launch panel centred with its logo. Worth having as an
* explicit action rather than something you get by quitting, since the
* app otherwise always reopens the last folder.
*/
function resetToLaunchState() {
clearLoadedFiles();
closeExportPanel();
forgetFolder();
currentFolderPath = null;
window.BWFA_FOLDER_NAME = null;
var app = currentApp();
if ( ! app ) {
return;
}
app.classList.remove( "has-folder" );
var entry = app.querySelector( "[data-bwfa-edit-entry]" );
if ( entry ) {
entry.classList.remove( "is-folder-open" );
}
var label = app.querySelector( "[data-bwfa-current-folder]" );
if ( label ) {
label.textContent = "";
label.hidden = true;
}
var button = app.querySelector( "[data-bwfa-edit-folder]" );
if ( button ) {
button.textContent = openFolderLabel;
}
}
// Captured before anything renames it to "Change Folder".
var openFolderLabel = "Open Folder";
document.addEventListener( "click", function ( e ) {
if ( e.target && e.target.closest && e.target.closest( "[data-bwfa-reset]" ) ) {
e.preventDefault();
e.stopPropagation();
resetToLaunchState();
}
}, true );
/* ------------------------------------------------------------------ */
/* Bulk edit and export as modals */
/* ------------------------------------------------------------------ */
/**
* Both panels are wrapped in the app's own modal furniture at build time.
* Neither side needs to know: the panel keeps its `hidden` attribute (the
* app toggles bulk edit's, the export code toggles its own) and the shell
* follows it through an observer.
*
* The alternative was `:has()` in CSS, which works but can't be asserted
* anywhere without a layout engine, and this is the sort of thing that
* should be tested rather than hoped about.
*/
var SHEETS = [
{ name: "bulk", panel: "[data-bwfa-bulk-edit-panel]", cancel: "[data-bwfa-bulk-edit-cancel]" },
{ name: "export", panel: "[data-bwfa-export-panel]", cancel: null },
{ name: "result", panel: "[data-bwfa-result-panel]", cancel: null }
];
function sheetPart( name, attribute ) {
var app = currentApp();
return app && app.querySelector( "[" + attribute + "=\"" + name + "\"]" );
}
function syncSheet( spec ) {
var app = currentApp();
var panel = app && app.querySelector( spec.panel );
var sheet = sheetPart( spec.name, "data-bwfa-sheet" );
var backdrop = sheetPart( spec.name, "data-bwfa-sheet-backdrop" );
if ( ! panel || ! sheet ) {
return;
}
var open = ! panel.hidden;
[ sheet, backdrop ].forEach( function ( part ) {
if ( ! part ) {
return;
}
part.hidden = ! open;
// The framework's modals need both: `hidden` for the author rule
// that beats the UA one, and `.open` for its own display: flex.
if ( open ) {
part.classList.add( "open" );
} else {
part.classList.remove( "open" );
}
} );
}
/** The observer is a frame behind; anything we toggle ourselves says so now. */
function refreshSheets() {
SHEETS.forEach( syncSheet );
}
function closeSheet( name ) {
var spec = SHEETS.filter( function ( item ) { return item.name === name; } )[ 0 ];
if ( ! spec ) {
return;
}
if ( spec.cancel ) {
// Go through the app's own Cancel so its state resets with it.
var cancel = currentApp() && currentApp().querySelector( spec.cancel );
if ( cancel ) {
cancel.click();
refreshSheets();
return;
}
}
if ( name === "export" ) {
closeExportPanel();
}
if ( name === "result" ) {
closeResultPanel();
}
}
function watchSheets() {
var app = currentApp();
if ( ! app || typeof MutationObserver !== "function" ) {
return;
}
SHEETS.forEach( function ( spec ) {
var panel = app.querySelector( spec.panel );
if ( ! panel ) {
return;
}
new MutationObserver( function () {
syncSheet( spec );
} ).observe( panel, { attributes: true, attributeFilter: [ "hidden" ] } );
syncSheet( spec );
} );
}
if ( document.readyState === "complete" ) {
watchSheets();
} else {
window.addEventListener( "load", watchSheets );
}
document.addEventListener( "click", function ( e ) {
if ( ! e.target || ! e.target.closest ) {
return;
}
var closer = e.target.closest( "[data-bwfa-sheet-close]" );
if ( closer ) {
e.preventDefault();
e.stopPropagation();
closeSheet( closer.getAttribute( "data-bwfa-sheet-close" ) );
return;
}
// Clicking the dimmed page behind a modal dismisses it, which is what
// clicking outside a window means everywhere else on this machine.
var backdrop = e.target.closest( "[data-bwfa-sheet-backdrop]" );
if ( backdrop ) {
closeSheet( backdrop.getAttribute( "data-bwfa-sheet-backdrop" ) );
return;
}
var sheet = e.target.closest( "[data-bwfa-sheet]" );
if ( sheet && ! e.target.closest( ".bwfa-sheet-dialog" ) ) {
closeSheet( sheet.getAttribute( "data-bwfa-sheet" ) );
}
}, true );
document.addEventListener( "keydown", function ( e ) {
if ( e.key !== "Escape" ) {
return;
}
var open = SHEETS.filter( function ( spec ) {
var sheet = sheetPart( spec.name, "data-bwfa-sheet" );
return sheet && ! sheet.hidden;
} );
if ( ! open.length ) {
return;
}
e.stopPropagation();
closeSheet( open[ open.length - 1 ].name );
}, true );
/* ------------------------------------------------------------------ */
/* Exporting copies to another folder */
/* ------------------------------------------------------------------ */
/**
* The export panel: copies of the recordings, written somewhere else, with
* 32-bit float converted to 24-bit and optional normalising on the way.
*
* All of the audio work is in Rust (src-tauri/src/convert.rs) because these
* are tens of gigabytes and none of it should cross the IPC boundary. What
* lives here is the part that needs to see the whole selection at once:
* deciding each file's target word length, measuring peaks, and working out
* the gain — which for "one gain for every file" is a single number derived
* from the loudest file in the batch, so the level relationships between
* takes survive the trip.
*/
var exportRun = {
rows: [],
dest: null,
running: false,
cancelled: false
};
/** The last finished run, kept so its log can be saved after the fact. */
var lastExportRun = null;
function exportPanel() {
var app = currentApp();
return app && app.querySelector( "[data-bwfa-export-panel]" );
}
function inPanel( selector ) {
var panel = exportPanel();
return panel && panel.querySelector( selector );
}
function closeExportPanel() {
var panel = exportPanel();
if ( panel && ! exportRun.running ) {
panel.hidden = true;
refreshSheets();
}
}
/**
* Everything the export panel remembers, dropped.
*
* The panel captures the files to export when it opens, which is right —
* you should be able to change the filter or play something without the
* pending export shifting under you. But it made switching folders a trap:
* the captured list outlived the folder it came from, so exporting after a
* folder change wrote out the previous card. The destination goes too; a
* different card is a different job.
*/
function forgetExportScope() {
exportRun.rows = [];
exportRun.dest = null;
exportRun.cancelled = true;
var field = inPanel( "[data-bwfa-export-dest]" );
if ( field ) {
field.value = "";
}
exportRun.plan = null;
exportRun.namePlan = null;
closeResultPanel();
setExportProgress( "" );
closeExportPanel();
updateExportRunState();
}
/**
* Which tracks of a single file to write out.
*
* Only offered when the panel is scoped to one file: across a folder of
* takes "track 3" isn't the same thing twice, so a track picker there
* would be a promise the files can't keep. Empty means all of them, which
* is also what the commands understand.
*/
function renderTrackChips( row ) {
var wrap = inPanel( "[data-bwfa-export-track-chips]" );
var block = inPanel( "[data-bwfa-export-tracks]" );
if ( ! wrap || ! block ) {
return;
}
wrap.textContent = "";
exportRun.tracks = null;
var channels = ( row && row.parsed && row.parsed.format &&
row.parsed.format.numChannels ) || 0;
if ( ! row || channels < 2 ) {
block.hidden = true;
return;
}
var names = ( row.parsed.ixml && row.parsed.ixml.trackList ) || [];
exportRun.tracks = [];
for ( var i = 0; i < channels; i++ ) {
exportRun.tracks.push( i + 1 );
var track = names.filter( function ( entry ) {
return String( entry.interleaveIndex ) === String( i + 1 );
} )[ 0 ] || names[ i ];
var chip = document.createElement( "button" );
chip.type = "button";
chip.className = "chip bwfa-channel-chip";
chip.setAttribute( "data-bwfa-export-track", String( i + 1 ) );
chip.textContent = ( track && track.name )
? ( i + 1 ) + " " + track.name
: "Track " + ( i + 1 );
wrap.appendChild( chip );
}
block.hidden = false;
}
function toggleTrackChip( chip ) {
var channel = parseInt( chip.getAttribute( "data-bwfa-export-track" ), 10 );
var tracks = exportRun.tracks || [];
var at = tracks.indexOf( channel );
if ( at === -1 ) {
tracks.push( channel );
tracks.sort( function ( a, b ) { return a - b; } );
} else if ( tracks.length > 1 ) {
tracks.splice( at, 1 ); // Never all of them off: that's not an export.
}
exportRun.tracks = tracks;
chip.classList.toggle( "is-off", tracks.indexOf( channel ) === -1 );
}
/** Rows the table is currently showing, in its current order. */
function rowsOnScreen() {
var state = window.BWFA_STATE;
var rows = ( state && state.filteredRows ) || [];
return rows.filter( function ( row ) {
return row && row.file && row.file.path;
} );
}
function setExportProgress( text ) {
var line = inPanel( "[data-bwfa-export-progress]" );
if ( line ) {
line.textContent = text || "";
line.hidden = ! text;
}
}
function decibels( gain ) {
if ( ! gain || gain <= 0 ) {
return "0.00 dB";
}
var db = 20 * Math.log10( gain );
return ( db >= 0 ? "+" : "" ) + db.toFixed( 2 ) + " dB";
}
function openExportPanel( rows, scope ) {
var panel = exportPanel();
if ( ! panel || exportRun.running ) {
return;
}
// Two modals stacked on each other is nobody's idea of an interface.
if ( ! ( currentApp().querySelector( "[data-bwfa-bulk-edit-panel]" ) || {} ).hidden ) {
closeSheet( "bulk" );
}
exportRun.rows = rows;
exportRun.cancelled = false;
renderTrackChips( rows.length === 1 ? rows[ 0 ] : null );
var scopeLine = inPanel( "[data-bwfa-export-scope]" );
if ( scopeLine ) {
scopeLine.textContent = scope;
}
// Last time's report isn't about this time.
closeResultPanel();
setExportProgress( "" );
var run = inPanel( "[data-bwfa-export-run]" );
if ( run ) {
run.textContent = "Export";
}
var cancel = inPanel( "[data-bwfa-export-cancel]" );
if ( cancel ) {
cancel.textContent = "Cancel";
}
panel.hidden = false;
refreshSheets();
refreshCombinePlan();
refreshNamePreview();
updateExportRunState();
}
/**
* What a combine would make, before anything is written.
*
* Every answer is already in the metadata — rates, timecodes, lengths — so
* this costs nothing and can be shown the moment the option is picked. The
* plan comes from Rust rather than being worked out here as well: one
* implementation for what the panel says and for what the writer enforces.
*/
function refreshCombinePlan() {
var line = inPanel( "[data-bwfa-export-summary]" );
if ( ! line ) {
return;
}
var combining = ( inPanel( "[data-bwfa-export-channels]" ) || {} ).value === "combine";
exportRun.plan = null;
if ( ! combining ) {
line.hidden = true;
line.textContent = "";
updateExportRunState();
return;
}
var paths = exportRun.rows.map( function ( row ) { return row.file.path; } );
line.hidden = false;
line.classList.remove( "is-blocked" );
line.textContent = "Working out the timeline…";
if ( paths.length < 2 ) {
exportRun.plan = { problems: [ "Combining takes more than one file." ] };
showCombinePlan();
return;
}
var want = wantedTarget();
invoke( "bwf_combine_plan", {
sources: paths,
bits: want.bits,
float: want.float
} ).then( function ( plan ) {
exportRun.plan = plan;
showCombinePlan();
} ).catch( function ( e ) {
exportRun.plan = { problems: [ String( ( e && e.message ) || e ) ] };
showCombinePlan();
} );
}
/**
* What the names would be, shown before anything is written.
*
* The whole point of renaming from metadata is that you can see it was
* right before it happens, so this runs on every keystroke and the same
* plan is what the run uses. Combining is exempt: its output is one file
* from many, and a per-file pattern has nothing to resolve against.
*/
function refreshNamePreview() {
var box = inPanel( "[data-bwfa-export-names]" );
var custom = inPanel( "[data-bwfa-export-pattern-row]" );
var choice = ( inPanel( "[data-bwfa-export-naming]" ) || {} ).value || "";
var combining = ( inPanel( "[data-bwfa-export-channels]" ) || {} ).value === "combine";
var naming = inPanel( "[data-bwfa-export-naming-row]" );
if ( naming ) {
naming.hidden = combining;
}
if ( custom ) {
custom.hidden = combining || choice !== "custom";
}
exportRun.namePlan = null;
if ( ! box || ! exportRun.rows.length ) {
if ( box ) {
box.hidden = true;
box.textContent = "";
}
updateExportRunState();
return;
}
var pattern = combining ? "" : namingPattern();
// Custom with nothing in it would export unrenamed while the panel
// says otherwise, which is a silent no-op at the exact moment the
// person is paying attention to names.
if ( ! combining && choice === "custom" && ! pattern ) {
exportRun.namePlan = {
names: {}, rows: [],
problems: [ "Type a pattern, or choose Leave the names alone" ]
};
showNamePreview( box, exportRun.namePlan, false );
return;
}
var plan = planNames( exportRun.rows, pattern, relativeTo, expansionFor(
( inPanel( "[data-bwfa-export-channels]" ) || {} ).value,
exportRun.rows.length === 1 ? ( exportRun.tracks || [] ) : []
) );
exportRun.namePlan = plan;
// With no pattern there is nothing to show unless something is wrong:
// a list of names mapping to themselves tells nobody anything.
showNamePreview( box, plan, !! pattern );
}
function showNamePreview( box, plan, showRows ) {
box.textContent = "";
box.classList.toggle( "is-blocked", !! plan.problems.length );
if ( plan.problems.length ) {
var trouble = document.createElement( "div" );
trouble.textContent = plan.problems[ 0 ];
box.appendChild( trouble );
} else if ( showRows ) {
plan.rows.slice( 0, 4 ).forEach( function ( pair ) {
var line = document.createElement( "div" );
line.className = "bwfa-export-name-row";
var was = document.createElement( "span" );
was.textContent = pair.was;
var now = document.createElement( "strong" );
// A split writes several files per take, so the preview names
// the first and counts the rest rather than implying one.
now.textContent = pair.now +
( pair.also ? " + " + pair.also + " more track" +
( pair.also === 1 ? "" : "s" ) : "" );
line.appendChild( was );
line.appendChild( now );
box.appendChild( line );
} );
if ( plan.rows.length > 4 ) {
var more = document.createElement( "small" );
more.textContent = "and " + ( plan.rows.length - 4 ) + " more";
box.appendChild( more );
}
} else {
box.hidden = true;
updateExportRunState();
return;
}
box.hidden = false;
updateExportRunState();
}
/**
* What the depth control is asking for, as the writers want it.
*
* Zero bits means "whatever the sources already are", which only the
* combine needs telling: it's the one call that has to resolve several
* sources into a single format.
*/
function wantedTarget() {
var convert = ( inPanel( "[data-bwfa-export-depth]" ) || {} ).value === "24";
return { bits: convert ? 24 : 0, float: false };
}
function showCombinePlan() {
var line = inPanel( "[data-bwfa-export-summary]" );
var plan = exportRun.plan;
if ( ! line || ! plan ) {
return;
}
line.textContent = "";
var blocked = plan.problems && plan.problems.length;
line.classList.toggle( "is-blocked", !! blocked );
var headline = document.createElement( "div" );
if ( blocked ) {
headline.textContent = plan.problems[ 0 ];
} else {
headline.textContent = exportRun.rows.length + " files · " + plan.channels +
" channels · " + ( plan.sampleRate / 1000 ) + " kHz · " +
( plan.targetFormat ? plan.targetFormat + " · " : "" ) +
clockOf( plan.seconds ) + " · about " + sizeOf( plan.bytes );
}
line.appendChild( headline );
var notes = ( blocked ? plan.problems.slice( 1 ) : ( plan.notes || [] ) );
if ( notes.length ) {
var small = document.createElement( "small" );
small.textContent = notes.join( ". " );
line.appendChild( small );
}
line.hidden = false;
updateExportRunState();
}
/**
* How the report describes the word length. A file that was rewritten
* without its format changing — normalised, or split, with the depth left
* as recorded — shouldn't read "32-bit float to 32-bit float".
*/
function became( from, to ) {
return from === to ? to : from + " to " + to;
}
function clockOf( seconds ) {
var whole = Math.max( 0, Math.round( seconds || 0 ) );
var hours = Math.floor( whole / 3600 );
var minutes = Math.floor( ( whole % 3600 ) / 60 );
var rest = whole % 60;
return ( hours < 10 ? "0" : "" ) + hours + ":" +
( minutes < 10 ? "0" : "" ) + minutes + ":" +
( rest < 10 ? "0" : "" ) + rest;
}
function sizeOf( bytes ) {
var mb = ( bytes || 0 ) / ( 1024 * 1024 );
return mb >= 1024 ? ( mb / 1024 ).toFixed( 1 ) + " GB" : Math.round( mb ) + " MB";
}
/**
* What to call the combined file: the longest name the sources agree on,
* which for a set of split mono files is the poly they came from.
*/
function combinedName( rows ) {
var names = rows.map( function ( row ) {
var full = ( row.parsed && row.parsed.fileName ) || row.file.name;
var dot = full.lastIndexOf( "." );
return dot > 0 ? full.slice( 0, dot ) : full;
} );
var shared = names[ 0 ] || "combined";
names.forEach( function ( name ) {
var at = 0;
while ( at < shared.length && at < name.length && shared[ at ] === name[ at ] ) {
at++;
}
shared = shared.slice( 0, at );
} );
shared = shared.replace( /[\s_\-.]+$/, "" );
return ( shared.length >= 3 ? shared : ( names[ 0 ] || "combined" ) ) + "_POLY.wav";
}
/** Export needs somewhere to write and something to write. */
function updateExportRunState() {
// A combine whose plan hasn't come back yet is not a combine that's
// been checked. Exporting through that window would skip the pre-check
// entirely and fall back to defaults the person never chose.
var waiting = ( inPanel( "[data-bwfa-export-channels]" ) || {} ).value === "combine" &&
! exportRun.plan;
var blocked = waiting ||
!! ( exportRun.plan && exportRun.plan.problems && exportRun.plan.problems.length ) ||
!! ( exportRun.namePlan && exportRun.namePlan.problems.length );
var run = inPanel( "[data-bwfa-export-run]" );
if ( run ) {
run.disabled = exportRun.running || ! exportRun.dest ||
! exportRun.rows.length || blocked;
}
var target = inPanel( "[data-bwfa-export-target]" );
var mode = inPanel( "[data-bwfa-export-normalize]" );
if ( target && mode ) {
// With normalising off there is no target to hit: the only gain
// applied is whatever keeps a hot float file from clipping.
target.disabled = mode.value === "off";
}
}
function chooseDestination() {
return nativeOpen( {
directory: true,
multiple: false,
title: "Choose a folder to export into",
defaultPath: currentFolderPath || undefined
} ).then( function ( chosen ) {
if ( ! chosen ) {
return;
}
var path = String( chosen ).replace( /\/+$/, "" );
if ( currentFolderPath && path === currentFolderPath.replace( /\/+$/, "" ) ) {
setExportProgress( "That's the folder the files are already in. Pick a different one." );
return;
}
exportRun.dest = path;
var field = inPanel( "[data-bwfa-export-dest]" );
if ( field ) {
field.value = path;
}
setExportProgress( "" );
updateExportRunState();
} );
}
/**
* What one file should come out as.
*
* Leave as recorded means exactly that, even when the audio has to be
* rewritten: a 32-bit float take that gets normalised, split or combined
* comes back as 32-bit float. Converting is the only thing that changes the
* word length, and it only brings a deep file down to 24-bit — nothing is
* padded upwards, because that invents precision.
*
* Zero bits means "copy the file", which is where a file needing no rewrite
* at all ends up: byte-for-byte is a stronger guarantee about metadata than
* any rebuild, however careful.
*/
function plannedFormat( info, convert, mustRewrite ) {
var isFloat = info.format.indexOf( "float" ) !== -1;
if ( convert && ( isFloat || info.bits > 24 ) ) {
return { bits: 24, float: false };
}
if ( ! mustRewrite ) {
return { bits: 0, float: false };
}
if ( isFloat ) {
return { bits: info.bits, float: true };
}
// 8-bit is the one thing not handed back as it was: below the writer's
// floor, and not a format to give a location workflow.
return { bits: Math.max( info.bits, 16 ), float: false };
}
/* ---- Naming the copies ------------------------------------------
Files come off a recorder as T001.WAV and post wants 12A-3. Every
field needed to do that is already parsed, so this is a naming
decision and nothing more: the writers take a destination path and
don't care how it was arrived at. Only copies are renamed. The
originals keep the names the recorder gave them, which is the one
thing on the card that ties a file back to the machine that made it.
------------------------------------------------------------------ */
var NAME_TOKENS = {
name: function ( row ) { return baseName( row ); },
scene: function ( row ) { return field( row, "scene" ); },
take: function ( row ) { return field( row, "take" ); },
tape: function ( row ) { return field( row, "tape" ); },
project: function ( row ) { return field( row, "project" ); },
date: function ( row ) {
return String( ( ( row.parsed || {} ).bext || {} ).originationDate || "" ).trim();
},
time: function ( row ) {
return String( ( ( row.parsed || {} ).bext || {} ).originationTime || "" )
.replace( /[^0-9]/g, "" );
},
tc: function ( row ) {
return String( ( row.parsed || {} ).startTimecode || "" ).replace( /[^0-9]/g, "" );
},
n: function ( row, index ) {
var number = String( index + 1 );
while ( number.length < 3 ) {
number = "0" + number;
}
return number;
}
};
/**
* Where a file goes inside the destination, so a card with Sound Devices'
* date subfolders comes out with those subfolders intact.
*
* Worked out from the two absolute paths rather than from the row's
* relativePath, which means one thing when the folder was walked for
* editing and another when it was scanned.
*/
function relativeTo( row ) {
var base = currentFolderPath ? currentFolderPath.replace( /\/+$/, "" ) + "/" : "";
if ( base && row.file.path.indexOf( base ) === 0 ) {
return row.file.path.slice( base.length );
}
var own = ( row.parsed && row.parsed.fileName ) || row.file.name;
return String( row.relativePath || own ).split( "/" ).pop();
}
function baseName( row ) {
var full = ( row.parsed && row.parsed.fileName ) || row.file.name;
var dot = full.lastIndexOf( "." );
return dot > 0 ? full.slice( 0, dot ) : full;
}
function extensionOf( row ) {
var full = ( row.parsed && row.parsed.fileName ) || row.file.name;
var dot = full.lastIndexOf( "." );
// Kept exactly as it was: .WAV off the recorder stays .WAV, because
// quietly changing the case of a name is the sort of thing that makes
// somebody re-link a whole timeline.
return dot > 0 ? full.slice( dot ) : ".wav";
}
function field( row, key ) {
var ixml = ( row.parsed || {} ).ixml || {};
return String( ixml[ key ] === undefined || ixml[ key ] === null ? "" : ixml[ key ] ).trim();
}
/** The tokens a pattern uses that aren't ones we know. */
function unknownTokens( pattern ) {
var found = String( pattern ).match( /\{[^}]*\}/g ) || [];
return found.filter( function ( token ) {
return ! Object.prototype.hasOwnProperty.call(
NAME_TOKENS, token.slice( 1, -1 ).trim().toLowerCase() );
} );
}
/**
* One file's new base name.
*
* An empty field takes its neighbouring separator with it, or a take with
* no scene comes out as "-3" and a whole card of them sorts into nonsense.
* A pattern that resolves to nothing at all falls back to the original
* name: better a file that isn't renamed than a file called ".wav".
*/
function nameFrom( pattern, row, index ) {
// An empty field is marked rather than simply dropped, so the collapse
// below can tell "the scene is missing" from "the user typed a dash".
// A pattern of "{name} - {scene}" keeps its spaced dash when there is
// a scene; a missing one takes exactly one separator with it.
var out = String( pattern ).replace( /\{([^}]*)\}/g, function ( whole, key ) {
var name = String( key ).trim().toLowerCase();
// Own properties only, or {constructor} is a "token" that returns
// a function and {__proto__} throws inside the replace.
if ( ! Object.prototype.hasOwnProperty.call( NAME_TOKENS, name ) ) {
return whole;
}
var token = NAME_TOKENS[ name ];
return token( row, index ) || "\u0000";
} );
out = out
// The marked gap takes the separator run after it, so "{a}--{b}"
// with no a comes out as "b" rather than "-b".
.replace( /\u0000[\s._-]*/g, "" )
.replace( /[ \t]+/g, " " )
.replace( /^[\s._-]+|[\s._-]+$/g, "" );
out = sanitiseName( out );
return out || sanitiseName( baseName( row ) ) || "export";
}
/** Names Windows refuses outright, whatever extension follows them. */
var RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
/**
* Characters that have no business in a filename on somebody else's
* machine.
*
* Every value fed to this came out of a file somebody else wrote, so it is
* treated as hostile: this function is the only thing between a metadata
* field and a path the app then hands to a writer that will happily create
* whatever directories it is given.
*
* Windows is the strict one and these files travel, so its rules are the
* ones worth keeping to: its forbidden characters, its reserved device
* names, and no trailing dot or space.
*/
function sanitiseName( text ) {
var out = String( text )
.replace( /[\/\\:*?"<>|]/g, "_" )
// eslint-disable-next-line no-control-regex
.replace( /[\u0000-\u001f\u007f]/g, "" )
// Zero-width and direction-override characters: invisible in the
// preview, invisible in the report, and two names that look
// identical are two files that quietly sit side by side.
.replace( /[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "" )
.replace( /_{2,}/g, "_" )
.replace( /^[.\s]+|[.\s]+$/g, "" );
out = clipBytes( out, 200 );
// Truncation can uncover a trailing dot or space that the strip above
// existed to remove, so the strip runs again after it.
out = out.replace( /[.\s]+$/g, "" );
if ( RESERVED_NAMES.test( out ) ) {
out += "_";
}
return out;
}
/**
* Cuts a string to a byte budget rather than a character count.
*
* A filename on APFS is capped at 255 bytes, not 255 characters, so a
* scene written in Japanese runs out three times sooner than one in
* English. Cutting on characters would let the preview promise a name the
* write then fails on. Surrogate pairs are kept whole: half of one is not
* representable as UTF-8 and would not survive the trip to Rust.
*/
function clipBytes( text, budget ) {
var bytes = 0;
var out = "";
for ( var at = 0; at < text.length; at++ ) {
var code = text.charCodeAt( at );
var piece = text[ at ];
if ( code >= 0xd800 && code <= 0xdbff && at + 1 < text.length ) {
piece += text[ at + 1 ];
at++;
}
var size = piece.length > 1 ? 4 : ( code < 0x80 ? 1 : ( code < 0x800 ? 2 : 3 ) );
if ( bytes + size > budget ) {
break;
}
bytes += size;
out += piece;
}
return out;
}
/** The pattern the naming controls are asking for, or "" for no renaming. */
function namingPattern() {
var choice = ( inPanel( "[data-bwfa-export-naming]" ) || {} ).value || "";
if ( choice !== "custom" ) {
return choice;
}
return String( ( inPanel( "[data-bwfa-export-pattern]" ) || {} ).value || "" ).trim();
}
/**
* What every file in the run would be called, and anything in the way.
*
* Runs whether or not anything is being renamed, because two files landing
* on one path is a lost take either way: a card holding the same name in
* two folders flattens into a collision the moment the folder they came
* from isn't known. The old behaviour was to write both and report two
* successes.
*
* Clashes are checked on the whole path inside the destination rather than
* the name, since a card of date subfolders can legitimately hold two
* takes called the same thing, and refusing that would be refusing the
* recorder's own layout. They're compared case-folded and normalised,
* because a Mac volume considers "12A" and "12a", and composed and
* decomposed accents, to be the same file even though JavaScript doesn't.
*
* `expand` turns one source into everything it will actually write, so a
* split is checked and previewed under the names its mono files will have
* rather than the poly name that never reaches the disk.
*/
function planNames( rows, pattern, relativeOf, expand ) {
var plan = { names: {}, rows: [], problems: [] };
if ( pattern ) {
var unknown = unknownTokens( pattern );
if ( unknown.length ) {
plan.problems.push( unknown[ 0 ] + " isn't a name I know" );
return plan;
}
if ( String( pattern ).indexOf( "/" ) !== -1 ) {
plan.problems.push( "A name can't contain a slash" );
return plan;
}
}
var seen = {};
rows.forEach( function ( row, index ) {
var was = relativeOf( row );
var folder = was.indexOf( "/" ) === -1 ? "" : was.slice( 0, was.lastIndexOf( "/" ) + 1 );
var now = pattern
? folder + nameFrom( pattern, row, index ) + extensionOf( row )
: was;
plan.names[ row.file.path ] = now;
var written = expand ? expand( row, now ) : [ now ];
plan.rows.push( { was: was, now: written[ 0 ], also: written.length - 1 } );
written.forEach( function ( target ) {
var key = target.toLowerCase();
key = key.normalize ? key.normalize( "NFC" ) : key;
if ( seen[ key ] && ! plan.problems.length ) {
plan.problems.push( "Two files would both be written as " +
target.split( "/" ).pop() +
( pattern ? ". Add {n} to tell them apart." : "." ) );
}
seen[ key ] = true;
} );
} );
return plan;
}
/**
* Everything one source file will be written as, under a given name.
*
* Only splitting turns one file into several. The channel count comes from
* the parse rather than a fresh probe, because this runs on every keystroke
* of the pattern field and the two agree on every file the app can open.
*/
function expansionFor( channelsMode, tracks ) {
return function ( row, target ) {
var total = ( ( row.parsed || {} ).format || {} ).numChannels || 1;
var wanted = tracks && tracks.length ? tracks : null;
var kept = wanted ? wanted.length : total;
if ( channelsMode !== "split" || kept < 2 ) {
return [ target ];
}
var folder = target.indexOf( "/" ) === -1
? "" : target.slice( 0, target.lastIndexOf( "/" ) + 1 );
var base = target.split( "/" ).pop().replace( /\.[^.]*$/, "" );
var channels = wanted || [];
if ( ! channels.length ) {
for ( var c = 1; c <= total; c++ ) {
channels.push( c );
}
}
return channels.map( function ( channel ) {
return folder + monoName( row, channel - 1, total, base );
} );
};
}
/**
* What to call one channel of a poly file.
*
* Numbered first so a folder of them sorts into channel order, then the
* recorder's own track name when there is one, because "A001_3_Lav_Anna"
* tells an editor something and "A001_3" doesn't. Sanitised hard: these end
* up as filenames on someone else's machine.
*/
function monoName( row, channel, total, override ) {
var full = ( row.parsed && row.parsed.fileName ) || row.file.name;
var dot = full.lastIndexOf( "." );
var base = override || ( dot > 0 ? full.slice( 0, dot ) : full );
var extension = dot > 0 ? full.slice( dot ) : ".wav";
var digits = String( total ).length;
var number = String( channel + 1 );
while ( number.length < digits ) {
number = "0" + number;
}
var tracks = ( row.parsed && row.parsed.ixml && row.parsed.ixml.trackList ) || [];
var track = tracks.filter( function ( entry ) {
return String( entry.interleaveIndex ) === String( channel + 1 );
} )[ 0 ] || tracks[ channel ];
var label = ( track && track.name ? String( track.name ) : "" )
.replace( /[^A-Za-z0-9]+/g, "_" )
.replace( /^_+|_+$/g, "" )
.slice( 0, 24 );
return base + "_" + number + ( label ? "_" + label : "" ) + extension;
}
function exportSummary( tally, gainNote ) {
var parts = [];
if ( tally.converted ) {
parts.push( tally.converted + ( tally.converted === 1 ? " file converted" : " files converted" ) );
}
if ( tally.copied ) {
parts.push( tally.copied + ( tally.copied === 1 ? " file copied" : " files copied" ) );
}
if ( tally.skipped ) {
parts.push( tally.skipped + " skipped" );
}
if ( tally.failed ) {
parts.push( tally.failed + " failed" );
}
if ( ! parts.length ) {
parts.push( "Nothing to do" );
}
return parts.join( ", " ) + ( gainNote ? ". " + gainNote : "." );
}
/* ---- What happened, once it has happened -------------------------
A run over a card produces a hundred lines, and they used to land
underneath the controls that started it, in a panel already as tall
as the window. A finished export isn't a form any more, so it stops
looking like one: the export modal closes and this takes its place.
------------------------------------------------------------------ */
function resultPanel() {
var app = currentApp();
return app && app.querySelector( "[data-bwfa-result-panel]" );
}
function inResult( selector ) {
var panel = resultPanel();
return panel && panel.querySelector( selector );
}
function closeResultPanel() {
var panel = resultPanel();
if ( panel ) {
panel.hidden = true;
refreshSheets();
}
}
/** The counts, as a strip. A zero is worth showing: nothing failed. */
function writeTally( tally ) {
var strip = inResult( "[data-bwfa-result-tally]" );
if ( ! strip ) {
return;
}
strip.textContent = "";
[
{ key: "converted", label: "converted" },
{ key: "copied", label: "copied" },
{ key: "skipped", label: "skipped" },
{ key: "failed", label: "failed", bad: true }
].forEach( function ( part ) {
var count = tally[ part.key ] || 0;
var stat = document.createElement( "div" );
stat.className = "bwfa-result-stat" +
( count ? "" : " is-zero" ) +
( count && part.bad ? " is-bad" : "" );
stat.setAttribute( "data-bwfa-result-count", part.key );
var number = document.createElement( "strong" );
number.textContent = String( count );
var label = document.createElement( "span" );
label.textContent = part.label;
stat.appendChild( number );
stat.appendChild( label );
strip.appendChild( stat );
} );
}
/**
* The per-file list. A table rather than a paragraph of lines: at a
* hundred rows the eye needs a column to run down.
*/
function writeReportList( lines ) {
var report = inResult( "[data-bwfa-export-report]" );
if ( ! report ) {
return;
}
report.textContent = "";
var list = document.createElement( "ul" );
lines.forEach( function ( line ) {
var item = document.createElement( "li" );
if ( line.problem ) {
item.className = "is-problem";
}
var name = document.createElement( "span" );
name.className = "bwfa-export-report-name";
name.textContent = line.name;
var detail = document.createElement( "span" );
detail.className = "bwfa-export-report-detail";
detail.textContent = line.detail;
item.appendChild( name );
item.appendChild( detail );
list.appendChild( item );
} );
report.appendChild( list );
report.hidden = false;
}
function writeExportReport( run ) {
lastExportRun = run;
var title = inResult( "[data-bwfa-result-title]" );
if ( title ) {
title.textContent = run.failed
? "Export failed"
: ( run.stopped ? "Export stopped" : "Export finished" );
}
var where = inResult( "[data-bwfa-result-where]" );
if ( where ) {
where.textContent = run.dest ? "Written to " + run.dest : "";
}
var note = inResult( "[data-bwfa-result-note]" );
if ( note ) {
note.textContent = run.note || "";
note.hidden = ! run.note;
}
writeTally( run.tally );
writeReportList( run.lines );
var panel = resultPanel();
if ( panel ) {
panel.hidden = false;
refreshSheets();
}
var report = inResult( "[data-bwfa-export-report]" );
if ( report ) {
report.scrollTop = 0;
}
}
/**
* The run as a plain text file.
*
* Written for whoever reads it later — an assistant checking a delivery,
* or the person themselves a week on — so it carries the settings that
* produced it, not just the outcome. Names are padded into a column,
* because that's what makes a hundred lines scannable in a text editor.
*/
function exportLogText( run ) {
var stamp = new Date();
var out = [
"BWF Analyser — export log",
stamp.toLocaleString(),
""
];
( run.settings || [] ).forEach( function ( pair ) {
out.push( pad( pair[ 0 ] + ":", 16 ) + pair[ 1 ] );
} );
out.push( "" );
out.push( run.summary );
if ( run.note ) {
out.push( run.note );
}
out.push( "" );
var widest = 0;
run.lines.forEach( function ( line ) {
widest = Math.max( widest, ( line.name || "" ).length );
} );
widest = Math.min( widest + 2, 42 );
run.lines.forEach( function ( line ) {
var mark = line.problem ? "!" : " ";
out.push( mark + " " + pad( line.name || "", widest ) + line.detail );
} );
out.push( "" );
return out.join( "\n" );
}
function pad( text, width ) {
var padded = String( text );
while ( padded.length < width ) {
padded += " ";
}
return padded;
}
function saveExportLog() {
if ( ! lastExportRun ) {
return;
}
var when = new Date();
var day = when.getFullYear() + "-" +
( "0" + ( when.getMonth() + 1 ) ).slice( -2 ) + "-" +
( "0" + when.getDate() ).slice( -2 );
var blob = new Blob( [ exportLogText( lastExportRun ) ], { type: "text/plain" } );
// Into the folder the export went to: the log is about those files.
return saveBlobNatively( blob, "bwf-export-log-" + day + ".txt",
lastExportRun.dest );
}
function runExport() {
if ( exportRun.running || ! exportRun.dest || ! exportRun.rows.length ) {
return;
}
// Even with the panel closing on a folder change, the rows it holds are
// a snapshot, and a snapshot can go stale in ways nobody predicted.
// Cheap to check, and the failure it prevents is writing out the wrong
// folder's audio.
var onScreen = {};
rowsOnScreen().forEach( function ( row ) { onScreen[ row.file.path ] = true; } );
var missing = exportRun.rows.filter( function ( row ) {
return ! onScreen[ row.file.path ];
} );
if ( missing.length ) {
exportRun.rows = [];
updateExportRunState();
setExportProgress( "Those files aren't open any more. Close this and choose Export Files again." );
return;
}
var rows = exportRun.rows.slice();
var dest = exportRun.dest;
var convert = ( inPanel( "[data-bwfa-export-depth]" ) || {} ).value === "24";
var mode = ( inPanel( "[data-bwfa-export-normalize]" ) || {} ).value || "off";
var overwrite = ( inPanel( "[data-bwfa-export-collision]" ) || {} ).value === "replace";
var channelsMode = ( inPanel( "[data-bwfa-export-channels]" ) || {} ).value;
var splitting = channelsMode === "split";
var combining = channelsMode === "combine";
// Only meaningful for a single file, and an empty list means all.
var tracks = ( rows.length === 1 && exportRun.tracks ) ? exportRun.tracks.slice() : [];
var allTracks = ! tracks.length ||
( rows.length === 1 && tracks.length === ( ( rows[ 0 ].parsed.format || {} ).numChannels || 0 ) );
var chosenTracks = allTracks ? [] : tracks;
var targetDb = parseFloat( ( inPanel( "[data-bwfa-export-target]" ) || {} ).value );
if ( ! isFinite( targetDb ) ) {
targetDb = -3;
}
var normalising = mode !== "off";
// Read off the controls while they're still on screen, because the
// panel closes the moment the run ends and the log is written after.
var settings = [
[ "From", currentFolderPath || "" ],
[ "To", dest ],
[ "Files", rows.length + ( rows.length === 1 ? " file" : " files" ) ],
[ "Bit depth", optionText( "[data-bwfa-export-depth]" ) ],
[ "Channels", optionText( "[data-bwfa-export-channels]" ) ],
[ "Normalise", optionText( "[data-bwfa-export-normalize]" ) ]
];
if ( normalising ) {
settings.push( [ "Target peak", targetDb + " dBFS" ] );
}
if ( chosenTracks.length ) {
settings.push( [ "Tracks", chosenTracks.join( ", " ) ] );
}
settings.push( [ "If it exists", optionText( "[data-bwfa-export-collision]" ) ] );
var pattern = combining ? "" : namingPattern();
var namePlan = combining
? { names: {}, problems: [] }
: planNames( rows, pattern, relativeTo,
expansionFor( channelsMode, chosenTracks ) );
// Checked here rather than relying on the button being disabled. Two
// takes landing on one path is a lost take, and a disabled attribute
// is a piece of UI state, not a guarantee.
if ( namePlan.problems.length ) {
setExportProgress( namePlan.problems[ 0 ] );
exportRun.running = false;
updateExportRunState();
return Promise.resolve();
}
var names = pattern ? namePlan.names : {};
if ( pattern ) {
settings.push( [ "File names", pattern ] );
}
exportRun.running = true;
exportRun.cancelled = false;
updateExportRunState();
var cancelButton = inPanel( "[data-bwfa-export-cancel]" );
if ( cancelButton ) {
cancelButton.textContent = "Stop";
}
var plans = [];
var lines = [];
var alreadyThere = 0;
var tally = { converted: 0, copied: 0, skipped: 0, failed: 0 };
var linkedGain = 0;
function fail( row, message ) {
tally.failed++;
lines.push( { name: nameOf( row ), detail: message, problem: true } );
}
/**
* How a file is labelled in the report.
*
* The relative path, not the bare name: a card can hold T001.WAV twice
* in different folders, and two rows reading "T001.WAV" under two
* different new names is a report nobody can act on.
*/
function nameOf( row ) {
return relativeTo( row );
}
/** Where the file lands, under whichever name it is going out as. */
function relative( row ) {
return names[ row.file.path ] || relativeTo( row );
}
return invoke( "bwf_prepare_dir", { path: dest } )
.then( function ( existing ) {
if ( existing ) {
// Kept for the report: as a progress line it lasted until
// the next file, which is to say not long enough to read.
alreadyThere = existing;
}
// Pass one: the headers only, which is cheap, and tells us
// authoritatively what each file is rather than guessing from
// the table.
return rows.reduce( function ( chain, row, index ) {
return chain.then( function () {
if ( exportRun.cancelled ) {
return;
}
setExportProgress( "Reading " + ( index + 1 ) + " of " + rows.length + "…" );
return invoke( "bwf_probe", { path: row.file.path, scan: false } )
.then( function ( info ) {
var kept = chosenTracks.length || info.channels;
var split = splitting && kept > 1;
// Normalising, splitting, combining and picking a
// subset all rewrite the audio, so none of them
// have a copy-untouched case.
var format = plannedFormat( info, convert,
normalising || split || combining || !! chosenTracks.length );
plans.push( {
row: row,
info: info,
// A subset is a rewrite by definition.
subset: !! chosenTracks.length,
bits: format.bits,
float: format.float,
split: split,
peak: -1,
gain: 1
} );
} )
.catch( function ( e ) {
fail( row, String( e.message || e ) );
} );
} );
}, Promise.resolve() );
} )
.then( function () {
// Pass two: peaks, but only where they change the outcome. An
// integer file can't exceed full scale, so with normalising off
// there is nothing to find out about it. Nor is there when a
// float file is staying float: the reason to measure one was
// that fixed point has no room above 0 dBFS, and float does.
var toFloat = combining
? !! ( exportRun.plan && exportRun.plan.targetFloat )
: null;
var needed = plans.filter( function ( plan ) {
if ( exportRun.cancelled ) {
return false;
}
if ( ! plan.bits && ! combining ) {
return false;
}
if ( normalising ) {
return true;
}
return plan.info.format.indexOf( "float" ) !== -1 &&
! ( toFloat === null ? plan.float : toFloat );
} );
return needed.reduce( function ( chain, plan, index ) {
return chain.then( function () {
if ( exportRun.cancelled ) {
return;
}
setExportProgress( "Measuring " + ( index + 1 ) + " of " + needed.length +
": " + nameOf( plan.row ) );
return invoke( "bwf_probe", { path: plan.row.file.path, scan: true } )
.then( function ( info ) {
plan.peak = info.peak;
plan.nonFinite = info.nonFinite;
} )
.catch( function ( e ) {
plan.error = String( e.message || e );
} );
} );
}, Promise.resolve() );
} )
.then( function () {
// The gain, worked out across the whole batch where that's what
// was asked for. One gain for everything is the default because
// per-file normalising flattens the difference between a
// whispered line and a shout, which is information.
var measured = plans.filter( function ( plan ) { return plan.peak > 0; } );
var loudest = measured.reduce( function ( most, plan ) {
return Math.max( most, plan.peak );
}, 0 );
plans.forEach( function ( plan ) {
if ( ! plan.bits || plan.peak <= 0 ) {
return; // Copied untouched, or silent: nothing to scale.
}
// What comes out. For a combine that's the one poly file,
// whose format can differ from any single source's.
var outFloat = combining
? !! ( exportRun.plan && exportRun.plan.targetFloat )
: plan.float;
var outBits = combining
? ( ( exportRun.plan && exportRun.plan.targetBits ) || 24 )
: plan.bits;
// The loudest value fixed point can actually hold is one
// step short of 1.0 — 8388607 of a possible 8388608 at
// 24-bit. Aiming at 1.0 exactly would clip the peak sample
// by a single LSB and report it, which is a confusing way
// to describe a successful export.
//
// Float has no such ceiling, and a float file peaking above
// 0 dBFS is an ordinary thing off a 32-bit recorder. Turning
// it down to fit a limit that isn't there would be a gain
// change nobody asked for.
var fullScale = outFloat ? Infinity : 1 - Math.pow( 2, 1 - outBits );
var ceiling = Math.min(
Math.pow( 10, Math.min( targetDb, 0 ) / 20 ),
fullScale
);
if ( mode === "linked" || ( combining && mode === "each" ) ) {
plan.gain = ceiling / loudest;
linkedGain = plan.gain;
} else if ( combining && plan.peak <= 0 ) {
plan.gain = 1;
} else if ( mode === "each" ) {
plan.gain = ceiling / plan.peak;
} else if ( combining && loudest > fullScale ) {
// One gain for the set: the loudest source decides, so
// the channels keep their relative levels.
plan.gain = fullScale / loudest;
} else if ( plan.peak > fullScale ) {
// Not the normalise target: with normalising off the
// only reason to touch the level at all is that fixed
// point has no headroom above full scale, and a 32-bit
// float recorder happily records past it. Turning a
// -1 dBFS take down to the target nobody asked for
// would be a gain change by the back door.
plan.gain = fullScale / plan.peak;
}
} );
// Combining is one file out of many, so it's one call rather than
// a loop — and one gain for the whole set, or the channels of
// the poly would no longer sit at the levels they were recorded.
if ( combining ) {
var target = dest + "/" + combinedName( rows );
var combineGain = plans.reduce( function ( most, plan ) {
return Math.max( most, plan.gain );
}, 0 ) || 1;
if ( mode === "each" ) {
lines.push( { name: "Note", detail:
"one gain across the set, since per-file levels would break the mix" } );
}
setExportProgress( "Combining " + rows.length + " files…" );
var want = wantedTarget();
return invoke( "bwf_combine", {
sources: rows.map( function ( row ) { return row.file.path; } ),
dest: target,
bits: want.bits,
float: want.float,
gain: combineGain,
overwrite: overwrite
} ).then( function ( result ) {
tally.converted++;
var detail = result.channels + " channels, " +
clockOf( result.seconds ) + ", " + result.targetFormat;
if ( combineGain !== 1 ) {
detail += ", " + decibels( combineGain );
}
if ( result.clipped ) {
detail += ", " + result.clipped + " samples clipped";
}
lines.push( {
name: combinedName( rows ),
detail: detail,
problem: !! result.clipped
} );
( ( exportRun.plan && exportRun.plan.notes ) || [] ).forEach( function ( note ) {
lines.push( { name: "", detail: note } );
} );
} ).catch( function ( e ) {
noted( rows[ 0 ], combinedName( rows ), e );
} );
}
// Pass three: the writing.
return plans.reduce( function ( chain, plan, index ) {
return chain.then( function () {
if ( exportRun.cancelled ) {
return;
}
var row = plan.row;
var name = nameOf( row );
setExportProgress( "Exporting " + ( index + 1 ) + " of " + plans.length +
": " + name );
var target = dest + "/" + relative( row );
var detailBits = plan.info.format;
// What it ended up called, when that isn't what it was
// called: the one thing a rename has to report.
var renamed = names[ row.file.path ]
? names[ row.file.path ].split( "/" ).pop() : "";
var newBase = renamed ? renamed.replace( /\.[^.]*$/, "" ) : null;
if ( plan.error ) {
fail( row, plan.error );
return;
}
if ( ! plan.bits && plan.gain === 1 && ! chosenTracks.length ) {
return invoke( "bwf_copy_file", {
src: row.file.path,
dest: target,
overwrite: overwrite
} ).then( function () {
tally.copied++;
lines.push( { name: name, detail: "copied, " + detailBits +
( renamed ? ", as " + renamed : "" ) } );
} ).catch( function ( e ) {
noted( row, name, e, renamed );
} );
}
if ( plan.split ) {
var total = plan.info.channels;
var wanted = chosenTracks.length
? chosenTracks
: ( function () {
var all = [];
for ( var c = 1; c <= total; c++ ) {
all.push( c );
}
return all;
}() );
var monoNames = wanted.map( function ( channel ) {
return monoName( row, channel - 1, total, newBase );
} );
// The destination is the folder the file would have
// gone into, so a card of subfolders keeps its shape.
var folder = target.slice( 0, target.lastIndexOf( "/" ) );
return invoke( "bwf_export_split", {
src: row.file.path,
dest: folder,
names: monoNames,
bits: plan.bits,
float: plan.float,
gain: plan.gain,
overwrite: overwrite,
channels: wanted
} ).then( function ( result ) {
tally.converted++;
var detail = became( detailBits, result.targetFormat ) +
", split into " + result.files.length + " mono files" +
( newBase ? ", named after " + newBase : "" );
if ( plan.gain !== 1 ) {
detail += ", " + decibels( plan.gain );
}
if ( result.clipped ) {
detail += ", " + result.clipped + " samples clipped";
}
lines.push( { name: name, detail: detail, problem: !! result.clipped } );
} ).catch( function ( e ) {
noted( row, name, e, renamed );
} );
}
return invoke( "bwf_export", {
src: row.file.path,
dest: target,
bits: plan.bits,
float: plan.float,
gain: plan.gain,
overwrite: overwrite,
channels: chosenTracks
} ).then( function ( result ) {
if ( result.copied ) {
tally.copied++;
lines.push( { name: name, detail: "copied, " + detailBits +
( renamed ? ", as " + renamed : "" ) } );
return;
}
tally.converted++;
var detail = became( detailBits, result.targetFormat );
if ( renamed ) {
detail += ", as " + renamed;
}
if ( chosenTracks.length ) {
detail += ", tracks " + chosenTracks.join( ", " );
}
if ( plan.gain !== 1 ) {
detail += ", " + decibels( plan.gain );
}
if ( plan.nonFinite ) {
detail += ", " + plan.nonFinite + " unreadable samples silenced";
}
if ( result.clipped ) {
detail += ", " + result.clipped + " samples clipped";
}
lines.push( { name: name, detail: detail, problem: !! result.clipped } );
} ).catch( function ( e ) {
noted( row, name, e, renamed );
} );
} );
}, Promise.resolve() );
/**
* `landed` is what the file would have been called in the
* destination. It's the whole point of the skip line: under a
* rename, the file that's already there has the new name, and
* saying the source name sends the reader looking for
* something that was never written.
*/
function noted( row, name, e, landed ) {
var message = String( ( e && e.message ) || e );
var which = landed && landed !== name ? " as " + landed : "";
if ( message.indexOf( "bwf:exists" ) !== -1 ) {
tally.skipped++;
lines.push( {
name: name,
detail: "already in that folder" + which + ", left alone"
} );
return;
}
if ( message.indexOf( "bwf:same-file" ) !== -1 ) {
tally.skipped++;
lines.push( { name: name, detail: "same file as the original" } );
return;
}
fail( row, message );
}
} )
.then( function () {
var notes = [];
if ( alreadyThere ) {
notes.push( alreadyThere + " recording" + ( alreadyThere === 1 ? " was" : "s were" ) +
" already in that folder before this run." );
}
if ( mode === "linked" && linkedGain ) {
notes.push( "One gain of " + decibels( linkedGain ) +
" across the batch, so the levels between takes are unchanged." );
}
finished( notes.join( " " ), false );
} )
.catch( function ( e ) {
tally.failed++;
lines.push( {
name: "Export",
detail: String( ( e && e.message ) || e ),
problem: true
} );
finished( "", true );
} );
/**
* Hand over: the form closes, the report opens. Running is cleared
* first because the panel refuses to close while a run is on, which
* is exactly the guard that keeps a half-finished export on screen.
*/
function finished( note, blewUp ) {
exportRun.running = false;
setExportProgress( "" );
var cancel = inPanel( "[data-bwfa-export-cancel]" );
if ( cancel ) {
cancel.textContent = "Cancel";
}
updateExportRunState();
closeExportPanel();
writeExportReport( {
lines: lines,
tally: tally,
dest: dest,
settings: settings,
note: note,
stopped: exportRun.cancelled,
failed: blewUp,
summary: ( exportRun.cancelled ? "Stopped. " : "" ) +
exportSummary( tally, "" )
} );
}
}
/** What a select actually says, which is what belongs in a log. */
function optionText( selector ) {
var select = inPanel( selector );
if ( ! select || ! select.options ) {
return "";
}
var chosen = select.options[ select.selectedIndex ];
return chosen ? chosen.textContent.trim() : "";
}
document.addEventListener( "click", function ( e ) {
if ( ! e.target || ! e.target.closest ) {
return;
}
if ( e.target.closest( "[data-bwfa-export-audio]" ) ) {
e.preventDefault();
e.stopPropagation();
var rows = rowsOnScreen();
openExportPanel( rows, rows.length === 1
? "One file: " + ( ( rows[ 0 ].parsed && rows[ 0 ].parsed.fileName ) || rows[ 0 ].file.name )
: rows.length + " files, as listed in the table" );
return;
}
if ( e.target.closest( "[data-bwfa-player-export]" ) ) {
e.preventDefault();
e.stopPropagation();
// Whatever the transport is holding, playing or not.
var state = window.BWFA_STATE;
var row = state && state.playerRow && state.playerRow();
if ( ! row || ! row.file || ! row.file.path ) {
return;
}
openExportPanel( [ row ], "One file: " +
( ( row.parsed && row.parsed.fileName ) || row.file.name ) );
return;
}
if ( e.target.closest( "[data-bwfa-export-track]" ) ) {
e.preventDefault();
e.stopPropagation();
toggleTrackChip( e.target.closest( "[data-bwfa-export-track]" ) );
return;
}
if ( e.target.closest( "[data-bwfa-export-choose]" ) ) {
e.preventDefault();
e.stopPropagation();
chooseDestination();
return;
}
if ( e.target.closest( "[data-bwfa-export-run]" ) ) {
e.preventDefault();
e.stopPropagation();
runExport();
return;
}
if ( e.target.closest( "[data-bwfa-result-save]" ) ) {
e.preventDefault();
e.stopPropagation();
saveExportLog();
return;
}
if ( e.target.closest( "[data-bwfa-result-done]" ) ) {
e.preventDefault();
e.stopPropagation();
closeResultPanel();
return;
}
if ( e.target.closest( "[data-bwfa-export-cancel]" ) ) {
e.preventDefault();
e.stopPropagation();
if ( exportRun.running ) {
exportRun.cancelled = true;
setExportProgress( "Stopping after this file…" );
} else {
closeExportPanel();
}
return;
}
// Bulk edit and export both need the height, so opening one closes
// the other.
if ( e.target.closest( "[data-bwfa-bulk-edit-toggle]" ) ) {
closeExportPanel();
}
}, true );
document.addEventListener( "input", function ( e ) {
if ( e.target && e.target.closest && e.target.closest( "[data-bwfa-export-pattern]" ) ) {
refreshNamePreview();
}
}, true );
document.addEventListener( "change", function ( e ) {
if ( ! e.target || ! e.target.closest ) {
return;
}
if ( e.target.closest( "[data-bwfa-export-normalize]" ) ) {
updateExportRunState();
}
// The depth changes what the combined file would be, and the summary
// line has to keep saying what will actually be written.
if ( e.target.closest( "[data-bwfa-export-channels]" ) ||
e.target.closest( "[data-bwfa-export-depth]" ) ) {
refreshCombinePlan();
}
if ( e.target.closest( "[data-bwfa-export-naming]" ) ||
e.target.closest( "[data-bwfa-export-channels]" ) ) {
refreshNamePreview();
}
}, true );
window.showDirectoryPicker = function () {
var chosen;
if ( pendingFolder ) {
chosen = Promise.resolve( pendingFolder );
pendingFolder = null;
} else {
chosen = nativeOpen( { directory: true, multiple: false, title: "Choose a folder of recordings" } )
.then( function ( selected ) {
if ( ! selected ) {
throw abortError( "The user aborted a request." );
}
return [].concat( selected )[ 0 ];
} );
}
return chosen.then( function ( path ) {
// One directory walk to find out whether there's anything to open.
// A scan that fails outright is not evidence of an empty folder, so
// that case carries on and lets the app report the real error.
return scan( [ path ] ).catch( function () { return null; } ).then( function ( entries ) {
if ( entries && ! entries.length ) {
openedNothing( path );
throw abortError( "no recordings in that folder" );
}
// A new folder means everything about the old one is gone: the
// rows, and anything holding a reference to them. The export
// panel captured a list of files when it opened, and without
// this it would still be holding yesterday's card.
if ( currentFolderPath && currentFolderPath !== path ) {
clearLoadedFiles();
}
forgetExportScope();
return handleFor( path );
} );
} );
};
/* ------------------------------------------------------------------ */
/* The webview's own context menu */
/* ------------------------------------------------------------------ */
/*
* WKWebView offers Reload, Back and Forward on right-click. In a browser
* that's harmless; in an app it's a trapdoor — Reload throws away the
* open folder and any unsaved edit for no stated reason. Text fields keep
* their menu, since Cut/Copy/Paste there is genuinely useful.
*/
document.addEventListener( "contextmenu", function ( e ) {
var target = e.target;
var editable = target && target.closest &&
target.closest( 'input, textarea, [contenteditable="true"]' );
if ( ! editable ) {
e.preventDefault();
}
} );
/* ------------------------------------------------------------------ */
/* Exports: turn a browser download into a native Save panel */
/* ------------------------------------------------------------------ */
/*
* CSV and PDF export both end in `<a download>` + click(), which a
* WKWebView has nowhere to put. Remembering each blob as its URL is handed
* out lets the click be answered with a real macOS save panel instead.
* The blob itself is held, not the URL, so the app revoking the URL a
* second later is harmless.
*/
var blobsByUrl = new Map();
var nativeCreateObjectURL = URL.createObjectURL.bind( URL );
URL.createObjectURL = function ( object ) {
var url = nativeCreateObjectURL( object );
if ( object instanceof Blob ) {
blobsByUrl.set( url, object );
// Only exports go through here; a handful is all that's ever live.
if ( blobsByUrl.size > 8 ) {
blobsByUrl.delete( blobsByUrl.keys().next().value );
}
}
return url;
};
function saveBlobNatively( blob, filename, folder ) {
var suggested = filename || "export";
var extension = suggested.indexOf( "." ) !== -1 ? suggested.split( "." ).pop() : null;
// A full path puts the panel in a folder with the name filled in; a
// bare name leaves it wherever it was last, which for a report about
// this card is never the useful answer. Which folder depends on what
// is being saved: a log belongs with the files it describes, which is
// where they were written, not where they came from.
var into = folder || currentFolderPath;
var target = ( into && suggested.indexOf( "/" ) === -1 )
? into.replace( /\/+$/, "" ) + "/" + suggested
: suggested;
return invoke( "plugin:dialog|save", {
options: {
title: "Save export",
defaultPath: target,
filters: extension ? [ { name: extension.toUpperCase(), extensions: [ extension ] } ] : []
}
} ).then( function ( destination ) {
if ( ! destination ) {
return null;
}
// Same chunked writer the metadata editor uses, so a large PDF
// can't arrive as one enormous request.
var writable = new NativeWritable( destination, false, true );
return blob.arrayBuffer()
.then( function ( buffer ) { return writable.write( new Uint8Array( buffer ) ); } )
.then( function () { return writable.close(); } )
.then( function () { return destination; } );
} ).then( function ( destination ) {
if ( destination ) {
setStatus( "Saved to " + destination );
}
} ).catch( function ( err ) {
setStatus( String( err ), true );
} );
}
document.addEventListener( "click", function ( e ) {
var link = e.target && e.target.closest && e.target.closest( "a[download]" );
if ( ! link ) {
return;
}
var blob = blobsByUrl.get( link.href );
if ( ! blob ) {
return; // Not one of ours; leave it alone.
}
e.preventDefault();
e.stopPropagation();
saveBlobNatively( blob, link.getAttribute( "download" ) );
}, true );
/*
* PDF export doesn't take that route. jsPDF has its own downloader, which
* clicks a link it never puts in the document — and an event on a detached
* element bubbles nowhere, so the listener above never sees it. Hence
* wrapping the constructor: jsPDF copies its API onto each instance, so an
* instance property is the one override that reliably wins.
*/
if ( window.jspdf && typeof window.jspdf.jsPDF === "function" ) {
var RealJsPDF = window.jspdf.jsPDF;
var WrappedJsPDF = function ( options ) {
var doc = new RealJsPDF( options );
doc.save = function ( filename ) {
saveBlobNatively( this.output( "blob" ), filename || "report.pdf" );
return this;
};
return doc;
};
// Anything reaching for the statics (jsPDF.API, version) still finds them.
Object.keys( RealJsPDF ).forEach( function ( key ) {
WrappedJsPDF[ key ] = RealJsPDF[ key ];
} );
WrappedJsPDF.prototype = RealJsPDF.prototype;
window.jspdf.jsPDF = WrappedJsPDF;
}
/* ------------------------------------------------------------------ */
/* Drag and drop */
/* ------------------------------------------------------------------ */
var events = TAURI && TAURI.event;
if ( events && typeof events.listen === "function" ) {
var dropTargetOf = function () {
var app = currentApp();
return app && ( app.querySelector( "[data-bwfa-edit-entry]" ) ||
app.querySelector( "[data-bwfa-dropzone]" ) );
};
var setDragState = function ( active ) {
var target = dropTargetOf();
if ( target ) {
target.classList.toggle( "is-dragover", active );
}
};
events.listen( "tauri://drag-enter", function () { setDragState( true ); } );
events.listen( "tauri://drag-leave", function () { setDragState( false ); } );
/**
* A dropped folder opens exactly like a picked one: park the path, then
* click the app's own button. Everything after that — walking the
* folder, opening each file read-write, switching the table into edit
* mode — is the app's own code, not a second copy of it here.
*/
events.listen( "tauri://drag-drop", function ( event ) {
setDragState( false );
var paths = ( event && event.payload && event.payload.paths ) || [];
if ( ! paths.length ) {
return;
}
var dropped = String( paths[ 0 ] );
var editButton = currentApp() && currentApp().querySelector( "[data-bwfa-edit-folder]" );
if ( ! editButton ) {
return;
}
// Dropping a recording rather than its folder opens the folder it
// sits in — the alternative is opening a card one file at a time.
var isFile = /\.(wav|bwf|broadcastwave)$/i.test( dropped );
pendingFolder = isFile ? dropped.replace( /\/[^/]*$/, "" ) : dropped;
setStatus( ( window.bwfaL10n && window.bwfaL10n.statusScanning ) || "Scanning folder…" );
editButton.click();
} );
}
}() );