From e68b9d00a6e05b3a941f63ffb696f91e554ac5ec Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.3. Signed-off-by: Daniel Baumann --- web_src/js/utils/color.js | 33 +++++ web_src/js/utils/color.test.js | 22 +++ web_src/js/utils/dom.js | 305 +++++++++++++++++++++++++++++++++++++++++ web_src/js/utils/dom.test.js | 5 + web_src/js/utils/image.js | 47 +++++++ web_src/js/utils/image.test.js | 29 ++++ web_src/js/utils/match.js | 43 ++++++ web_src/js/utils/match.test.js | 50 +++++++ web_src/js/utils/time.js | 72 ++++++++++ web_src/js/utils/time.test.js | 40 ++++++ web_src/js/utils/url.js | 15 ++ web_src/js/utils/url.test.js | 13 ++ 12 files changed, 674 insertions(+) create mode 100644 web_src/js/utils/color.js create mode 100644 web_src/js/utils/color.test.js create mode 100644 web_src/js/utils/dom.js create mode 100644 web_src/js/utils/dom.test.js create mode 100644 web_src/js/utils/image.js create mode 100644 web_src/js/utils/image.test.js create mode 100644 web_src/js/utils/match.js create mode 100644 web_src/js/utils/match.test.js create mode 100644 web_src/js/utils/time.js create mode 100644 web_src/js/utils/time.test.js create mode 100644 web_src/js/utils/url.js create mode 100644 web_src/js/utils/url.test.js (limited to 'web_src/js/utils') diff --git a/web_src/js/utils/color.js b/web_src/js/utils/color.js new file mode 100644 index 0000000..198f97c --- /dev/null +++ b/web_src/js/utils/color.js @@ -0,0 +1,33 @@ +import tinycolor from 'tinycolor2'; + +// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance +// Keep this in sync with modules/util/color.go +function getRelativeLuminance(color) { + const {r, g, b} = tinycolor(color).toRgb(); + return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255; +} + +function useLightText(backgroundColor) { + return getRelativeLuminance(backgroundColor) < 0.453; +} + +// Given a background color, returns a black or white foreground color that the highest +// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. +// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 +export function contrastColor(backgroundColor) { + return useLightText(backgroundColor) ? '#fff' : '#000'; +} + +function resolveColors(obj) { + const styles = window.getComputedStyle(document.documentElement); + const getColor = (name) => styles.getPropertyValue(name).trim(); + return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)])); +} + +export const chartJsColors = resolveColors({ + text: '--color-text', + border: '--color-secondary-alpha-60', + commits: '--color-primary-alpha-60', + additions: '--color-green', + deletions: '--color-red', +}); diff --git a/web_src/js/utils/color.test.js b/web_src/js/utils/color.test.js new file mode 100644 index 0000000..fee9afc --- /dev/null +++ b/web_src/js/utils/color.test.js @@ -0,0 +1,22 @@ +import {contrastColor} from './color.js'; + +test('contrastColor', () => { + expect(contrastColor('#d73a4a')).toBe('#fff'); + expect(contrastColor('#0075ca')).toBe('#fff'); + expect(contrastColor('#cfd3d7')).toBe('#000'); + expect(contrastColor('#a2eeef')).toBe('#000'); + expect(contrastColor('#7057ff')).toBe('#fff'); + expect(contrastColor('#008672')).toBe('#fff'); + expect(contrastColor('#e4e669')).toBe('#000'); + expect(contrastColor('#d876e3')).toBe('#000'); + expect(contrastColor('#ffffff')).toBe('#000'); + expect(contrastColor('#2b8684')).toBe('#fff'); + expect(contrastColor('#2b8786')).toBe('#fff'); + expect(contrastColor('#2c8786')).toBe('#000'); + expect(contrastColor('#3bb6b3')).toBe('#000'); + expect(contrastColor('#7c7268')).toBe('#fff'); + expect(contrastColor('#7e716c')).toBe('#fff'); + expect(contrastColor('#81706d')).toBe('#fff'); + expect(contrastColor('#807070')).toBe('#fff'); + expect(contrastColor('#84b6eb')).toBe('#000'); +}); diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js new file mode 100644 index 0000000..44adc79 --- /dev/null +++ b/web_src/js/utils/dom.js @@ -0,0 +1,305 @@ +import {debounce} from 'throttle-debounce'; + +function elementsCall(el, func, ...args) { + if (typeof el === 'string' || el instanceof String) { + el = document.querySelectorAll(el); + } + if (el instanceof Node) { + func(el, ...args); + } else if (el.length !== undefined) { + // this works for: NodeList, HTMLCollection, Array, jQuery + for (const e of el) { + func(e, ...args); + } + } else { + throw new Error('invalid argument to be shown/hidden'); + } +} + +/** + * @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery + * @param force force=true to show or force=false to hide, undefined to toggle + */ +function toggleShown(el, force) { + if (force === true) { + el.classList.remove('tw-hidden'); + } else if (force === false) { + el.classList.add('tw-hidden'); + } else if (force === undefined) { + el.classList.toggle('tw-hidden'); + } else { + throw new Error('invalid force argument'); + } +} + +export function showElem(el) { + elementsCall(el, toggleShown, true); +} + +export function hideElem(el) { + elementsCall(el, toggleShown, false); +} + +export function toggleElem(el, force) { + elementsCall(el, toggleShown, force); +} + +export function isElemHidden(el) { + const res = []; + elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden'))); + if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`); + return res[0]; +} + +function applyElemsCallback(elems, fn) { + if (fn) { + for (const el of elems) { + fn(el); + } + } + return elems; +} + +export function queryElemSiblings(el, selector = '*', fn) { + return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn); +} + +// it works like jQuery.children: only the direct children are selected +export function queryElemChildren(parent, selector = '*', fn) { + return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); +} + +export function queryElems(selector, fn) { + return applyElemsCallback(document.querySelectorAll(selector), fn); +} + +export function onDomReady(cb) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', cb); + } else { + cb(); + } +} + +// checks whether an element is owned by the current document, and whether it is a document fragment or element node +// if it is, it means it is a "normal" element managed by us, which can be modified safely. +export function isDocumentFragmentOrElementNode(el) { + try { + return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE; + } catch { + // in case the el is not in the same origin, then the access to nodeType would fail + return false; + } +} + +// autosize a textarea to fit content. Based on +// https://github.com/github/textarea-autosize +// --------------------------------------------------------------------- +// Copyright (c) 2018 GitHub, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// --------------------------------------------------------------------- +export function autosize(textarea, {viewportMarginBottom = 0} = {}) { + let isUserResized = false; + // lastStyleHeight and initialStyleHeight are CSS values like '100px' + let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight; + + function onUserResize(event) { + if (isUserResized) return; + if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) { + const newStyleHeight = textarea.style.height; + if (lastStyleHeight && lastStyleHeight !== newStyleHeight) { + isUserResized = true; + } + lastStyleHeight = newStyleHeight; + } + + lastMouseX = event.clientX; + lastMouseY = event.clientY; + } + + function overflowOffset() { + let offsetTop = 0; + let el = textarea; + + while (el !== document.body && el !== null) { + offsetTop += el.offsetTop || 0; + el = el.offsetParent; + } + + const top = offsetTop - document.defaultView.scrollY; + const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight); + return {top, bottom}; + } + + function resizeToFit() { + if (isUserResized) return; + if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return; + + try { + const {top, bottom} = overflowOffset(); + const isOutOfViewport = top < 0 || bottom < 0; + + const computedStyle = getComputedStyle(textarea); + const topBorderWidth = parseFloat(computedStyle.borderTopWidth); + const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth); + const isBorderBox = computedStyle.boxSizing === 'border-box'; + const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0; + + const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom; + const curHeight = parseFloat(computedStyle.height); + const maxHeight = curHeight + bottom - adjustedViewportMarginBottom; + + textarea.style.height = 'auto'; + let newHeight = textarea.scrollHeight + borderAddOn; + + if (isOutOfViewport) { + // it is already out of the viewport: + // * if the textarea is expanding: do not resize it + if (newHeight > curHeight) { + newHeight = curHeight; + } + // * if the textarea is shrinking, shrink line by line (just use the + // scrollHeight). do not apply max-height limit, otherwise the page + // flickers and the textarea jumps + } else { + // * if it is in the viewport, apply the max-height limit + newHeight = Math.min(maxHeight, newHeight); + } + + textarea.style.height = `${newHeight}px`; + lastStyleHeight = textarea.style.height; + } finally { + // ensure that the textarea is fully scrolled to the end, when the cursor + // is at the end during an input event + if (textarea.selectionStart === textarea.selectionEnd && + textarea.selectionStart === textarea.value.length) { + textarea.scrollTop = textarea.scrollHeight; + } + } + } + + function onFormReset() { + isUserResized = false; + if (initialStyleHeight !== undefined) { + textarea.style.height = initialStyleHeight; + } else { + textarea.style.removeProperty('height'); + } + } + + textarea.addEventListener('mousemove', onUserResize); + textarea.addEventListener('input', resizeToFit); + textarea.form?.addEventListener('reset', onFormReset); + initialStyleHeight = textarea.style.height ?? undefined; + if (textarea.value) resizeToFit(); + + return { + resizeToFit, + destroy() { + textarea.removeEventListener('mousemove', onUserResize); + textarea.removeEventListener('input', resizeToFit); + textarea.form?.removeEventListener('reset', onFormReset); + }, + }; +} + +export function onInputDebounce(fn) { + return debounce(300, fn); +} + +// Set the `src` attribute on an element and returns a promise that resolves once the element +// has loaded or errored. Suitable for all elements mention in: +// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event +export function loadElem(el, src) { + return new Promise((resolve) => { + el.addEventListener('load', () => resolve(true), {once: true}); + el.addEventListener('error', () => resolve(false), {once: true}); + el.src = src; + }); +} + +// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter +// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)" +const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined'; + +export function submitEventSubmitter(e) { + return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter; +} + +function submitEventPolyfillListener(e) { + const form = e.target.closest('form'); + if (!form) return; + form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]'); +} + +export function initSubmitEventPolyfill() { + if (!needSubmitEventPolyfill) return; + console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`); + document.body.addEventListener('click', submitEventPolyfillListener); + document.body.addEventListener('focus', submitEventPolyfillListener); +} + +/** + * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. + * Note: This function doesn't account for all possible visibility scenarios. + * @param {HTMLElement} element The element to check. + * @returns {boolean} True if the element is visible. + */ +export function isElemVisible(element) { + if (!element) return false; + + return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); +} + +// extract text and images from "paste" event +export function getPastedContent(e) { + const images = []; + for (const item of e.clipboardData?.items ?? []) { + if (item.type?.startsWith('image/')) { + images.push(item.getAsFile()); + } + } + const text = e.clipboardData?.getData?.('text') ?? ''; + return {text, images}; +} + +// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this +export function replaceTextareaSelection(textarea, text) { + const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); + const after = textarea.value.slice(textarea.selectionEnd ?? undefined); + let success = true; + + textarea.contentEditable = 'true'; + try { + success = document.execCommand('insertText', false, text); + } catch { + success = false; + } + textarea.contentEditable = 'false'; + + if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) { + success = false; + } + + if (!success) { + textarea.value = `${before}${text}${after}`; + textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true})); + } +} + +// Warning: Do not enter any unsanitized variables here +export function createElementFromHTML(htmlString) { + const div = document.createElement('div'); + div.innerHTML = htmlString.trim(); + return div.firstChild; +} diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js new file mode 100644 index 0000000..fd7d97c --- /dev/null +++ b/web_src/js/utils/dom.test.js @@ -0,0 +1,5 @@ +import {createElementFromHTML} from './dom.js'; + +test('createElementFromHTML', () => { + expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar'); +}); diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js new file mode 100644 index 0000000..ed5d98e --- /dev/null +++ b/web_src/js/utils/image.js @@ -0,0 +1,47 @@ +export async function pngChunks(blob) { + const uint8arr = new Uint8Array(await blob.arrayBuffer()); + const chunks = []; + if (uint8arr.length < 12) return chunks; + const view = new DataView(uint8arr.buffer); + if (view.getBigUint64(0) !== 9894494448401390090n) return chunks; + + const decoder = new TextDecoder(); + let index = 8; + while (index < uint8arr.length) { + const len = view.getUint32(index); + chunks.push({ + name: decoder.decode(uint8arr.slice(index + 4, index + 8)), + data: uint8arr.slice(index + 8, index + 8 + len), + }); + index += len + 12; + } + + return chunks; +} + +// decode a image and try to obtain width and dppx. If will never throw but instead +// return default values. +export async function imageInfo(blob) { + let width = 0; // 0 means no width could be determined + let dppx = 1; // 1 dot per pixel for non-HiDPI screens + + if (blob.type === 'image/png') { // only png is supported currently + try { + for (const {name, data} of await pngChunks(blob)) { + const view = new DataView(data.buffer); + if (name === 'IHDR' && data?.length) { + // extract width from mandatory IHDR chunk + width = view.getUint32(0); + } else if (name === 'pHYs' && data?.length) { + // extract dppx from optional pHYs chunk, assuming pixels are square + const unit = view.getUint8(8); + if (unit === 1) { + dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx + } + } + } + } catch {} + } + + return {width, dppx}; +} diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js new file mode 100644 index 0000000..ba47582 --- /dev/null +++ b/web_src/js/utils/image.test.js @@ -0,0 +1,29 @@ +import {pngChunks, imageInfo} from './image.js'; + +const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg=='; +const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A=='; +const pngEmpty = 'data:image/png;base64,'; + +async function dataUriToBlob(datauri) { + return await (await globalThis.fetch(datauri)).blob(); +} + +test('pngChunks', async () => { + expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([ + {name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])}, + {name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])}, + {name: 'IEND', data: new Uint8Array([])}, + ]); + expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([ + {name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])}, + {name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])}, + {name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])}, + ]); + expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]); +}); + +test('imageInfo', async () => { + expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1}); + expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2}); + expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1}); +}); diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js new file mode 100644 index 0000000..17fdfed --- /dev/null +++ b/web_src/js/utils/match.js @@ -0,0 +1,43 @@ +import emojis from '../../../assets/emoji.json'; + +const maxMatches = 6; + +function sortAndReduce(map) { + const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1])); + return Array.from(sortedMap.keys()).slice(0, maxMatches); +} + +export function matchEmoji(queryText) { + const query = queryText.toLowerCase().replaceAll('_', ' '); + if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]); + + // results is a map of weights, lower is better + const results = new Map(); + for (const {aliases} of emojis) { + const mainAlias = aliases[0]; + for (const [aliasIndex, alias] of aliases.entries()) { + const index = alias.replaceAll('_', ' ').indexOf(query); + if (index === -1) continue; + const existing = results.get(mainAlias); + const rankedIndex = index + aliasIndex; + results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex); + } + } + + return sortAndReduce(results); +} + +export function matchMention(queryText) { + const query = queryText.toLowerCase(); + + // results is a map of weights, lower is better + const results = new Map(); + for (const obj of window.config.mentionValues ?? []) { + const index = obj.key.toLowerCase().indexOf(query); + if (index === -1) continue; + const existing = results.get(obj); + results.set(obj, existing ? existing - index : index); + } + + return sortAndReduce(results); +} diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js new file mode 100644 index 0000000..1e30b45 --- /dev/null +++ b/web_src/js/utils/match.test.js @@ -0,0 +1,50 @@ +import {matchEmoji, matchMention} from './match.js'; + +test('matchEmoji', () => { + expect(matchEmoji('')).toEqual([ + '+1', + '-1', + '100', + '1234', + '1st_place_medal', + '2nd_place_medal', + ]); + + expect(matchEmoji('hea')).toEqual([ + 'headphones', + 'headstone', + 'health_worker', + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + ]); + + expect(matchEmoji('hear')).toEqual([ + 'hear_no_evil', + 'heard_mcdonald_islands', + 'heart', + 'heart_decoration', + 'heart_eyes', + 'heart_eyes_cat', + ]); + + expect(matchEmoji('poo')).toEqual([ + 'poodle', + 'hankey', + 'spoon', + 'bowl_with_spoon', + ]); + + expect(matchEmoji('1st_')).toEqual([ + '1st_place_medal', + ]); + + expect(matchEmoji('jellyfis')).toEqual([ + 'jellyfish', + ]); +}); + +test('matchMention', () => { + expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6)); + expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]); +}); diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js new file mode 100644 index 0000000..7c7eabd --- /dev/null +++ b/web_src/js/utils/time.js @@ -0,0 +1,72 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import {getCurrentLocale} from '../utils.js'; + +dayjs.extend(utc); + +/** + * Returns an array of millisecond-timestamps of start-of-week days (Sundays) + * + * @param startConfig The start date. Can take any type that `Date` accepts. + * @param endConfig The end date. Can take any type that `Date` accepts. + */ +export function startDaysBetween(startDate, endDate) { + const start = dayjs.utc(startDate); + const end = dayjs.utc(endDate); + + let current = start; + + // Ensure the start date is a Sunday + while (current.day() !== 0) { + current = current.add(1, 'day'); + } + + const startDays = []; + while (current.isBefore(end)) { + startDays.push(current.valueOf()); + current = current.add(1, 'week'); + } + + return startDays; +} + +export function firstStartDateAfterDate(inputDate) { + if (!(inputDate instanceof Date)) { + throw new Error('Invalid date'); + } + const dayOfWeek = inputDate.getUTCDay(); + const daysUntilSunday = 7 - dayOfWeek; + const resultDate = new Date(inputDate.getTime()); + resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday); + return resultDate.valueOf(); +} + +export function fillEmptyStartDaysWithZeroes(startDays, data) { + const result = {}; + + for (const startDay of startDays) { + result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0}; + } + + return Object.values(result); +} + +let dateFormat; + +// format a Date object to document's locale, but with 24h format from user's current locale because this +// option is a personal preference of the user, not something that the document's locale should dictate. +export function formatDatetime(date) { + if (!dateFormat) { + // TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support + dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())), + minute: '2-digit', + timeZoneName: 'short', + }); + } + return dateFormat.format(date); +} diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js new file mode 100644 index 0000000..dbe5d7d --- /dev/null +++ b/web_src/js/utils/time.test.js @@ -0,0 +1,40 @@ +import {firstStartDateAfterDate, startDaysBetween, fillEmptyStartDaysWithZeroes} from './time.js'; + +test('startDaysBetween', () => { + expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([ + 1708214400000, + 1708819200000, + 1709424000000, + 1710028800000, + 1710633600000, + 1711238400000, + 1711843200000, + 1712448000000, + 1713052800000, + ]); +}); + +test('firstStartDateAfterDate', () => { + const expectedDate = new Date('2024-02-18').getTime(); + expect(firstStartDateAfterDate(new Date('2024-02-15'))).toEqual(expectedDate); + + expect(() => firstStartDateAfterDate('2024-02-15')).toThrowError('Invalid date'); +}); +test('fillEmptyStartDaysWithZeroes with data', () => { + expect(fillEmptyStartDaysWithZeroes([1708214400000, 1708819200000, 1708819300000], { + 1708214400000: {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3}, + 1708819200000: {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6}, + })).toEqual([ + {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3}, + {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6}, + { + 'additions': 0, + 'commits': 0, + 'deletions': 0, + 'week': 1708819300000, + }]); +}); + +test('fillEmptyStartDaysWithZeroes with empty array', () => { + expect(fillEmptyStartDaysWithZeroes([], {})).toEqual([]); +}); diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.js new file mode 100644 index 0000000..470ece3 --- /dev/null +++ b/web_src/js/utils/url.js @@ -0,0 +1,15 @@ +export function pathEscapeSegments(s) { + return s.split('/').map(encodeURIComponent).join('/'); +} + +function stripSlash(url) { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +export function isUrl(url) { + try { + return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim(); + } catch { + return false; + } +} diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.js new file mode 100644 index 0000000..08c6373 --- /dev/null +++ b/web_src/js/utils/url.test.js @@ -0,0 +1,13 @@ +import {pathEscapeSegments, isUrl} from './url.js'; + +test('pathEscapeSegments', () => { + expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c'); + expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c'); +}); + +test('isUrl', () => { + expect(isUrl('https://example.com')).toEqual(true); + expect(isUrl('https://example.com/')).toEqual(true); + expect(isUrl('https://example.com/index.html')).toEqual(true); + expect(isUrl('/index.html')).toEqual(false); +}); -- cgit v1.2.3