Unify chat timestamps format (#4806) * Unify chat timestamps format * Handle ISO timestamps in stats.js * Refactor timestamp parsing on server * Switch to ISO timestamps for character/messages creation dates * Fix type error * Early exist in saveGroupChat if group not found * Remove redundant fields from temp.chat export header * Auto-fix char creation date format on edit * Add name to fallback chat file names * Rename parseTimestamp server side function

5993084ee6ebd887625fa4a8404d21e56ab69f32

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
12 files changed, +176 -180Showing whitespace changes
public/script.js+1 -4
@@ -1028,9 +1028,6 @@ function verifyCharactersSearchSortRule() {
1028 }1028 }
1029}1029}
10301030
1031/** @typedef {object} Character - A character */
1032/** @typedef {object} Group - A group */
1033
1034/**1031/**
1035 * @typedef {object} Entity - Object representing a display entity1032 * @typedef {object} Entity - Object representing a display entity
1036 * @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item1033 * @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item
@@ -8248,7 +8245,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
8248 $('#talkativeness_slider').val(characters[chid].talkativeness || talkativeness_default);8245 $('#talkativeness_slider').val(characters[chid].talkativeness || talkativeness_default);
8249 $('#mes_example_textarea').val(characters[chid].mes_example);8246 $('#mes_example_textarea').val(characters[chid].mes_example);
8250 $('#selected_chat_pole').val(characters[chid].chat);8247 $('#selected_chat_pole').val(characters[chid].chat);
8251 $('#create_date_pole').val(characters[chid].create_date);8248 $('#create_date_pole').val(timestampToMoment(characters[chid].create_date).toISOString());
8252 $('#avatar_url_pole').val(characters[chid].avatar);8249 $('#avatar_url_pole').val(characters[chid].avatar);
8253 $('#chat_import_avatar_url').val(characters[chid].avatar);8250 $('#chat_import_avatar_url').val(characters[chid].avatar);
8254 $('#chat_import_character_name').val(characters[chid].name);8251 $('#chat_import_character_name').val(characters[chid].name);
public/scripts/RossAscends-mods.js+28 -34
@@ -162,43 +162,37 @@ export function shouldSendOnEnter() {
162 }162 }
163}163}
164164
165//RossAscends: Added function to format dates used in files and chat timestamps to a humanized format.165/**
166//Mostly I wanted this to be for file names, but couldn't figure out exactly where the filename save code was as everything seemed to be connected.166 * Gets a humanized date time string from a given timestamp.
167//Does not break old characters/chats, as the code just uses whatever timestamp exists in the chat.167 * @param {number} timestamp Timestamp in milliseconds
168//New chats made with characters will use this new formatting.168 * @returns {string} Humanized date time string in the format `YYYY-MM-DD@HHhMMmSSsMSms`
169export function humanizedDateTime() {169 */
170 const now = new Date(Date.now());170export function humanizedDateTime(timestamp = Date.now()) {
171 const date = new Date(timestamp);
171 const dt = {172 const dt = {
172 year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(),173 year: date.getFullYear(),
173 hour: now.getHours(), minute: now.getMinutes(), second: now.getSeconds(),174 month: date.getMonth() + 1,
175 day: date.getDate(),
176 hour: date.getHours(),
177 minute: date.getMinutes(),
178 second: date.getSeconds(),
179 millisecond: date.getMilliseconds(),
174 };180 };
175 for (const key in dt) {181 for (const key in dt) {
176 dt[key] = dt[key].toString().padStart(2, '0');182 const padLength = key === 'millisecond' ? 3 : 2;
177 }183 dt[key] = dt[key].toString().padStart(padLength, '0');
178 return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s`;184 }
179}185 return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s${dt.millisecond}ms`;
180186}
181//this is a common format version to display a timestamp on each chat message187
182//returns something like: June 19, 2023 2:20pm188/**
183export function getMessageTimeStamp() {189 * Gets a timestamp for messages in ISO 8601 format.
184 const date = Date.now();190 * @param {number} timestamp - optional timestamp in milliseconds
185 const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];191 * @returns {string} ISO 8601 formatted timestamp
186 const d = new Date(date);192 */
187 const month = months[d.getMonth()];193export function getMessageTimeStamp(timestamp = Date.now()) {
188 const day = d.getDate();194 const date = new Date(timestamp);
189 const year = d.getFullYear();195 return date.toISOString();
190 let hours = d.getHours();
191 const minutes = ('0' + d.getMinutes()).slice(-2);
192 let meridiem = 'am';
193 if (hours >= 12) {
194 meridiem = 'pm';
195 hours -= 12;
196 }
197 if (hours === 0) {
198 hours = 12;
199 }
200 const formattedDate = month + ' ' + day + ', ' + year + ' ' + hours + ':' + minutes + meridiem;
201 return formattedDate;
202}196}
203197
204198
public/scripts/chats.js+2 -7
@@ -10,7 +10,6 @@ import {
10 event_types,10 event_types,
11 getCurrentChatId,11 getCurrentChatId,
12 getRequestHeaders,12 getRequestHeaders,
13 name1,
14 name2,13 name2,
15 reloadCurrentChat,14 reloadCurrentChat,
16 saveSettingsDebounced,15 saveSettingsDebounced,
@@ -2140,13 +2139,9 @@ export function initChatUtilities() {
2140 await viewMessageFile(messageId, fileIndex);2139 await viewMessageFile(messageId, fileIndex);
2141 });2140 });
21422141
2143 $(document).on('click', '.assistant_note_export', async function () {2142 $(document).on('click', '.assistant_note_export', async function (_e) {
2144 const chatToSave = [2143 const chatToSave = [
2145 {2144 { chat_metadata: chat_metadata },
2146 user_name: name1,
2147 character_name: name2,
2148 chat_metadata: chat_metadata,
2149 },
2150 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),2145 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
2151 ];2146 ];
21522147
public/scripts/extensions/stable-diffusion/index.js+1 -1
@@ -2865,7 +2865,7 @@ function getCharacterAvatarUrl() {
2865 if (context.groupId) {2865 if (context.groupId) {
2866 const groupMembers = context.groups.find(x => x.id === context.groupId)?.members;2866 const groupMembers = context.groups.find(x => x.id === context.groupId)?.members;
2867 const lastMessageAvatar = context.chat?.filter(x => !x.is_system && !x.is_user)?.slice(-1)[0]?.original_avatar;2867 const lastMessageAvatar = context.chat?.filter(x => !x.is_system && !x.is_user)?.slice(-1)[0]?.original_avatar;
2868 const randomMemberAvatar = Array.isArray(groupMembers) ? groupMembers[Math.floor(Math.random() * groupMembers.length)]?.avatar : null;2868 const randomMemberAvatar = Array.isArray(groupMembers) ? groupMembers[Math.floor(Math.random() * groupMembers.length)] : null;
2869 const avatarToUse = lastMessageAvatar || randomMemberAvatar;2869 const avatarToUse = lastMessageAvatar || randomMemberAvatar;
2870 return formatCharacterAvatar(avatarToUse);2870 return formatCharacterAvatar(avatarToUse);
2871 } else {2871 } else {
public/scripts/group-chats.js+7 -3
@@ -324,7 +324,7 @@ export async function getGroupChat(groupId, reload = false) {
324 * Retrieves the members of a group324 * Retrieves the members of a group
325 *325 *
326 * @param {string} [groupId=selected_group] - The ID of the group to retrieve members from. Defaults to the currently selected group.326 * @param {string} [groupId=selected_group] - The ID of the group to retrieve members from. Defaults to the currently selected group.
327 * @returns {import('../script.js').Character[]} An array of character objects representing the members of the group. If the group is not found, an empty array is returned.327 * @returns {Character[]} An array of character objects representing the members of the group. If the group is not found, an empty array is returned.
328 */328 */
329export function getGroupMembers(groupId = selected_group) {329export function getGroupMembers(groupId = selected_group) {
330 const group = groups.find((x) => x.id === groupId);330 const group = groups.find((x) => x.id === groupId);
@@ -617,7 +617,11 @@ function resetSelectedGroup() {
617 */617 */
618async function saveGroupChat(groupId, shouldSaveGroup, force = false) {618async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
619 const group = groups.find(x => x.id == groupId);619 const group = groups.find(x => x.id == groupId);
620 const chat_id = group.chat_id;620 if (!group) {
621 console.warn('Group not found', groupId);
622 return;
623 }
624 const chatId = group.chat_id;
621 group['date_last_chat'] = Date.now();625 group['date_last_chat'] = Date.now();
622 /** @type {ChatHeader} */626 /** @type {ChatHeader} */
623 const chatHeader = {627 const chatHeader = {
@@ -626,7 +630,7 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
626 const response = await fetch('/api/chats/group/save', {630 const response = await fetch('/api/chats/group/save', {
627 method: 'POST',631 method: 'POST',
628 headers: getRequestHeaders(),632 headers: getRequestHeaders(),
629 body: JSON.stringify({ id: chat_id, chat: [chatHeader, ...chat], force: force }),633 body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),
630 });634 });
631635
632 if (!response.ok) {636 if (!response.ok) {
public/scripts/utils.js+2 -0
@@ -1105,6 +1105,8 @@ function parseTimestamp(timestamp) {
1105 ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';1105 ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';
1106 return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;1106 return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;
1107 };1107 };
1108 // 2024-07-12@01h31m37s123ms
1109 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s(\d{1,3})ms/ });
1108 // 2024-7-12@01h31m37s1110 // 2024-7-12@01h31m37s
1109 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });1111 dtFmt.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });
1110 // 2024-6-5 @14h 56m 50s 682ms1112 // 2024-6-5 @14h 56m 50s 682ms
src/byaf.js+3 -6
@@ -2,7 +2,7 @@ import { promises as fsPromises } from 'node:fs';
2import path from 'node:path';2import path from 'node:path';
3import urlJoin from 'url-join';3import urlJoin from 'url-join';
4import { DEFAULT_AVATAR_PATH } from './constants.js';4import { DEFAULT_AVATAR_PATH } from './constants.js';
5import { extractFileFromZipBuffer, humanizedISO8601DateTime } from './util.js';5import { extractFileFromZipBuffer } from './util.js';
66
7/**7/**
8 * A parser for BYAF (Backyard Archive Format) files.8 * A parser for BYAF (Backyard Archive Format) files.
@@ -267,7 +267,7 @@ export class ByafParser {
267 extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by SillyTavern currently.267 extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by SillyTavern currently.
268 },268 },
269 // @ts-ignore Non-standard spec extension269 // @ts-ignore Non-standard spec extension
270 create_date: humanizedISO8601DateTime(),270 create_date: new Date().toISOString(),
271 };271 };
272 }272 }
273 /**273 /**
@@ -330,13 +330,10 @@ export class ByafParser {
330 * @returns {string} Chat data330 * @returns {string} Chat data
331 */331 */
332 static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {332 static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
333 const chatStartDate = scenario?.messages?.length == 0 ? humanizedISO8601DateTime() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;333 const chatStartDate = scenario?.messages?.length == 0 ? new Date().toISOString() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
334 const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';334 const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
335 /** @type {object[]} */335 /** @type {object[]} */
336 const chat = [{336 const chat = [{
337 user_name: userName,
338 character_name: characterName,
339 create_date: chatStartDate,
340 chat_metadata: {337 chat_metadata: {
341 scenario: scenario?.narrative ?? '',338 scenario: scenario?.narrative ?? '',
342 mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),339 mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
src/endpoints/characters.js+18 -21
@@ -14,7 +14,7 @@ import storage from 'node-persist';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';17import { deepMerge, humanizedDateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, read, write } from '../character-card-parser.js';19import { parse, read, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';20import { readWorldInfoFile } from './worldinfo.js';
@@ -414,7 +414,7 @@ const processCharacter = async (item, directories, { shallow }) => {
414 character['json_data'] = imgData;414 character['json_data'] = imgData;
415 const charStat = fs.statSync(path.join(directories.characters, item));415 const charStat = fs.statSync(path.join(directories.characters, item));
416 character['date_added'] = charStat.ctimeMs;416 character['date_added'] = charStat.ctimeMs;
417 character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs);417 character['create_date'] = jsonObject['create_date'] || new Date(Math.round(charStat.ctimeMs)).toISOString();
418 const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));418 const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));
419419
420 const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);420 const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);
@@ -452,7 +452,7 @@ function getCharaCardV2(jsonObject, directories, hoistDate = true) {
452 jsonObject = convertToV2(jsonObject, directories);452 jsonObject = convertToV2(jsonObject, directories);
453453
454 if (hoistDate && !jsonObject.create_date) {454 if (hoistDate && !jsonObject.create_date) {
455 jsonObject.create_date = humanizedISO8601DateTime();455 jsonObject.create_date = new Date().toISOString();
456 }456 }
457 } else {457 } else {
458 jsonObject = readFromV2(jsonObject);458 jsonObject = readFromV2(jsonObject);
@@ -486,7 +486,7 @@ function convertToV2(char, directories) {
486 depth_prompt_role: char.depth_prompt_role,486 depth_prompt_role: char.depth_prompt_role,
487 }, directories);487 }, directories);
488488
489 result.chat = char.chat ?? humanizedISO8601DateTime();489 result.chat = char.chat ?? `${char.name} - ${humanizedDateTime()}`;
490 result.create_date = char.create_date;490 result.create_date = char.create_date;
491491
492 return result;492 return result;
@@ -551,7 +551,7 @@ function readFromV2(char) {
551 char[charField] = v2Value;551 char[charField] = v2Value;
552 });552 });
553553
554 char['chat'] = char['chat'] ?? humanizedISO8601DateTime();554 char['chat'] = char['chat'] ?? `${char.name} - ${humanizedDateTime()}`;
555555
556 return char;556 return char;
557}557}
@@ -587,7 +587,7 @@ function charaFormatData(data, directories) {
587 // Old ST extension fields (for backward compatibility, will be deprecated)587 // Old ST extension fields (for backward compatibility, will be deprecated)
588 _.set(char, 'creatorcomment', data.creator_notes || '');588 _.set(char, 'creatorcomment', data.creator_notes || '');
589 _.set(char, 'avatar', 'none');589 _.set(char, 'avatar', 'none');
590 _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime());590 _.set(char, 'chat', data.ch_name + ' - ' + humanizedDateTime());
591 _.set(char, 'talkativeness', data.talkativeness || 0.5);591 _.set(char, 'talkativeness', data.talkativeness || 0.5);
592 _.set(char, 'fav', data.fav == 'true');592 _.set(char, 'fav', data.fav == 'true');
593 _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);593 _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
@@ -624,9 +624,6 @@ function charaFormatData(data, directories) {
624 _.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');624 _.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');
625 _.set(char, 'data.extensions.depth_prompt.depth', depth_value);625 _.set(char, 'data.extensions.depth_prompt.depth', depth_value);
626 _.set(char, 'data.extensions.depth_prompt.role', role_value);626 _.set(char, 'data.extensions.depth_prompt.role', role_value);
627 //_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime());
628 //_.set(char, 'data.extensions.avatar', 'none');
629 //_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime());
630627
631 // V3 fields628 // V3 fields
632 _.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []);629 _.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []);
@@ -746,8 +743,8 @@ async function importFromYaml(uploadPath, context, preservedFileName) {
746 'name': yamlData.name,743 'name': yamlData.name,
747 'description': yamlData.context ?? '',744 'description': yamlData.context ?? '',
748 'first_mes': yamlData.greeting ?? '',745 'first_mes': yamlData.greeting ?? '',
749 'create_date': humanizedISO8601DateTime(),746 'create_date': new Date().toISOString(),
750 'chat': `${yamlData.name} - ${humanizedISO8601DateTime()}`,747 'chat': `${yamlData.name} - ${humanizedDateTime()}`,
751 'personality': '',748 'personality': '',
752 'creatorcomment': '',749 'creatorcomment': '',
753 'avatar': 'none',750 'avatar': 'none',
@@ -800,7 +797,7 @@ async function importFromCharX(uploadPath, { request }, preservedFileName) {
800 }797 }
801798
802 unsetPrivateFields(card);799 unsetPrivateFields(card);
803 card['create_date'] = humanizedISO8601DateTime();800 card['create_date'] = new Date().toISOString();
804 card.name = sanitize(card.name);801 card.name = sanitize(card.name);
805 const fileName = preservedFileName || getPngName(card.name, request.user.directories);802 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
806 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);803 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
@@ -822,7 +819,7 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) {
822 * @param {Partial<ByafScenario>} scenario819 * @param {Partial<ByafScenario>} scenario
823 */820 */
824 const createChatAsCurrentPersona = (scenario) => {821 const createChatAsCurrentPersona = (scenario) => {
825 const chatName = sanitize(`${scenario.title || card.name} - ${humanizedISO8601DateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });822 const chatName = sanitize(`${scenario.title || card.name} - ${humanizedDateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });
826 const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);823 const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);
827 const dir = path.dirname(filePath);824 const dir = path.dirname(filePath);
828 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });825 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -898,7 +895,7 @@ async function importFromJson(uploadPath, { request }, preservedFileName) {
898 importRisuSprites(request.user.directories, jsonData);895 importRisuSprites(request.user.directories, jsonData);
899 unsetPrivateFields(jsonData);896 unsetPrivateFields(jsonData);
900 jsonData = readFromV2(jsonData);897 jsonData = readFromV2(jsonData);
901 jsonData['create_date'] = humanizedISO8601DateTime();898 jsonData['create_date'] = new Date().toISOString();
902 const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);899 const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
903 const char = JSON.stringify(jsonData);900 const char = JSON.stringify(jsonData);
904 const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request);901 const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request);
@@ -917,10 +914,10 @@ async function importFromJson(uploadPath, { request }, preservedFileName) {
917 'personality': jsonData.personality ?? '',914 'personality': jsonData.personality ?? '',
918 'first_mes': jsonData.first_mes ?? '',915 'first_mes': jsonData.first_mes ?? '',
919 'avatar': 'none',916 'avatar': 'none',
920 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),917 'chat': jsonData.name + ' - ' + humanizedDateTime(),
921 'mes_example': jsonData.mes_example ?? '',918 'mes_example': jsonData.mes_example ?? '',
922 'scenario': jsonData.scenario ?? '',919 'scenario': jsonData.scenario ?? '',
923 'create_date': humanizedISO8601DateTime(),920 'create_date': new Date().toISOString(),
924 'talkativeness': jsonData.talkativeness ?? 0.5,921 'talkativeness': jsonData.talkativeness ?? 0.5,
925 'creator': jsonData.creator ?? '',922 'creator': jsonData.creator ?? '',
926 'tags': jsonData.tags ?? '',923 'tags': jsonData.tags ?? '',
@@ -943,10 +940,10 @@ async function importFromJson(uploadPath, { request }, preservedFileName) {
943 'personality': '',940 'personality': '',
944 'first_mes': jsonData.char_greeting ?? '',941 'first_mes': jsonData.char_greeting ?? '',
945 'avatar': 'none',942 'avatar': 'none',
946 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),943 'chat': jsonData.name + ' - ' + humanizedDateTime(),
947 'mes_example': jsonData.example_dialogue ?? '',944 'mes_example': jsonData.example_dialogue ?? '',
948 'scenario': jsonData.world_scenario ?? '',945 'scenario': jsonData.world_scenario ?? '',
949 'create_date': humanizedISO8601DateTime(),946 'create_date': new Date().toISOString(),
950 'talkativeness': jsonData.talkativeness ?? 0.5,947 'talkativeness': jsonData.talkativeness ?? 0.5,
951 'creator': jsonData.creator ?? '',948 'creator': jsonData.creator ?? '',
952 'tags': jsonData.tags ?? '',949 'tags': jsonData.tags ?? '',
@@ -981,7 +978,7 @@ async function importFromPng(uploadPath, { request }, preservedFileName) {
981 importRisuSprites(request.user.directories, jsonData);978 importRisuSprites(request.user.directories, jsonData);
982 unsetPrivateFields(jsonData);979 unsetPrivateFields(jsonData);
983 jsonData = readFromV2(jsonData);980 jsonData = readFromV2(jsonData);
984 jsonData['create_date'] = humanizedISO8601DateTime();981 jsonData['create_date'] = new Date().toISOString();
985 const char = JSON.stringify(jsonData);982 const char = JSON.stringify(jsonData);
986 const result = await writeCharacterData(uploadPath, char, pngName, request);983 const result = await writeCharacterData(uploadPath, char, pngName, request);
987 fs.unlinkSync(uploadPath);984 fs.unlinkSync(uploadPath);
@@ -1000,10 +997,10 @@ async function importFromPng(uploadPath, { request }, preservedFileName) {
1000 'personality': jsonData.personality ?? '',997 'personality': jsonData.personality ?? '',
1001 'first_mes': jsonData.first_mes ?? '',998 'first_mes': jsonData.first_mes ?? '',
1002 'avatar': 'none',999 'avatar': 'none',
1003 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(),1000 'chat': jsonData.name + ' - ' + humanizedDateTime(),
1004 'mes_example': jsonData.mes_example ?? '',1001 'mes_example': jsonData.mes_example ?? '',
1005 'scenario': jsonData.scenario ?? '',1002 'scenario': jsonData.scenario ?? '',
1006 'create_date': humanizedISO8601DateTime(),1003 'create_date': new Date().toISOString(),
1007 'talkativeness': jsonData.talkativeness ?? 0.5,1004 'talkativeness': jsonData.talkativeness ?? 0.5,
1008 'creator': jsonData.creator ?? '',1005 'creator': jsonData.creator ?? '',
1009 'tags': jsonData.tags ?? '',1006 'tags': jsonData.tags ?? '',
src/endpoints/chats.js+22 -24
@@ -11,7 +11,7 @@ import _ from 'lodash';
11import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';11import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
12import {12import {
13 getConfigValue,13 getConfigValue,
14 humanizedISO8601DateTime,14 humanizedDateTime,
15 tryParse,15 tryParse,
16 generateTimestamp,16 generateTimestamp,
17 removeOldBackups,17 removeOldBackups,
@@ -106,9 +106,7 @@ process.on('exit', () => {
106function importOobaChat(userName, characterName, jsonData) {106function importOobaChat(userName, characterName, jsonData) {
107 /** @type {object[]} */107 /** @type {object[]} */
108 const chat = [{108 const chat = [{
109 user_name: userName,109 chat_metadata: {},
110 character_name: characterName,
111 create_date: humanizedISO8601DateTime(),
112 }];110 }];
113111
114 for (const arr of jsonData.data_visible) {112 for (const arr of jsonData.data_visible) {
@@ -116,8 +114,9 @@ function importOobaChat(userName, characterName, jsonData) {
116 const userMessage = {114 const userMessage = {
117 name: userName,115 name: userName,
118 is_user: true,116 is_user: true,
119 send_date: humanizedISO8601DateTime(),117 send_date: new Date().toISOString(),
120 mes: arr[0],118 mes: arr[0],
119 extra: {},
121 };120 };
122 chat.push(userMessage);121 chat.push(userMessage);
123 }122 }
@@ -125,8 +124,9 @@ function importOobaChat(userName, characterName, jsonData) {
125 const charMessage = {124 const charMessage = {
126 name: characterName,125 name: characterName,
127 is_user: false,126 is_user: false,
128 send_date: humanizedISO8601DateTime(),127 send_date: new Date().toISOString(),
129 mes: arr[1],128 mes: arr[1],
129 extra: {},
130 };130 };
131 chat.push(charMessage);131 chat.push(charMessage);
132 }132 }
@@ -145,9 +145,7 @@ function importOobaChat(userName, characterName, jsonData) {
145function importAgnaiChat(userName, characterName, jsonData) {145function importAgnaiChat(userName, characterName, jsonData) {
146 /** @type {object[]} */146 /** @type {object[]} */
147 const chat = [{147 const chat = [{
148 user_name: userName,148 chat_metadata: {},
149 character_name: characterName,
150 create_date: humanizedISO8601DateTime(),
151 }];149 }];
152150
153 for (const message of jsonData.messages) {151 for (const message of jsonData.messages) {
@@ -155,8 +153,9 @@ function importAgnaiChat(userName, characterName, jsonData) {
155 chat.push({153 chat.push({
156 name: isUser ? userName : characterName,154 name: isUser ? userName : characterName,
157 is_user: isUser,155 is_user: isUser,
158 send_date: humanizedISO8601DateTime(),156 send_date: new Date().toISOString(),
159 mes: message.msg,157 mes: message.msg,
158 extra: {},
160 });159 });
161 }160 }
162161
@@ -178,16 +177,15 @@ function importCAIChat(userName, characterName, jsonData) {
178 */177 */
179 function convert(history) {178 function convert(history) {
180 const starter = {179 const starter = {
181 user_name: userName,180 chat_metadata: {},
182 character_name: characterName,
183 create_date: humanizedISO8601DateTime(),
184 };181 };
185182
186 const historyData = history.msgs.map((msg) => ({183 const historyData = history.msgs.map((msg) => ({
187 name: msg.src.is_human ? userName : characterName,184 name: msg.src.is_human ? userName : characterName,
188 is_user: msg.src.is_human,185 is_user: msg.src.is_human,
189 send_date: humanizedISO8601DateTime(),186 send_date: new Date().toISOString(),
190 mes: msg.text,187 mes: msg.text,
188 extra: {},
191 }));189 }));
192190
193 return [starter, ...historyData];191 return [starter, ...historyData];
@@ -215,7 +213,8 @@ function importKoboldLiteChat(_userName, _characterName, data) {
215 name: isUser ? header.user_name : header.character_name,213 name: isUser ? header.user_name : header.character_name,
216 is_user: isUser,214 is_user: isUser,
217 mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(),215 mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(),
218 send_date: Date.now(),216 send_date: new Date().toISOString(),
217 extra: {},
219 };218 };
220 }219 }
221220
@@ -276,9 +275,7 @@ function flattenChubChat(userName, characterName, lines) {
276function importRisuChat(userName, characterName, jsonData) {275function importRisuChat(userName, characterName, jsonData) {
277 /** @type {object[]} */276 /** @type {object[]} */
278 const chat = [{277 const chat = [{
279 user_name: userName,278 chat_metadata: {},
280 character_name: characterName,
281 create_date: humanizedISO8601DateTime(),
282 }];279 }];
283280
284 for (const message of jsonData.data.message) {281 for (const message of jsonData.data.message) {
@@ -286,8 +283,9 @@ function importRisuChat(userName, characterName, jsonData) {
286 chat.push({283 chat.push({
287 name: message.name ?? (isUser ? userName : characterName),284 name: message.name ?? (isUser ? userName : characterName),
288 is_user: isUser,285 is_user: isUser,
289 send_date: Number(message.time ?? Date.now()),286 send_date: new Date(Number(message.time ?? Date.now())).toISOString(),
290 mes: message.data ?? '',287 mes: message.data ?? '',
288 extra: {},
291 });289 });
292 }290 }
293291
@@ -417,7 +415,7 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
417 if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {415 if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {
418 chatData.chat_items = (itemCounter - 1);416 chatData.chat_items = (itemCounter - 1);
419 chatData.mes = jsonData['mes'] || '[The message is empty]';417 chatData.mes = jsonData['mes'] || '[The message is empty]';
420 chatData.last_mes = jsonData['send_date'] || stats.mtimeMs;418 chatData.last_mes = jsonData['send_date'] || new Date(Math.round(stats.mtimeMs)).toISOString();
421419
422 res(chatData);420 res(chatData);
423 } else {421 } else {
@@ -623,7 +621,7 @@ router.post('/group/import', function (request, response) {
623 return response.sendStatus(400);621 return response.sendStatus(400);
624 }622 }
625623
626 const chatname = humanizedISO8601DateTime();624 const chatname = humanizedDateTime();
627 const pathToUpload = path.join(filedata.destination, filedata.filename);625 const pathToUpload = path.join(filedata.destination, filedata.filename);
628 const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);626 const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);
629 fs.copyFileSync(pathToUpload, pathToNewFile);627 fs.copyFileSync(pathToUpload, pathToNewFile);
@@ -675,7 +673,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
675 }673 }
676674
677 const handleChat = (chat) => {675 const handleChat = (chat) => {
678 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;676 const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
679 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);677 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
680 fileNames.push(fileName);678 fileNames.push(fileName);
681 writeFileAtomicSync(filePath, chat, 'utf8');679 writeFileAtomicSync(filePath, chat, 'utf8');
@@ -714,7 +712,7 @@ router.post('/import', validateAvatarUrlMiddleware, function (request, response)
714 console.warn('Failed to flatten Chub Chat data: ', error);712 console.warn('Failed to flatten Chub Chat data: ', error);
715 }713 }
716714
717 const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`;715 const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
718 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);716 const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
719 fileNames.push(fileName);717 fileNames.push(fileName);
720 if (flattenedChat !== data) {718 if (flattenedChat !== data) {
@@ -879,7 +877,7 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
879 }877 }
880878
881 const lastMessage = messages[messages.length - 1];879 const lastMessage = messages[messages.length - 1];
882 const lastMesDate = lastMessage?.send_date || Math.round(fs.statSync(chatFile.path).mtimeMs);880 const lastMesDate = lastMessage?.send_date || new Date(fs.statSync(chatFile.path).mtimeMs).toISOString();
883881
884 // If no search query, just return metadata882 // If no search query, just return metadata
885 if (!query) {883 if (!query) {
src/endpoints/groups.js+2 -2
@@ -6,7 +6,7 @@ import express from 'express';
6import sanitize from 'sanitize-filename';6import sanitize from 'sanitize-filename';
7import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';7import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
88
9import { color, humanizedISO8601DateTime, tryParse } from '../util.js';9import { color, tryParse } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111
12export const router = express.Router();12export const router = express.Router();
@@ -127,7 +127,7 @@ router.post('/all', (request, response) => {
127 const group = JSON.parse(fileContents);127 const group = JSON.parse(fileContents);
128 const groupStat = fs.statSync(filePath);128 const groupStat = fs.statSync(filePath);
129 group['date_added'] = groupStat.birthtimeMs;129 group['date_added'] = groupStat.birthtimeMs;
130 group['create_date'] = humanizedISO8601DateTime(groupStat.birthtimeMs);130 group['create_date'] = new Date(groupStat.birthtimeMs).toISOString();
131131
132 let chat_size = 0;132 let chat_size = 0;
133 let date_last_chat = 0;133 let date_last_chat = 0;
src/endpoints/stats.js+69 -67
@@ -12,6 +12,21 @@ import { getAllUserHandles, getUserDirectories } from '../users.js';
1212
13const STATS_FILE = 'stats.json';13const STATS_FILE = 'stats.json';
1414
15const monthNames = [
16 'January',
17 'February',
18 'March',
19 'April',
20 'May',
21 'June',
22 'July',
23 'August',
24 'September',
25 'October',
26 'November',
27 'December',
28];
29
15/**30/**
16 * @type {Map<string, Object>} The stats object for each user.31 * @type {Map<string, Object>} The stats object for each user.
17 */32 */
@@ -23,92 +38,79 @@ const TIMESTAMPS = new Map();
2338
24/**39/**
25 * Convert a timestamp to an integer timestamp.40 * Convert a timestamp to an integer timestamp.
26 * (sorry, it's momentless for now, didn't want to add a package just for this)
27 * This function can handle several different timestamp formats:41 * This function can handle several different timestamp formats:
28 * 1. Unix timestamps (the number of seconds since the Unix Epoch)42 * 1. Date.now timestamps (the number of milliseconds since the Unix Epoch)
29 * 2. ST "humanized" timestamps, formatted like "YYYY-MM-DD @HHh MMm SSs ms"43 * 2. ST "humanized" timestamps, formatted like `YYYY-MM-DD@HHhMMmSSsMSms`
30 * 3. Date strings in the format "Month DD, YYYY H:MMam/pm"44 * 3. Date strings in the format `Month DD, YYYY H:MMam/pm`
45 * 4. ISO 8601 formatted strings
46 * 5. Date objects
31 *47 *
32 * The function returns the timestamp as the number of milliseconds since48 * The function returns the timestamp as the number of milliseconds since
33 * the Unix Epoch, which can be converted to a JavaScript Date object with new Date().49 * the Unix Epoch, which can be converted to a JavaScript Date object with new Date().
34 *50 *
35 * @param {string|number} timestamp - The timestamp to convert.51 * @param {string|number|Date} timestamp - The timestamp to convert.
36 * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.52 * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.
37 *53 *
38 * @example54 * @example
39 * // Unix timestamp55 * // Unix timestamp
40 * timestampToMoment(1609459200);56 * parseTimestamp(1609459200);
41 * // ST humanized timestamp57 * // ST humanized timestamp
42 * timestampToMoment("2021-01-01 \@00h 00m 00s 000ms");58 * parseTimestamp("2021-01-01 \@00h 00m 00s 000ms");
43 * // Date string59 * // Date string
44 * timestampToMoment("January 1, 2021 12:00am");60 * parseTimestamp("January 1, 2021 12:00am");
45 */61 */
46function timestampToMoment(timestamp) {62function parseTimestamp(timestamp) {
47 if (!timestamp) {63 if (!timestamp) {
48 return 0;64 return 0;
49 }65 }
5066
51 if (typeof timestamp === 'number') {67 // Date object
52 return timestamp;68 if (timestamp instanceof Date) {
53 }69 return timestamp.getTime();
54
55 const pattern1 =
56 /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/;
57 const replacement1 = (
58 match,
59 year,
60 month,
61 day,
62 hour,
63 minute,
64 second,
65 millisecond,
66 ) => {
67 return `${year}-${month.padStart(2, '0')}-${day.padStart(
68 2,
69 '0',
70 )}T${hour.padStart(2, '0')}:${minute.padStart(
71 2,
72 '0',
73 )}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`;
74 };
75 const isoTimestamp1 = timestamp.replace(pattern1, replacement1);
76 if (!isNaN(Number(new Date(isoTimestamp1)))) {
77 return new Date(isoTimestamp1).getTime();
78 }70 }
7971
80 const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i;72 // Unix time
81 const replacement2 = (match, month, day, year, hour, minute, meridiem) => {73 if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
82 const monthNames = [74 const unixTime = Number(timestamp);
83 'January',75 const isValid = Number.isFinite(unixTime) && !Number.isNaN(unixTime) && unixTime >= 0;
84 'February',76 if (!isValid) return 0;
85 'March',77 return new Date(unixTime).getTime();
86 'April',78 }
87 'May',79
88 'June',80 // ISO 8601 format
89 'July',81 const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
90 'August',82 if (isoPattern.test(timestamp)) {
91 'September',83 return new Date(timestamp).getTime();
92 'October',84 }
93 'November',85
94 'December',86 let dateFormats = [];
95 ];87
88 // meridiem-based format
89 const convertFromMeridiemBased = (_, month, day, year, hour, minute, meridiem) => {
96 const monthNum = monthNames.indexOf(month) + 1;90 const monthNum = monthNames.indexOf(month) + 1;
97 const hour24 =91 const hour24 = meridiem.toLowerCase() === 'pm' ? (parseInt(hour, 10) % 12) + 12 : parseInt(hour, 10) % 12;
98 meridiem.toLowerCase() === 'pm'92 return `${year}-${monthNum}-${day.padStart(2, '0')}T${hour24.toString().padStart(2, '0')}:${minute.padStart(2, '0')}:00`;
99 ? (parseInt(hour, 10) % 12) + 1293 };
100 : parseInt(hour, 10) % 12;94 // June 19, 2023 2:20pm
101 return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart(95 dateFormats.push({ callback: convertFromMeridiemBased, pattern: /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i });
102 2,96
103 '0',97 // ST "humanized" format patterns
104 )}T${hour24.toString().padStart(2, '0')}:${minute.padStart(98 const convertFromHumanized = (_, year, month, day, hour, min, sec, ms) => {
105 2,99 ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';
106 '0',100 return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;
107 )}:00Z`;
108 };101 };
109 const isoTimestamp2 = timestamp.replace(pattern2, replacement2);102 // 2024-07-12@01h31m37s123ms
110 if (!isNaN(Number(new Date(isoTimestamp2)))) {103 dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s(\d{1,3})ms/ });
111 return new Date(isoTimestamp2).getTime();104 // 2024-7-12@01h31m37s
105 dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });
106 // 2024-6-5 @14h 56m 50s 682ms
107 dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/ });
108
109 for (const x of dateFormats) {
110 const rgxMatch = timestamp.match(x.pattern);
111 if (!rgxMatch) continue;
112 const isoTimestamp = x.callback(...rgxMatch);
113 return new Date(isoTimestamp).getTime();
112 }114 }
113115
114 return 0;116 return 0;
@@ -416,7 +418,7 @@ function calculateTotalGenTimeAndWordCount(
416 // If this is the first user message, set the first chat time418 // If this is the first user message, set the first chat time
417 if (json.is_user) {419 if (json.is_user) {
418 //get min between firstChatTime and timestampToMoment(json.send_date)420 //get min between firstChatTime and timestampToMoment(json.send_date)
419 firstChatTime = Math.min(timestampToMoment(json.send_date), firstChatTime);421 firstChatTime = Math.min(parseTimestamp(json.send_date), firstChatTime);
420 }422 }
421 } catch (error) {423 } catch (error) {
422 console.error(`Error parsing line ${line}: ${error}`);424 console.error(`Error parsing line ${line}: ${error}`);
src/util.js+21 -11
@@ -389,17 +389,27 @@ export function uuidv4() {
389 });389 });
390}390}
391391
392export function humanizedISO8601DateTime(date) {392/**
393 let baseDate = typeof date === 'number' ? new Date(date) : new Date();393 * Gets a humanized date time string from a given timestamp.
394 let humanYear = baseDate.getFullYear();394 * @param {number} timestamp Timestamp in milliseconds
395 let humanMonth = (baseDate.getMonth() + 1);395 * @returns {string} Humanized date time string in the format `YYYY-MM-DD@HHhMMmSSsMSms`
396 let humanDate = baseDate.getDate();396 */
397 let humanHour = (baseDate.getHours() < 10 ? '0' : '') + baseDate.getHours();397export function humanizedDateTime(timestamp = Date.now()) {
398 let humanMinute = (baseDate.getMinutes() < 10 ? '0' : '') + baseDate.getMinutes();398 const date = new Date(timestamp);
399 let humanSecond = (baseDate.getSeconds() < 10 ? '0' : '') + baseDate.getSeconds();399 const dt = {
400 let humanMillisecond = (baseDate.getMilliseconds() < 10 ? '0' : '') + baseDate.getMilliseconds();400 year: date.getFullYear(),
401 let HumanizedDateTime = (humanYear + '-' + humanMonth + '-' + humanDate + ' @' + humanHour + 'h ' + humanMinute + 'm ' + humanSecond + 's ' + humanMillisecond + 'ms');401 month: date.getMonth() + 1,
402 return HumanizedDateTime;402 day: date.getDate(),
403 hour: date.getHours(),
404 minute: date.getMinutes(),
405 second: date.getSeconds(),
406 millisecond: date.getMilliseconds(),
407 };
408 for (const key in dt) {
409 const padLength = key === 'millisecond' ? 3 : 2;
410 dt[key] = dt[key].toString().padStart(padLength, '0');
411 }
412 return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s${dt.millisecond}ms`;
403}413}
404414
405export function tryParse(str) {415export function tryParse(str) {