From 0bd881ad6203fe101979a00840cf638845ee9fa2 Mon Sep 17 00:00:00 2001 From: Sam Atkins Date: Wed, 27 Mar 2024 20:51:20 +0000 Subject: [PATCH] Implement a basic `date` builtin A couple of the format sequences aren't implemented yet, because the values aren't exposed by JS, and any solution I come up with is likely to be incorrect when crossing daylight-savings boundaries. Basically, I can't wait for the Temporal API to be generally available! --- src/puter-shell/coreutils/__exports__.js | 2 + src/puter-shell/coreutils/date.js | 311 +++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 src/puter-shell/coreutils/date.js diff --git a/src/puter-shell/coreutils/__exports__.js b/src/puter-shell/coreutils/__exports__.js index 0b0a95b..4edc1d2 100644 --- a/src/puter-shell/coreutils/__exports__.js +++ b/src/puter-shell/coreutils/__exports__.js @@ -24,6 +24,7 @@ import module_cd from './cd.js' import module_changelog from './changelog.js' import module_clear from './clear.js' import module_cp from './cp.js' +import module_date from './date.js' import module_dcall from './dcall.js' import module_dirname from './dirname.js' import module_echo from './echo.js' @@ -64,6 +65,7 @@ export default { "changelog": module_changelog, "clear": module_clear, "cp": module_cp, + "date": module_date, "dcall": module_dcall, "dirname": module_dirname, "echo": module_echo, diff --git a/src/puter-shell/coreutils/date.js b/src/puter-shell/coreutils/date.js new file mode 100644 index 0000000..97e0c26 --- /dev/null +++ b/src/puter-shell/coreutils/date.js @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Phoenix Shell. + * + * Phoenix Shell is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Exit } from './coreutil_lib/exit.js'; + +// "When no formatting operand is specified, the output in the POSIX locale shall be equivalent to specifying:" +const DEFAULT_FORMAT = '+%a %b %e %H:%M:%S %Z %Y'; + +function padStart(number, length, padChar) { + let string = number.toString(); + if ( string.length >= length ) { + return string; + } + + return padChar.repeat(length - string.length) + string; +} + +function highlight(text) { + return `\x1B[92m${text}\x1B[0m`; +} + +export default { + name: 'date', + usage: 'date [OPTIONS] [+FORMAT]', + description: 'Print the system date and time\n\n' + + 'If FORMAT is provided, it controls the date format used.', + helpSections: { + 'Format Sequences': 'The following format sequences are understood:\n\n' + + ` ${highlight('%a')} Weekday name, abbreviated.\n` + + ` ${highlight('%A')} Weekday name\n` + + ` ${highlight('%b')} Month name, abbreviated\n` + + ` ${highlight('%B')} Month name\n` + + ` ${highlight('%c')} Default date and time representation\n` + + ` ${highlight('%C')} Century, 2 digits padded with '0'\n` + + ` ${highlight('%d')} Day of the month, 2 digits padded with '0'\n` + + ` ${highlight('%D')} Date in the format mm/dd/yy\n` + + ` ${highlight('%e')} Day of the month, 2 characters padded with leading spaces\n` + + ` ${highlight('%h')} Same as ${highlight('%b')}\n` + + ` ${highlight('%H')} Hour (24-hour clock), 2 digits padded with '0'\n` + + ` ${highlight('%I')} Hour (12-hour clock), 2 digits padded with '0'\n` + + // ` ${highlight('%j')} TODO: Day of the year, 3 digits padded with '0'\n` + + ` ${highlight('%m')} Month, 2 digits padded with '0', with January = 01\n` + + ` ${highlight('%M')} Minutes, 2 digits padded with '0'\n` + + ` ${highlight('%n')} A newline character\n` + + ` ${highlight('%p')} AM or PM\n` + + ` ${highlight('%r')} Time (12-hour clock) with AM/PM, as 'HH:MM:SS AM/PM'\n` + + ` ${highlight('%S')} Seconds, 2 digits padded with '0'\n` + + ` ${highlight('%t')} A tab character\n` + + ` ${highlight('%T')} Time (24-hour clock), as 'HH:MM:SS'\n` + + ` ${highlight('%u')} Weekday as a number, with Monday = 1 and Sunday = 7\n` + + // ` ${highlight('%U')} TODO: Week of the year (Sunday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Sunday shall be considered to be in week 0.\n` + + // ` ${highlight('%V')} TODO: Week of the year (Monday as the first day of the week) as a decimal number [01,53]. If the week containing January 1 has four or more days in the new year, then it shall be considered week 1; otherwise, it shall be the last week of the previous year, and the next week shall be week 1.\n` + + ` ${highlight('%w')} Weekday as a number, with Sunday = 0\n` + + // ` ${highlight('%W')} TODO: Week of the year (Monday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Monday shall be considered to be in week 0.\n` + + ` ${highlight('%x')} Default date representation\n` + + ` ${highlight('%X')} Default time representation\n` + + ` ${highlight('%y')} Year within century, 2 digits padded with '0'\n` + + ` ${highlight('%Y')} Year\n` + + ` ${highlight('%Z')} Timezone name, if it can be determined\n` + + ` ${highlight('%%')} A percent sign\n` + }, + args: { + $: 'simple-parser', + allowPositionals: true, + }, + execute: async ctx => { + const { out, err } = ctx.externs; + const { positionals } = ctx.locals; + + if ( positionals.length > 1 ) { + await err.write('date: Too many arguments\n'); + throw new Exit(1); + } + + let format = positionals.shift() ?? DEFAULT_FORMAT; + + if ( ! format.startsWith('+') ) { + await err.write('date: Format does not begin with `+`\n'); + throw new Exit(1); + } + format = format.substring(1); + + // TODO: Should we use the server time instead? Maybe put that behind an option. + const date = new Date(); + const locale = 'en-US'; // TODO: POSIX: Pull this from the user's settings. + + let output = ''; + for (let i = 0; i < format.length; i++) { + let char = format[i]; + if ( char === '%' ) { + char = format[++i]; + switch (char) { + // "Locale's abbreviated weekday name." + case 'a': { + output += date.toLocaleDateString(locale, { weekday: 'short' }); + break; + } + + // "Locale's full weekday name." + case 'A': { + output += date.toLocaleDateString(locale, { weekday: 'long' }); + break; + } + + // "Locale's abbreviated month name." + case 'b': + // "A synonym for %b." + case 'h': { + output += date.toLocaleDateString(locale, { month: 'short' }); + break; + } + + // "Locale's full month name." + case 'B': { + output += date.toLocaleDateString(locale, { month: 'long' }); + break; + } + + // "Locale's appropriate date and time representation." + case 'c': { + output += date.toLocaleString(locale); + break; + } + + // "Century (a year divided by 100 and truncated to an integer) as a decimal number [00,99]." + case 'C': { + output += Math.trunc(date.getFullYear() / 100); + break; + } + + // "Day of the month as a decimal number [01,31]." + case 'd': { + output += padStart(date.getDate(), 2, '0'); + break; + } + + // "Date in the format mm/dd/yy." + case 'D': { + const month = padStart(date.getMonth() + 1, 2, '0'); + const day = padStart(date.getDate(), 2, '0'); + const year = padStart(date.getFullYear() % 100, 2, '0'); + output += `${month}/${day}/${year}`; + break; + } + + // "Day of the month as a decimal number [1,31] in a two-digit field with leading + // character fill." + case 'e': { + output += padStart(date.getDate(), 2, ' '); + break; + } + + // "Hour (24-hour clock) as a decimal number [00,23]." + case 'H': { + output += padStart(date.getHours(), 2, '0'); + break; + } + + // "Hour (12-hour clock) as a decimal number [01,12]." + case 'I': { + output += padStart((date.getHours() % 12) || 12, 2, '0'); + break; + } + + // TODO: "Day of the year as a decimal number [001,366]." + case 'j': break; + + // "Month as a decimal number [01,12]." + case 'm': { + // getMonth() starts at 0 for January + output += padStart(date.getMonth() + 1, 2, '0'); + break; + } + + // "Minute as a decimal number [00,59]." + case 'M': { + output += padStart(date.getMinutes(), 2, '0'); + break; + } + + // "A ." + case 'n': output += '\n'; break; + + // "Locale's equivalent of either AM or PM." + case 'p': { + // TODO: We should access this from the locale. + output += date.getHours() < 12 ? 'AM' : 'PM'; + break; + } + + // "12-hour clock time [01,12] using the AM/PM notation; in the POSIX locale, this shall be + // equivalent to %I : %M : %S %p." + case 'r': { + const rawHours = date.getHours(); + const hours = padStart((rawHours % 12) || 12, 2, '0'); + // TODO: We should access this from the locale. + const am_pm = rawHours < 12 ? 'AM' : 'PM'; + const minutes = padStart(date.getMinutes(), 2, '0'); + const seconds = padStart(date.getSeconds(), 2, '0'); + output += `${hours}:${minutes}:${seconds} ${am_pm}`; + break; + } + + // "Seconds as a decimal number [00,60]." + case 'S': { + output += padStart(date.getSeconds(), 2, '0'); + break; + } + + // "A ." + case 't': output += '\t'; break; + + // "24-hour clock time [00,23] in the format HH:MM:SS." + case 'T': { + const hours = padStart(date.getHours(), 2, '0'); + const minutes = padStart(date.getMinutes(), 2, '0'); + const seconds = padStart(date.getSeconds(), 2, '0'); + output += `${hours}:${minutes}:${seconds}`; + break; + } + + // "Weekday as a decimal number [1,7] (1=Monday)." + case 'u': { + // getDay() returns 0 for Sunday + output += date.getDay() || 7; + break; + } + + // TODO: "Week of the year (Sunday as the first day of the week) as a decimal number [00,53]. + // All days in a new year preceding the first Sunday shall be considered to be in week 0." + case 'U': break; + + // TODO: "Week of the year (Monday as the first day of the week) as a decimal number [01,53]. + // If the week containing January 1 has four or more days in the new year, then it shall be + // considered week 1; otherwise, it shall be the last week of the previous year, and the next + // week shall be week 1." + case 'V': break; + + // "Weekday as a decimal number [0,6] (0=Sunday)." + case 'w': { + output += date.getDay(); + break; + } + + // TODO: "Week of the year (Monday as the first day of the week) as a decimal number [00,53]. + // All days in a new year preceding the first Monday shall be considered to be in week 0." + case 'W': break; + + // "Locale's appropriate date representation." + case 'x': { + output += date.toLocaleDateString(locale); + break; + } + + // "Locale's appropriate time representation." + case 'X': { + output += date.toLocaleTimeString(locale); + break; + } + + // "Year within century [00,99]." + case 'y': { + output += date.getFullYear() % 100; + break; + } + + // "Year with century as a decimal number." + case 'Y': { + output += date.getFullYear(); + break; + } + + // "Timezone name, or no characters if no timezone is determinable." + case 'Z': { + output += date.toLocaleDateString(locale, { timeZoneName: 'short' }); + break; + } + + // "A character." + case '%': output += '%'; break; + + // We reached the end of the string, just output the %. + case undefined: output += '%'; break; + + // If nothing matched, just output the input verbatim + default: output += '%' + char; break; + } + continue; + } + output += char; + } + output += '\n'; + + await out.write(output); + } +};