|
3 | 3 | import { Series } from '../Series'; |
4 | 4 | import { Context } from '..'; |
5 | 5 | import { PineArrayObject, PineArrayType } from './array/PineArrayObject'; |
| 6 | +import { getDatePartsInTimezone } from './Time'; |
| 7 | + |
| 8 | +const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; |
| 9 | +const MONTH_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; |
| 10 | +const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; |
| 11 | +const DAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; |
| 12 | + |
| 13 | +const pad = (n: number, len: number) => String(n).padStart(len, '0'); |
6 | 14 |
|
7 | 15 | export class Str { |
8 | 16 | constructor(private context: Context) {} |
@@ -133,6 +141,109 @@ export class Str { |
133 | 141 | return String(source).substring(begin_pos, end_pos); |
134 | 142 | } |
135 | 143 |
|
| 144 | + /** |
| 145 | + * Format a UNIX millisecond timestamp using Java SimpleDateFormat-style tokens |
| 146 | + * (yyyy, MM, dd, HH, mm, ss, EEE, EEEE, MMM, MMMM, a, h, S, Z, etc.). |
| 147 | + * Text inside single quotes is treated as a literal; '' produces a literal '. |
| 148 | + */ |
| 149 | + format_time(time: any, format: string = "yyyy-MM-dd'T'HH:mm:ssZ", timezone?: string) { |
| 150 | + if (time === null || time === undefined || (typeof time === 'number' && isNaN(time))) { |
| 151 | + return 'NaN'; |
| 152 | + } |
| 153 | + const ts = Number(time); |
| 154 | + const tz = timezone || this.context.pine?.syminfo?.timezone || 'UTC'; |
| 155 | + const parts = getDatePartsInTimezone(ts, tz); |
| 156 | + |
| 157 | + // Compute timezone offset (for Z token) by comparing tz-local recomposed UTC ms to actual ts |
| 158 | + const tzAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); |
| 159 | + const offsetMin = Math.round((tzAsUtc - ts) / 60000); |
| 160 | + |
| 161 | + // Day of year (in target tz) |
| 162 | + const startOfYearUtc = Date.UTC(parts.year, 0, 1); |
| 163 | + const dayOfYear = Math.floor((tzAsUtc - startOfYearUtc) / 86400000) + 1; |
| 164 | + |
| 165 | + const hour12 = parts.hour % 12 === 0 ? 12 : parts.hour % 12; |
| 166 | + |
| 167 | + let result = ''; |
| 168 | + let i = 0; |
| 169 | + while (i < format.length) { |
| 170 | + const ch = format[i]; |
| 171 | + |
| 172 | + // Single-quoted literal |
| 173 | + if (ch === "'") { |
| 174 | + if (format[i + 1] === "'") { result += "'"; i += 2; continue; } |
| 175 | + const end = format.indexOf("'", i + 1); |
| 176 | + if (end === -1) { result += format.substring(i + 1); break; } |
| 177 | + result += format.substring(i + 1, end); |
| 178 | + i = end + 1; |
| 179 | + continue; |
| 180 | + } |
| 181 | + |
| 182 | + // Pattern token: count consecutive same-letter chars |
| 183 | + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) { |
| 184 | + let count = 1; |
| 185 | + while (format[i + count] === ch) count++; |
| 186 | + i += count; |
| 187 | + |
| 188 | + switch (ch) { |
| 189 | + case 'y': |
| 190 | + result += count === 2 ? pad(parts.year % 100, 2) : count >= 4 ? pad(parts.year, 4) : String(parts.year); |
| 191 | + break; |
| 192 | + case 'M': |
| 193 | + if (count >= 4) result += MONTH_LONG[parts.month - 1]; |
| 194 | + else if (count === 3) result += MONTH_SHORT[parts.month - 1]; |
| 195 | + else if (count === 2) result += pad(parts.month, 2); |
| 196 | + else result += String(parts.month); |
| 197 | + break; |
| 198 | + case 'd': |
| 199 | + result += count === 2 ? pad(parts.day, 2) : String(parts.day); |
| 200 | + break; |
| 201 | + case 'D': |
| 202 | + result += count >= 3 ? pad(dayOfYear, 3) : count === 2 ? pad(dayOfYear, 2) : String(dayOfYear); |
| 203 | + break; |
| 204 | + case 'E': |
| 205 | + result += count >= 4 ? DAY_LONG[parts.dayOfWeek] : DAY_SHORT[parts.dayOfWeek]; |
| 206 | + break; |
| 207 | + case 'a': |
| 208 | + result += parts.hour < 12 ? 'AM' : 'PM'; |
| 209 | + break; |
| 210 | + case 'h': |
| 211 | + result += count === 2 ? pad(hour12, 2) : String(hour12); |
| 212 | + break; |
| 213 | + case 'H': |
| 214 | + result += count === 2 ? pad(parts.hour, 2) : String(parts.hour); |
| 215 | + break; |
| 216 | + case 'm': |
| 217 | + result += count === 2 ? pad(parts.minute, 2) : String(parts.minute); |
| 218 | + break; |
| 219 | + case 's': |
| 220 | + result += count === 2 ? pad(parts.second, 2) : String(parts.second); |
| 221 | + break; |
| 222 | + case 'S': { |
| 223 | + const ms = ts - Math.floor(ts / 1000) * 1000; |
| 224 | + result += pad(ms, 3).substring(0, count); |
| 225 | + break; |
| 226 | + } |
| 227 | + case 'Z': { |
| 228 | + const sign = offsetMin >= 0 ? '+' : '-'; |
| 229 | + const absMin = Math.abs(offsetMin); |
| 230 | + result += `${sign}${pad(Math.floor(absMin / 60), 2)}${pad(absMin % 60, 2)}`; |
| 231 | + break; |
| 232 | + } |
| 233 | + default: |
| 234 | + // Unknown letter token — leave as-is |
| 235 | + result += ch.repeat(count); |
| 236 | + } |
| 237 | + continue; |
| 238 | + } |
| 239 | + |
| 240 | + // Literal char |
| 241 | + result += ch; |
| 242 | + i++; |
| 243 | + } |
| 244 | + return result; |
| 245 | + } |
| 246 | + |
136 | 247 | format(message: string, ...args: any[]) { |
137 | 248 | // Handle both simple {0} and extended {0,number,#.##} patterns |
138 | 249 | return message.replace(/\{(\d+)(?:,number,([^}]+))?\}/g, (match, index, fmt) => { |
|
0 commit comments