Optimize work experience at Microsoft

Optimize work experience at Microsoft!

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Optimize work experience at Microsoft
// @namespace    https://www.microsoft.com/
// @version      1.0.3
// @description  Optimize work experience at Microsoft!
// @author       Guosen Wang
// @match        https://ms.portal.azure.com/*
// @match        https://m365pulse.microsoft.com/*
// @match        https://seval.microsoft.com/*
// @match        https://seval-staging.microsoft.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=microsoft.com
// @run-at       document-start
// @grant        none
// ==/UserScript==
(function () {
  'use strict';
  const host = location.host;

  switch (true) {
    case 'ms.portal.azure.com' === host:
      azure();
      break;
    case 'm365pulse.microsoft.com' === host:
      m365pulse();
      break;
    case 'seval.microsoft.com' === host:
    case 'seval-staging.microsoft.com' === host:
      seval();
      break;
  }
})();

/**
 * General DOM handling function
 * @param {Array} targets - Array of targets to process, each object includes:
 *   - selector: CSS selector
 *   - text: text to match (optional)
 *   - action: function to modify the element
 * @param {Object} options - Configuration options:
 *   - timeout: timeout in milliseconds (default 10000, ignored in continuous mode)
 *   - continuousMode: whether to run in continuous monitoring mode (default false)
 */
function handleDOMTargets(targets, options = {}) {
  const { timeout = 10000, continuousMode = false } = options;
  const processed = Array(targets.length).fill(false);
  let observer = null;

  function checkAndProcess() {
    targets.forEach((target, index) => {
      if (!continuousMode && processed[index]) return;

      const element = document.querySelector(target.selector);
      if (!element) return;

      // Check text match if specified
      if (target.text && element.innerText !== target.text) return;

      // Execute action function
      if (target.action) {
        target.action(element);
      }

      if (!continuousMode) {
        processed[index] = true;
      }
    });

    // Disconnect observer if all targets have been processed (only in non-continuous mode)
    if (!continuousMode && processed.every(Boolean) && observer) {
      observer.disconnect();
      observer = null;
      clearTimeout(timeoutId);
    }
  }

  // Run initial check immediately
  checkAndProcess();

  // If there are unprocessed targets or in continuous mode, create an observer
  if (!processed.every(Boolean) || continuousMode) {
    function startObserver() {
      if (!document.body) {
        requestAnimationFrame(startObserver);
        return;
      }

      observer = new MutationObserver(checkAndProcess);
      observer.observe(document.body, { childList: true, subtree: true });

      if (!continuousMode) {
        const timeoutId = setTimeout(() => {
          observer?.disconnect();
          observer = null;
        }, timeout);
      }
    }

    startObserver();
  }
}

/**
 * Function to remove feedback card and "New Version" link from M365 Pulse
 */
function m365pulse() {
  handleDOMTargets([
    {
      selector: 'a.right:nth-child(1)',
      text: 'New Version',
      action: element => element.remove()
    },
    {
      selector: 'div[class^="feedback-"]',
      action: element => element.parentElement?.remove()
    }
  ], { timeout: 10000 });
}

/**
 * Function to optimize Azure portal
 */
function azure() {
  handleDOMTargets([
    {
      selector: '#_weave_e_6',
      action: element => element.remove()
    },
    {
      selector: '#_weave_e_5 > div.fxs-topbar-internal.fxs-internal-full',
      action: element => {
        element.innerText = element.innerText.replace(' (Preview)', '');
      }
    }
  ], { timeout: 5000 });
}

/**
 * Function to modify the side pane close button appearance using continuous monitoring
 */
function seval() {
  handleDOMTargets([
    {
      selector: '[data-testid="side-pane"] [aria-label="Back Button"]',
      action: element => {
        // Only on detail pages (/detail/*) and job pages (/job/*).
        if (!/^\/detail\//.test(location.pathname) &&
            !/^\/job\//.test(location.pathname)) return;

        // Apply style transformation
        element.style = 'transform:translateY(1px)';

        // Modify the SVG path
        const pathElement = element.querySelector('svg:nth-child(2) > path');
        if (pathElement) {
          pathElement.setAttribute('d', 'M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z');
        }

        // Add keyboard listener for ESC key when button exists
        if (!element.dataset.escListenerAdded) {
          document.addEventListener('keydown', event => {
            if (event.key === 'Escape') {
              element.click();
            }
          });
          element.dataset.escListenerAdded = 'true';
        }
      }
    },
    {
      // Syntax-highlight the JSON shown in the settings dialog and add a copy button
      selector: '.fui-DialogContent',
      action: highlightSevalJsonDialog
    }
  ], { continuousMode: true }); // No timeout needed for continuous monitoring
}

/**
 * Replace the plain-text JSON in a SEVAL settings dialog with a CodeMirror-like
 * viewer: line numbers, syntax highlighting and a top-right "Copy" button.
 *
 * The viewer lives inside a Shadow DOM so the page's (per-page-varying) CSS
 * cannot leak in and break it, and the host fills the content area (width:100%).
 * @param {HTMLElement} content - The .fui-DialogContent element
 */
function highlightSevalJsonDialog(content) {
  // Only on the jobs list page (/jobs) and the scheduler list pages
  // (/scheduler/list/*).
  if (!/^\/jobs(\/|$)/.test(location.pathname) &&
      !/^\/scheduler\/list(\/|$)/.test(location.pathname)) return;

  // Already processed and still rendered → nothing to do (also breaks the
  // MutationObserver feedback loop since our own DOM edits trigger it again).
  if (content.querySelector(':scope > .gw-json-host')) return;

  // The dialog renders each JSON line as a direct child <span>. Anything else
  // (e.g. the CodeMirror-based dialog) should be left untouched.
  const lineSpans = content.querySelectorAll(':scope > span');
  if (lineSpans.length === 0) return;

  // Reconstruct the raw JSON from the line spans. textContent decodes the
  // HTML entities (e.g. &amp; → &) back into the original JSON text.
  const raw = Array.from(lineSpans, span => span.textContent).join('\n');

  // Only act on actual JSON; normalize formatting when it parses cleanly.
  let pretty;
  try {
    pretty = JSON.stringify(JSON.parse(raw), null, 2);
  } catch (e) {
    return;
  }

  // Widen the dialog so the JSON viewer has more room.
  const surface = content.closest('.fui-DialogSurface');
  if (surface) {
    surface.style.maxWidth = '1500px';
  }

  const host = document.createElement('div');
  host.className = 'gw-json-host';
  host.style.display = 'block';
  // Fill the dialog content area so the viewer is full-width and centered.
  host.style.width = '100%';

  // Isolate everything in a shadow root: page CSS can't reach in, so the result
  // looks identical regardless of which SEVAL page injected which styles.
  const shadow = host.attachShadow({ mode: 'open' });
  shadow.append(buildJsonViewer(pretty));

  lineSpans.forEach(span => span.remove());
  content.appendChild(host);
}

/**
 * Build the JSON viewer element: a bordered panel with a sticky line-number
 * gutter, syntax-highlighted code and a floating "Copy" button.
 *
 * Everything is styled with inline (CSSOM) styles rather than a <style> element,
 * because SEVAL's CSP uses style nonces and would block a nonce-less stylesheet.
 * Inline element.style.* assignments are not subject to CSP style-src, and no
 * innerHTML is used so it is also safe under Trusted Types.
 * @param {string} json - A JSON string (already pretty-printed)
 * @returns {HTMLElement}
 */
function buildJsonViewer(json) {
  const lines = json.split('\n');
  const lastIndex = lines.length - 1;
  // Fixed gutter width so 1- and 2-digit line numbers keep the code aligned.
  const gutterWidth = `calc(${String(lines.length).length}ch + 22px)`;

  const viewer = document.createElement('div');
  Object.assign(viewer.style, {
    position: 'relative',
    boxSizing: 'border-box',
    width: '100%',
    border: '1px solid #e0e0e0',
    borderRadius: '6px',
    background: '#fff',
    overflow: 'hidden'
  });
  viewer.appendChild(buildCopyButton(json));

  const scroller = document.createElement('div');
  Object.assign(scroller.style, {
    overflow: 'auto',
    maxHeight: '65vh',
    fontFamily: "Consolas, 'Courier New', monospace",
    fontSize: '14px',
    lineHeight: '1.5',
    color: '#000'
  });

  // One flex row per line: [sticky line-number cell][code cell]. Because the
  // grey background lives on each row's number cell, the gutter is always full
  // height (vertical scroll) and stays pinned left (horizontal scroll) — a
  // single sticky gutter element instead loses its background height in a
  // scroll container.
  lines.forEach((line, index) => {
    const row = document.createElement('div');
    row.style.display = 'flex';

    const numberCell = document.createElement('div');
    Object.assign(numberCell.style, {
      position: 'sticky',
      left: '0',
      zIndex: '1',
      flex: '0 0 auto',
      boxSizing: 'border-box',
      width: gutterWidth,
      paddingLeft: '12px',
      paddingRight: '10px',
      paddingTop: index === 0 ? '14px' : '0',
      paddingBottom: index === lastIndex ? '14px' : '0',
      textAlign: 'right',
      color: '#6c6c6c',
      background: '#f5f5f5',
      borderRight: '1px solid #e5e5e5',
      userSelect: 'none',
      whiteSpace: 'nowrap'
    });
    numberCell.textContent = String(index + 1);

    const codeCell = document.createElement('div');
    Object.assign(codeCell.style, {
      flex: '1 0 auto',
      paddingLeft: '12px',
      paddingRight: '16px',
      paddingTop: index === 0 ? '14px' : '0',
      paddingBottom: index === lastIndex ? '14px' : '0',
      whiteSpace: 'pre'
    });
    codeCell.appendChild(buildHighlightedJson(line));

    row.append(numberCell, codeCell);
    scroller.appendChild(row);
  });

  viewer.appendChild(scroller);
  return viewer;
}

/**
 * Tokenize a JSON string into a DocumentFragment of text nodes and colored
 * <span>s. No innerHTML is used, so this is safe under Trusted Types.
 * @param {string} json - A JSON string (a single line, already pretty-printed)
 * @returns {DocumentFragment}
 */
function buildHighlightedJson(json) {
  const fragment = document.createDocumentFragment();
  const tokenRegex = /"(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g;

  let lastIndex = 0;
  let match;
  while ((match = tokenRegex.exec(json)) !== null) {
    if (match.index > lastIndex) {
      fragment.appendChild(document.createTextNode(json.slice(lastIndex, match.index)));
    }

    const token = match[0];
    // Colors mirror SEVAL's CodeMirror JSON highlighting.
    let color = '#098658'; // number
    if (token[0] === '"') {
      color = /:$/.test(token) ? '#001080' : '#a31515'; // key : string
    } else if (token === 'true' || token === 'false') {
      color = '#098658'; // boolean
    } else if (token === 'null') {
      color = '#795e26';
    }

    const span = document.createElement('span');
    span.style.color = color;
    span.textContent = token;
    fragment.appendChild(span);

    lastIndex = tokenRegex.lastIndex;
  }

  if (lastIndex < json.length) {
    fragment.appendChild(document.createTextNode(json.slice(lastIndex)));
  }

  return fragment;
}

/**
 * Build the floating "Copy" button shown at the top-right of the JSON viewer,
 * mirroring the Copy button in SEVAL's JSON Configuration dialog.
 * @param {string} text - The JSON text to copy
 * @returns {HTMLButtonElement}
 */
function buildCopyButton(text) {
  const button = document.createElement('button');
  button.type = 'button';
  Object.assign(button.style, {
    position: 'absolute',
    top: '12px',
    right: '18px',
    zIndex: '2',
    display: 'inline-flex',
    alignItems: 'center',
    gap: '6px',
    padding: '5px 12px',
    fontSize: '14px',
    fontFamily: "'Segoe UI', system-ui, sans-serif",
    color: '#242424',
    background: '#fff',
    border: '1px solid #d1d1d1',
    borderRadius: '4px',
    cursor: 'pointer'
  });

  // Hover/press feedback mirroring SEVAL's native Fluent button (press = darker).
  let hovering = false;
  button.addEventListener('mouseenter', () => { hovering = true; button.style.background = '#f5f5f5'; });
  button.addEventListener('mouseleave', () => { hovering = false; button.style.background = '#fff'; });
  button.addEventListener('mousedown', () => { button.style.background = '#e0e0e0'; });
  button.addEventListener('mouseup', () => { button.style.background = hovering ? '#f5f5f5' : '#fff'; });

  button.appendChild(createCopyIcon());

  const label = document.createElement('span');
  label.textContent = 'Copy';
  button.appendChild(label);

  button.addEventListener('click', () => {
    navigator.clipboard.writeText(text).catch(() => {});
  });

  return button;
}

/**
 * Build the Fluent "copy" glyph as an SVG element (created via the SVG
 * namespace so no innerHTML is needed).
 * @returns {SVGElement}
 */
function createCopyIcon() {
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('fill', 'currentColor');
  svg.setAttribute('aria-hidden', 'true');
  svg.setAttribute('width', '16');
  svg.setAttribute('height', '16');
  svg.setAttribute('viewBox', '0 0 16 16');

  const path = document.createElementNS(ns, 'path');
  path.setAttribute('fill', 'currentColor');
  path.setAttribute('d', 'M4 4.09v6.41A2.5 2.5 0 0 0 6.34 13h4.57c-.2.58-.76 1-1.41 1H6a3 3 0 0 1-3-3V5.5c0-.65.42-1.2 1-1.41ZM11.5 2c.83 0 1.5.67 1.5 1.5v7c0 .83-.67 1.5-1.5 1.5h-5A1.5 1.5 0 0 1 5 10.5v-7C5 2.67 5.67 2 6.5 2h5Zm0 1h-5a.5.5 0 0 0-.5.5v7c0 .28.22.5.5.5h5a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.5-.5Z');

  svg.appendChild(path);
  return svg;
}