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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Export a track to Guitar Pro (.gp5).** A new **File ▸ Export ▸ Guitar Pro
(.gp5)** item downloads the current fretted track as a `.gp5` file you can open
in Guitar Pro (or any tab tool that reads GP5) — real interop *out* of the
editor for the first time. It converts the saved pack through the Tab View
plugin, the same conversion the read-only Tab preview already engraves, and
hands you the bytes as a download named after the song and track. Because the
converter reads what's on disk, exporting mid-edit offers the usual Save /
Don't Save / Cancel prompt first — the file you take away is never silently a
stale pack. Fretted tracks only (keys/drums have no tab); if the Tab View
plugin isn't installed or the song isn't saved yet, the status line says so.
- **Techniques now show in the Piano-roll view.** Switching from String view to
the roll used to hide every technique — bends, slides, mutes, hammer-ons and
the rest were all invisible on roll notes. They're drawn now: a slide gets its
Expand Down
115 changes: 115 additions & 0 deletions src/gp5-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// File ▸ Export ▸ Guitar Pro (.gp5). Downloads the GP5 bytes the Tab View
// plugin converts the current fretted part's SAVED pack into — the very bytes
// the read-only Tab preview engraves (src/tab-preview.js). The conversion
// endpoint (_tabPreviewUrlPure) lives in tab-preview.js and is reused here, so
// the tabview contract is written once; export adds only the browser download,
// plus a fretted/saved guard, the session's own dirty prompt (the preview is a
// lens labelled "as last saved" — an export is a FILE you take away, so it may
// not silently be stale) and status messages worded for a "save it out" action.
//
// Pure, decision-shaped helpers stay inside the @pure:gp5-export block (the JS
// test suite slices that block into a `new Function`); the download seam and the
// async orchestrator reach the DOM/network and are live-verified.

import { S } from './state.js';
import { setStatus } from './ui.js';
import { guardSessionTransition } from './session-lifecycle.js';
import { _tabPreviewUrlPure } from './tab-preview.js';

/* @pure:gp5-export:start */
// Which parts can export, with the exact user-facing reason when one can't.
// Mirrors the Tab preview guard: fretted-only (keys/drums pack as pitch, not
// string·fret, so a GP conversion would engrave nonsense), and a SAVED pack is
// required because the converter reads the last-saved pack. Regexes inlined so
// this @pure block stays self-contained and slice-testable (the tab-preview
// block inlines the same fretted test for the same reason).
function _gp5ExportGuardPure(filename, arrName, hasArrangements) {
if (!hasArrangements) return { ok: false, reason: 'Load a song first.' };
const nm = String(arrName || '');
if (/^(keys|piano|keyboard|synth)/i.test(nm) || /^drums/i.test(nm)) {
return { ok: false, reason: 'Guitar Pro export is for fretted tracks — keys and drums tracks have no tab.' };
}
if (!filename) {
return { ok: false, reason: 'Save the song first — export reads the saved pack.' };
}
return { ok: true, reason: '' };
}
// The download filename: the song's base name (pack extension dropped) + the
// part name, sanitised so it can't break a download across OSes.
function _gp5ExportNamePure(filename, partName) {
let base = String(filename || '').replace(/\.(feedpak|sloppak)$/i, '').trim();
if (!base) base = 'track';
const part = String(partName || '').trim();
let name = part ? base + ' — ' + part : base;
// Drop the characters that are illegal in a filename on Windows/macOS/Linux.
name = name.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim();
return name + '.gp5';
}
// Map a failed conversion response to the honest, export-worded message.
function _gp5ExportHttpMessagePure(status, bodyText) {
if (status === 404) {
return 'Export needs the Tab View plugin installed — or the song has no saved pack yet (Save/Build first).';
}
if (status === 501) {
return 'The host is too old for Guitar Pro export — update feedBack.';
}
const body = String(bodyText || '').slice(0, 140);
return 'Export failed (' + status + ')' + (body ? ': ' + body : '');
}
/* @pure:gp5-export:end */
export { _gp5ExportGuardPure, _gp5ExportNamePure, _gp5ExportHttpMessagePure };

// Trigger a browser download of `bytes` as `name`. A tiny DOM seam — all the
// testable logic lives in the pure helpers above. The object URL is revoked on
// the next tick so the click has grabbed the blob first (revoking synchronously
// cancels the download in some browsers); it is a fire-once transient, not a
// registered timer, so it doesn't leak across screen re-injection.
function _downloadBytes(bytes, name) {
const blob = new Blob([bytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}

export async function editorExportGp5() {
const cur = () => (S.arrangements.length ? S.arrangements[S.currentArr] : null);
let arr = cur();
let guard = _gp5ExportGuardPure(S.filename, arr && arr.name, !!S.arrangements.length);
if (!guard.ok) { setStatus(guard.reason); return; }
// The converter reads the SAVED pack and indexes it by S.currentArr — and
// it CLAMPS that index into the saved track list. So an unsaved session
// doesn't just export stale notes: add or reorder a track and the clamp
// hands you a DIFFERENT track's bytes under the requested track's name.
// Offer the session's own Save / Don't Save / Cancel prompt first.
if (!(await guardSessionTransition('exporting to Guitar Pro'))) {
setStatus('Export cancelled.');
return;
}
// The prompt awaited: the song (and the current part) may have moved.
arr = cur();
guard = _gp5ExportGuardPure(S.filename, arr && arr.name, !!S.arrangements.length);
if (!guard.ok) { setStatus(guard.reason); return; }
Comment on lines +89 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not continue after “Don’t Save” for this export.

guardSessionTransition returns true for both discard and successful save. The export can therefore use unsaved S metadata/current index while the converter reads the saved pack, exporting stale or even another track’s bytes under the current filename. Expose the transition choice and require save, or cancel export after discard.

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 89-89: React's useState should not be directly called
Context: setStatus('Export cancelled.')
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[error] 95-95: React's useState should not be directly called
Context: setStatus(guard.reason)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gp5-export.js` around lines 89 - 96, Update the export flow around
guardSessionTransition so it exposes the transition choice and continues only
when the result is save; treat discard as export cancellation. Ensure arr,
guard, and subsequent conversion logic execute only after a successful save,
preserving the existing cancellation status behavior.

const name = _gp5ExportNamePure(S.filename, arr && arr.name);
setStatus('Exporting ' + name + '…');
try {
// Cache-bust with the timestamp so an export right after a Save always
// re-converts the latest pack (same reason the preview does).
const resp = await fetch(_tabPreviewUrlPure(S.filename, S.currentArr, Date.now()));
if (!resp.ok) {
let body = '';
try { body = await resp.text(); } catch (_) { /* keep '' */ }
setStatus(_gp5ExportHttpMessagePure(resp.status, body));
return;
}
const buf = await resp.arrayBuffer();
_downloadBytes(new Uint8Array(buf), name);
setStatus('Exported ' + name);
} catch (e) {
setStatus('Export failed: ' + (e && e.message ? e.message : e));
}
}
2 changes: 2 additions & 0 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { _editorCommandById, _editorEffectiveRightClickBehaviorPure, _editorEofC
import { SNAP_VALUES, _editorEffectiveSnapValuePure, _editorSnapSubdivisionsPure } from './snap.js';
import { S } from './state.js';
import { _editorShowTabPreview, _tabPreviewKeyPolicyPure } from './tab-preview.js';
import { editorExportGp5 } from './gp5-export.js';
import { TempoGridCmd, _editorModulateTempoAtSelection, _editorTapTempoAtSelection, _editorToggleSyncLock, _editorToggleTempoMapMode, _tapTempoHandleKey, _tempoDeleteSelection, _tempoInsertSyncPoint, _tempoMapOnContextMenu, _tempoMeasureBeatCount, _tempoMeasureDenominator, _tempoPromptMeasureBpm, _tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure, _tempoPromptPickup, _tempoSelRangePure } from './tempo.js';
import { _tourNoteAction } from './tour.js';
import { _signpostNote } from './signposts.js';
Expand Down Expand Up @@ -833,6 +834,7 @@ export function _editorRunEofCommand(cmd) {
case 'importMidi': _editorOpenImportMidi(); return true;
case 'importXml': _editorOpenImportXml(); return true;
case 'importGp': _editorOpenImportGp(); return true;
case 'exportGp5': editorExportGp5(); return true;
case 'prevBeat': _editorJumpBeat(-1); return true;
case 'nextBeat': _editorJumpBeat(+1); return true;
case 'prevNote': _editorJumpNote(-1); return true;
Expand Down
4 changes: 4 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
_editorShowTabPreview, editorHideTabPreview,
editorRefreshTabPreview
} from './tab-preview.js';
import { editorExportGp5 } from './gp5-export.js';
import {
_editorCurrentNoteIndices, _editorSeekToTime, _editorSnapStepSeconds,
editorRunShortcutCommand, editorToggleShortcutPanel, onContextMenu, onKeyDown
Expand Down Expand Up @@ -560,6 +561,9 @@
// Tab preview (tab-preview.js owns the logic; HTML calls these by name).
window.editorShowTabPreview = _editorShowTabPreview;
window.editorRefreshTabPreview = editorRefreshTabPreview;
// Guitar Pro export (gp5-export.js) — the File ▸ Export menu cmd dispatches
// through the command runner; expose on window too for parity/scripted access.
window.editorExportGp5 = editorExportGp5;

// Input layer (input.js owns the keyboard/command/shortcut-panel logic).
// Entry tours (workspace-shell C3): Help ▸ Editor tour + the card's buttons.
Expand Down Expand Up @@ -1698,7 +1702,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1705 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
3 changes: 3 additions & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'importMidi' },
{ cmd: 'importXml' },
{ sep: true },
{ hdr: 'Export' },
{ cmd: 'exportGp5' },
{ sep: true },
{ label: 'Replace audio…', fn: 'editorShowReplaceAudioModal' },
{ label: 'Build feedpak', fn: 'editorBuild' },
] },
Expand Down
1 change: 1 addition & 0 deletions src/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'importMidi', label: 'Import MIDI / keys', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F6' } },
{ id: 'importXml', label: 'Import XML source', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F7' } },
{ id: 'importGp', label: 'Import Guitar Pro source', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F12' } },
{ id: 'exportGp5', label: 'Export track as Guitar Pro (.gp5)', group: 'File', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'prevBeat', label: 'Jump to previous beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Up', eof: 'Page Up' } },
{ id: 'nextBeat', label: 'Jump to next beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Down', eof: 'Page Down' } },
{ id: 'prevNote', label: 'Jump to previous note', group: 'Timeline', status: 'ready', keys: { feedback: 'Alt+Left', eof: 'Shift+Page Up' } },
Expand Down
5 changes: 4 additions & 1 deletion src/tab-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ function _tabPreviewHttpMessagePure(status, bodyText) {
return 'Preview failed (' + status + ')' + (body ? ': ' + body : '');
}
/* @pure:tab-preview:end */
export { _tabPreviewKeyPolicyPure };
// _tabPreviewUrlPure is the tabview-plugin GP5 conversion endpoint — the shared
// contract. File ▸ Export ▸ Guitar Pro (src/gp5-export.js) downloads the bytes
// from the same URL, so the endpoint format lives here only, never duplicated.
export { _tabPreviewKeyPolicyPure, _tabPreviewUrlPure };

// Same pinned version + memoized loader idiom as the Tab View plugin —
// pinning insulates the preview from CDN latest-tag churn (V12: alphaTab
Expand Down
Loading
Loading