about summary refs log tree commit diff
path: root/users/Profpatsch/lyric/extension/src/extension.ts
import * as vscode from 'vscode';
import * as net from 'net';
import { adjustTimestampToEighthNote, bpmToEighthNoteDuration } from './quantize-lrc';
import { publishLyrics, PublishRequest } from './upload-lrc';

const channel_global = vscode.window.createOutputChannel('LRC');

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(...registerCheckLineTimestamp(context));
  context.subscriptions.push(
    vscode.commands.registerCommand('extension.jumpToLrcPosition', jumpToLrcPosition),
    vscode.commands.registerCommand('extension.shiftLyricsDown', shiftLyricsDown),
    vscode.commands.registerCommand('extension.shiftLyricsUp', shiftLyricsUp),
    vscode.commands.registerCommand('extension.tapBpm', tapBpm),
    vscode.commands.registerCommand('extension.quantizeToEigthNote', quantizeToEigthNote),
    vscode.commands.registerCommand(
      'extension.fineTuneTimestampDown100MsAndPlay',
      fineTuneTimestampAndPlay(-100),
    ),
    vscode.commands.registerCommand(
      'extension.fineTuneTimestampUp100MsAndPlay',
      fineTuneTimestampAndPlay(100),
    ),
    vscode.commands.registerCommand(
      'extension.uploadLyricsToLrclibDotNet',
      uploadToLrclibDotNet,
    ),
  );
}

/**
 * Jumps to the position in the lyric file corresponding to the current cursor position in the active text editor.
 * Sends a command to a socket to seek to the specified position in mpv at the socket path `~/tmp/mpv-socket`.
 * @remarks
 * This function requires the following dependencies:
 * - `vscode` module for accessing the active text editor and displaying messages.
 * - `net` module for creating a socket connection.
 * @throws {Error} If there is an error sending the command to the socket.
 */
function jumpToLrcPosition() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);
  const position = editor.selection.active;
  const res = ext.getTimestampFromLine(position.line);

  if (!res) {
    return;
  }
  const { milliseconds, seconds } = res;

  // Prepare JSON command to send to the socket
  const seekCommand = {
    command: ['seek', seconds, 'absolute'],
  };
  const reloadSubtitlesCommand = {
    command: ['sub-reload'],
  };

  const socket = new net.Socket();

  const socketPath = process.env.HOME + '/tmp/mpv-socket';
  socket.connect(socketPath, () => {
    socket.write(JSON.stringify(seekCommand));
    socket.write('\n');
    socket.write(JSON.stringify(reloadSubtitlesCommand));
    socket.write('\n');
    vscode.window.showInformationMessage(
      `Sent command to jump to [${formatTimestamp(milliseconds)}] and sync subtitles.`,
    );
    socket.end();
  });

  socket.on('error', err => {
    vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
  });
}

/**
 * Shifts the lyrics down by one line starting from the current cursor position in the active text editor.
 * @remarks
 * This function requires the following dependencies:
 * - `vscode` module for accessing the active text editor and displaying messages.
 */
async function shiftLyricsDown() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);

  const getLine = (line: number) => ({
    number: line,
    range: editor.document.lineAt(line),
  });

  // get the document range from the beginning of the current line to the end of the file
  const documentRange = new vscode.Range(
    getLine(editor.selection.active.line).range.range.start,
    editor.document.lineAt(editor.document.lineCount - 1).range.end,
  );

  let newLines: string = '';
  // iterate through all lines under the current line, save the lyric text from the current line, and replace it with the lyric text from the previous line
  let previousLineText = '';
  for (
    // get the current line range
    let line = getLine(editor.selection.active.line);
    line.number < editor.document.lineCount - 1;
    // next line as position from line number
    line = getLine(line.number + 1)
  ) {
    const timestamp = ext.getTimestampFromLine(line.number);
    if (timestamp === undefined) {
      newLines += line.range.text + '\n';
      continue;
    }
    newLines += `[${formatTimestamp(timestamp.milliseconds)}]` + previousLineText + '\n';
    previousLineText = timestamp.text;
  }
  // replace documentRange with newLines
  await editor.edit(editBuilder => {
    editBuilder.replace(documentRange, newLines);
  });
}

/**
 * Shifts the lyrics up by one line starting from the current cursor position in the active text editor.
 * @remarks
 * This function requires the following dependencies:
 * - `vscode` module for accessing the active text editor and displaying messages.
 */
async function shiftLyricsUp() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);

  const getLine = (line: number) => ({
    number: line,
    range: editor.document.lineAt(line),
  });

  // get the document range from the beginning of the current line to the end of the file
  const documentRange = new vscode.Range(
    getLine(editor.selection.active.line).range.range.start,
    editor.document.lineAt(editor.document.lineCount - 1).range.end,
  );

  let newLines: string = '';
  // iterate through all lines under the current line, save the lyric text from the current line, and replace it with the lyric text from the next line
  for (
    // get the current line range
    let line = getLine(editor.selection.active.line);
    line.number < editor.document.lineCount - 2;
    // next line as position from line number
    line = getLine(line.number + 1)
  ) {
    const nextLineText =
      ext.getTimestampFromLine(line.number + 1)?.text ??
      ext.document.lineAt(line.number + 1).text;
    const timestamp = ext.getTimestampFromLine(line.number);
    if (timestamp === undefined) {
      continue;
    }
    newLines += `[${formatTimestamp(timestamp.milliseconds)}]` + nextLineText + '\n';
  }
  // replace documentRange with newLines
  await editor.edit(editBuilder => {
    editBuilder.replace(documentRange, newLines);
  });
}

/**
 * Tap the BPM of the track and write it to the header of the active text editor.
 * @remarks
 * This function requires the following dependencies:
 * - `vscode` module for accessing the active text editor and displaying messages.
 */
async function tapBpm() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);
  const startBpm = ext.findBpmHeader();

  const bpm = await timeInputBpm(startBpm);

  if (bpm === undefined) {
    return;
  }

  await ext.writeHeader('bpm', bpm.toString());
}

/** first ask the user for the BPM of the track, then quantize the timestamps in the active text editor to the closest eighth note based on the given BPM */
async function quantizeToEigthNote() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);

  const startBpm = ext.findBpmHeader();
  const bpm = await timeInputBpm(startBpm);

  if (bpm === undefined) {
    return;
  }

  await ext.writeHeader('bpm', bpm.toString());

  const getLine = (line: number) => ({
    number: line,
    range: editor.document.lineAt(line),
  });

  const documentRange = new vscode.Range(
    getLine(0).range.range.start,
    editor.document.lineAt(editor.document.lineCount - 1).range.end,
  );

  const eighthNoteDuration = bpmToEighthNoteDuration(bpm);

  let newLines: string = '';
  for (
    let line = getLine(0);
    line.number < editor.document.lineCount - 1;
    line = getLine(line.number + 1)
  ) {
    const timestamp = ext.getTimestampFromLine(line.number);
    if (timestamp === undefined) {
      newLines += line.range.text + '\n';
      continue;
    }
    const adjustedMs = adjustTimestampToEighthNote(
      timestamp.milliseconds,
      eighthNoteDuration,
    );
    newLines += `[${formatTimestamp(adjustedMs)}]${timestamp.text}\n`;
  }

  await editor.edit(editBuilder => {
    editBuilder.replace(documentRange, newLines);
  });
}

/** fine tune the timestamp of the current line by the given amount (in milliseconds) and play the track at slightly before the new timestamp */
function fineTuneTimestampAndPlay(amountMs: number) {
  return async () => {
    const editor = vscode.window.activeTextEditor;

    if (!editor) {
      vscode.window.showErrorMessage('No active editor found.');
      return;
    }

    const ext = new Ext(editor.document);

    const position = editor.selection.active;
    const res = ext.getTimestampFromLine(position.line);

    if (!res) {
      return;
    }
    const { milliseconds } = res;

    const newMs = milliseconds + amountMs;

    // adjust the timestamp
    const documentRange = editor.document.lineAt(position.line).range;
    await editor.edit(editBuilder => {
      editBuilder.replace(documentRange, `[${formatTimestamp(newMs)}]${res.text}`);
    });

    const PLAY_BEFORE_TIMESTAMP_MS = 2000;
    const seekCommand = {
      command: ['seek', (newMs - PLAY_BEFORE_TIMESTAMP_MS) / 1000, 'absolute'],
    };
    const reloadSubtitlesCommand = {
      command: ['sub-reload'],
    };

    const socket = new net.Socket();

    const socketPath = process.env.HOME + '/tmp/mpv-socket';
    socket.connect(socketPath, () => {
      socket.write(JSON.stringify(seekCommand));
      socket.write('\n');
      socket.write(JSON.stringify(reloadSubtitlesCommand));
      socket.write('\n');
      vscode.window.showInformationMessage(
        `Sent command to jump to [${formatTimestamp(newMs)}] and sync subtitles.`,
      );
      socket.end();
    });

    socket.on('error', err => {
      vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
    });
  };
}

// convert the given bpm to miliseconds
function bpmToMs(bpm: number) {
  return Math.floor((60 / bpm) * 1000);
}

// Show input boxes in a loop, and record the time between each input, averaging the last 5 inputs over a sliding window, then calculate the BPM of the average
async function timeInputBpm(startBpm?: number) {
  const startBpmMs = bpmToMs(startBpm ?? 120);
  const timeDifferences: number[] = [
    startBpmMs,
    startBpmMs,
    startBpmMs,
    startBpmMs,
    startBpmMs,
  ];
  // assign a weight to the time differences, so that the most recent time differences have more weight
  const weights = [0.1, 0.1, 0.2, 0.3, 0.3];

  const calculateBPM = () => {
    // use a weighted average here
    let avg = 0;
    for (let i = 0; i < timeDifferences.length; i++) {
      avg += timeDifferences[i] * weights[i];
    }

    return Math.floor(60000 / avg);
  };

  let lastPressTime = Date.now();
  let firstLoop = true;
  while (true) {
    const res = await vscode.window.showInputBox({
      prompt: `Press enter to record BPM (current BPM: ${calculateBPM()}), enter the final BPM once you know, or press esc to finish`,
      placeHolder: 'BPM',
      value: startBpm !== undefined && firstLoop ? startBpm.toString() : undefined,
    });
    firstLoop = false;
    if (res === undefined) {
      return undefined;
    }
    if (res !== '') {
      const resBpm = parseInt(res, 10);
      if (isNaN(resBpm)) {
        vscode.window.showErrorMessage('Invalid BPM');
        continue;
      }
      return resBpm;
    }

    const now = Date.now();
    const timeDiff = now - lastPressTime;
    // Add the time difference to the array (limit to last 5 key presses)
    timeDifferences.shift(); // Remove the oldest time difference
    timeDifferences.push(timeDiff);

    lastPressTime = now;
  }
}

/**
 * Uploads the lyrics in the active text editor to the LrclibDotNet API.
 * @remarks
 * This function requires the following dependencies:
 * - `vscode` module for accessing the active text editor and displaying messages.
 * - `fetch` module for making HTTP requests.
 * @throws {Error} If there is an HTTP error.
 */
async function uploadToLrclibDotNet() {
  const editor = vscode.window.activeTextEditor;

  if (!editor) {
    vscode.window.showErrorMessage('No active editor found.');
    return;
  }

  const ext = new Ext(editor.document);

  const title = ext.findHeader('ti')?.value;
  const artist = ext.findHeader('ar')?.value;
  const album = ext.findHeader('al')?.value;
  const lengthString = ext.findHeader('length')?.value;

  if (
    title === undefined ||
    artist === undefined ||
    album === undefined ||
    lengthString === undefined
  ) {
    vscode.window.showErrorMessage(
      'Missing required headers: title, artist, album, length',
    );
    return;
  }
  // parse length as mm:ss
  const [minutes, seconds] = lengthString?.split(':') ?? [];
  if (
    !minutes ||
    !seconds ||
    isNaN(parseInt(minutes, 10)) ||
    isNaN(parseInt(seconds, 10))
  ) {
    vscode.window.showErrorMessage('Invalid length header, expected format: mm:ss');
    return;
  }
  const length = parseInt(minutes, 10) * 60 + parseInt(seconds, 10);

  const syncedLyrics = ext.getLyricsPart();
  const plainLyrics = plainLyricsFromLyrics(syncedLyrics);

  // open a html preview with the lyrics saying
  //
  // Uploading these lyrics to lrclib.net:
  // <metadata as table>
  // Lyrics:
  // ```<lyrics>```
  // Plain lyrics:
  // ```<plainLyrics>```
  //
  // Is this ok?
  // <button to upload>

  const previewTitle = 'Lyric Preview';
  const metadataTable = `
  <table>
    <tr>
      <th>Title</th>
      <td>${title}</td>
    </tr>
    <tr>
      <th>Artist</th>
      <td>${artist}</td>
    </tr>
    <tr>
      <th>Album</th>
      <td>${album}</td>
    </tr>
    <tr>
      <th>Length</th>
      <td>${lengthString}</td>
    </tr>
  </table>
  `;
  const previewContent = `
    <p>Uploading these lyrics to lrclib.net:</p>
    ${metadataTable}
    <p>Lyrics:</p>
    <pre>${syncedLyrics}</pre>
    <p>Plain lyrics:</p>
    <pre>${plainLyrics}</pre>
    <p>Is this ok?</p>
    <button>Upload</button>
  `;

  const panel = vscode.window.createWebviewPanel(
    'lyricPreview',
    previewTitle,
    vscode.ViewColumn.One,
    { enableScripts: true },
  );
  panel.webview.html = `
      <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Markdown Preview</title>
    </head>
    <body>
      ${previewContent}
    </body>
    <script>
      const vscode = acquireVsCodeApi();
      document.querySelector('button').addEventListener('click', () => {
        vscode.postMessage({ command: 'upload' });
      });
    </script>
    </html>
  `;
  let isDisposed = false;
  panel.onDidDispose(() => {
    isDisposed = true;
  });

  await new Promise((resolve, _reject) => {
    panel.webview.onDidReceiveMessage((message: { command: string }) => {
      if (isDisposed) {
        return;
      }
      if (message.command === 'upload') {
        panel.dispose();
        resolve(true);
      }
    });
  });

  const toUpload: PublishRequest = {
    trackName: title,
    artistName: artist,
    albumName: album,
    duration: length,
    plainLyrics: plainLyrics,
    syncedLyrics: syncedLyrics,
  };

  // log the data to our extension output buffer
  channel_global.appendLine('Uploading lyrics to LrclibDotNet');
  const json = JSON.stringify(toUpload, null, 2);
  channel_global.appendLine(json);

  const res = await publishLyrics(toUpload);

  if (res) {
    vscode.window.showInformationMessage('Lyrics successfully uploaded.');
    channel_global.appendLine('Lyrics successfully uploaded.');
  } else {
    vscode.window.showErrorMessage('Failed to upload lyrics.');
    channel_global.appendLine('Failed to upload lyrics.');
  }
}

// If the difference to the timestamp on the next line is larger than 10 seconds (for 120 BPM), underline the next line and show a warning message on hover
export function registerCheckLineTimestamp(_context: vscode.ExtensionContext) {
  const changesToCheck: Set<vscode.TextDocument> = new Set();
  const everSeen = new Set<vscode.TextDocument>();

  return [
    vscode.workspace.onDidChangeTextDocument(e => {
      changesToCheck.add(e.document);
      if (vscode.window.activeTextEditor?.document === e.document) {
        doEditorChecks(vscode.window.activeTextEditor.document, everSeen, changesToCheck);
      }
    }),
    vscode.workspace.onDidOpenTextDocument(e => {
      changesToCheck.add(e);
      everSeen.add(e);
      if (vscode.window.activeTextEditor?.document === e) {
        doEditorChecks(vscode.window.activeTextEditor.document, everSeen, changesToCheck);
      }
    }),

    vscode.window.onDidChangeActiveTextEditor(editor => {
      if (editor) {
        doEditorChecks(editor.document, everSeen, changesToCheck);
      }
    }),
    vscode.window.onDidChangeVisibleTextEditors(editors => {
      for (const editor of editors) {
        doEditorChecks(editor.document, everSeen, changesToCheck);
      }
    }),
  ];
}

function doEditorChecks(
  document: vscode.TextDocument,
  everSeen: Set<vscode.TextDocument>,
  changesToCheck: Set<vscode.TextDocument>,
) {
  const ext = new Ext(document);

  if (!everSeen.has(document)) {
    changesToCheck.add(document);
    everSeen.add(document);
  }

  if (!changesToCheck.has(document)) {
    return;
  }
  changesToCheck.delete(document);

  const from = 0;
  const to = document.lineCount - 1;
  for (let line = from; line <= to; line++) {
    const warnings: string[] = [];
    const timeDiff = timeDifferenceTooLarge(ext, line);
    if (timeDiff !== undefined) {
      warnings.push(timeDiff);
    }
    const nextTimestampSmaller = nextLineTimestampSmallerThanCurrent(ext, line);
    if (nextTimestampSmaller !== undefined) {
      warnings.push(nextTimestampSmaller);
    }
    for (const warning of warnings) {
      global_manageWarnings.setWarning(document, line, warning);
    }
    // unset any warnings if this doesn’t apply anymore
    if (warnings.length === 0) {
      global_manageWarnings.setWarning(document, line);
    }
  }
}

/** Warn if the difference to the timestamp on the next line is larger than
 * * 10 seconds at 120 BPM
 * * 5 seconds at 240 BPM
 * * 20 seconds at 60 BPM
 * * etc
 */
function timeDifferenceTooLarge(ext: Ext, line: number): string | undefined {
  const bpm = ext.findBpmHeader() ?? 120;
  const maxTimeDifference = 10000 * (120 / bpm);
  const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
    new vscode.Position(line, 0),
  );
  if (
    !timeDifference ||
    timeDifference.thisLineIsEmpty ||
    timeDifference.difference <= maxTimeDifference
  ) {
    return;
  }
  return `Time difference to next line is ${formatTimestamp(
    timeDifference.difference,
  )}, should there be silence here? At ${bpm} BPM, we assume anything more than ${(
    maxTimeDifference / 1000
  ).toFixed(2)} seconds is a mistake.`;
}

/** Warn if the timestamp on the next line is smaller or equal to the current timestamp */
function nextLineTimestampSmallerThanCurrent(ext: Ext, line: number): string | undefined {
  const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
    new vscode.Position(line, 0),
  );
  if (!timeDifference) {
    return;
  }
  if (timeDifference.difference == 0) {
    return `The timestamp to the next line is not increasing`;
  }
  if (timeDifference.difference < 0) {
    return `The timestamp to the next line is decreasing`;
  }
}

class Ext {
  constructor(public document: vscode.TextDocument) {}

  getTimeDifferenceToNextLineTimestamp(position: vscode.Position) {
    const thisLineTimestamp = this.getTimestampFromLine(position.line);
    const nextLineTimestamp = this.getTimestampFromLine(position.line + 1);
    if (!thisLineTimestamp || !nextLineTimestamp) {
      return;
    }
    return {
      difference: nextLineTimestamp.milliseconds - thisLineTimestamp.milliseconds,
      thisLineIsEmpty: thisLineTimestamp.text.trim() === '',
    };
  }

  /**
   * Retrieves the timestamp and text from the line at the given position in the active text editor.
   *
   * @param position - The position of the line in the editor.
   * @returns An object containing the milliseconds, seconds, and text extracted from the line.
   */
  getTimestampFromLine(line: number) {
    const lineText = this.document.lineAt(line).text;
    return this.getTimestampFromLineText(lineText);
  }

  getTimestampFromLineText(lineText: string) {
    // Extract timestamp [mm:ss.ms] from the current line
    const match = lineText.match(/\[(\d+:\d+\.\d+)\](.*)/);
    if (!match) {
      return;
    }
    const [, timestamp, text] = match;
    const milliseconds = parseTimestamp(timestamp);
    const seconds = milliseconds / 1000;
    return { milliseconds, seconds, text };
  }

  // Find a header line of the format
  // [header:value]
  // at the beginning of the lrc file (before the first empty line)
  findHeader(headerName: string) {
    for (let line = 0; line < this.document.lineCount; line++) {
      const text = this.document.lineAt(line).text;
      if (text.trim() === '') {
        return;
      }
      const match = text.match(/^\[(\w+):(.*)\]$/);
      if (match && match[1] === headerName) {
        return { key: match[1], value: match[2], line: line };
      }
    }
  }

  /** Find the bpm header and return the bpm as number, if any */
  findBpmHeader() {
    const startBpmStr = this.findHeader('bpm')?.value;
    let bpm;
    if (startBpmStr !== undefined) {
      bpm = parseInt(startBpmStr, 10);
      if (isNaN(bpm)) {
        bpm = undefined;
      }
    }
    return bpm;
  }

  // check if the given line is a header line
  isHeaderLine(line: string) {
    return (
      line.trim() !== '' &&
      line.match(/^\[(\w+):(.*)\]$/) !== null &&
      line.match(/^\[\d\d:\d\d.\d+\]/) === null
    );
  }

  // write the given header to the lrc file, if the header already exists, update the value
  async writeHeader(headerName: string, value: string) {
    const header = this.findHeader(headerName);
    const editor = findActiveEditor(this.document);
    if (!editor) {
      return;
    }
    if (header) {
      const lineRange = this.document.lineAt(header.line).range;
      await editor.edit(editBuilder => {
        editBuilder.replace(lineRange, `[${headerName}: ${value}]`);
      });
    } else {
      // insert before the first timestamp line if no header is found, or after the last header if there are multiple headers
      let insertLine = 0;
      let extraNewline = '';
      for (let line = 0; line < this.document.lineCount; line++) {
        const text = this.document.lineAt(line).text;
        // check if header
        if (this.isHeaderLine(text)) {
          insertLine = line + 1;
        } else if (text.trim() === '') {
          insertLine = line;
          break;
        } else {
          insertLine = line;
          if (line == 0) {
            extraNewline = '\n';
          }
          break;
        }
      }
      await editor.edit(editBuilder => {
        editBuilder.insert(
          new vscode.Position(insertLine, 0),
          `[${headerName}: ${value}]\n${extraNewline}`,
        );
      });
    }
  }

  // get the lyrics part of the lrc file (after the headers)
  getLyricsPart() {
    const first = this.document.lineAt(0).text;
    let line = 0;
    if (this.isHeaderLine(first)) {
      // skip all headers (until the first empty line)
      for (; line < this.document.lineCount; line++) {
        const text = this.document.lineAt(line).text;
        if (text.trim() === '') {
          line++;
          break;
        }
      }
    }

    // get the range from the current line to the end of the file
    const documentRange = new vscode.Range(
      new vscode.Position(line, 0),
      this.document.lineAt(this.document.lineCount - 1).range.end,
    );
    return this.document.getText(documentRange);
  }
}

// find an active editor that has the given document opened
function findActiveEditor(document: vscode.TextDocument) {
  return vscode.window.visibleTextEditors.find(editor => editor.document === document);
}

function plainLyricsFromLyrics(lyrics: string) {
  // remove the timestamp from the beginning of every line
  return (
    lyrics
      .replace(/\[\d\d:\d\d\.\d\d\]\s?/gm, '')
      // remove empty lines
      .replace(/\n{2,}/g, '\n')
  );
}

function parseTimestamp(timestamp: string): number {
  // Parse [mm:ss.ms] format into milliseconds
  const [min, sec] = timestamp.split(':');

  const minutes = parseInt(min, 10);
  const seconds = parseFloat(sec);

  return minutes * 60 * 1000 + seconds * 1000;
}

function formatTimestamp(ms: number): string {
  // Format milliseconds back into [mm:ss.ms]
  const minutes = Math.floor(ms / 60000);
  ms %= 60000;
  const seconds = (ms / 1000).toFixed(2);

  return `${String(minutes).padStart(2, '0')}:${seconds.padStart(5, '0')}`;
}

class ManageWarnings {
  private warnings: Map<number, string> = new Map();
  private diagnostics: vscode.DiagnosticCollection;

  constructor() {
    this.diagnostics = vscode.languages.createDiagnosticCollection();
  }

  /** Set a warning message on a line in a document, if null then unset */
  setWarning(document: vscode.TextDocument, line: number, message?: string) {
    if (message !== undefined) {
      this.warnings.set(line, message);
    } else {
      this.warnings.delete(line);
    }
    this.updateDiagnostics(document);
  }

  private updateDiagnostics(document: vscode.TextDocument) {
    const mkWarning = (line: number, message: string) => {
      const lineRange = document.lineAt(line).range;
      return new vscode.Diagnostic(lineRange, message, vscode.DiagnosticSeverity.Warning);
    };
    const diagnostics: vscode.Diagnostic[] = [];
    for (const [line, message] of this.warnings) {
      diagnostics.push(mkWarning(line, message));
    }
    this.diagnostics.delete(document.uri);
    this.diagnostics.set(document.uri, diagnostics);
  }
}

const global_manageWarnings = new ManageWarnings();