Calendar local datetime format and download buttons

This commit is contained in:
Natsumi
2025-11-07 01:22:33 +11:00
parent 6ad7052485
commit 18c2b42852
10 changed files with 164 additions and 60 deletions

View File

@@ -221,5 +221,28 @@ namespace VRCX
{ {
MainForm.Instance.BeginInvoke(new MethodInvoker(() => { MainForm.Instance.SetTrayIconNotification(notify); })); MainForm.Instance.BeginInvoke(new MethodInvoker(() => { MainForm.Instance.SetTrayIconNotification(notify); }));
} }
public override void OpenCalendarFile(string icsContent)
{
// validate content
if (!icsContent.StartsWith("BEGIN:VCALENDAR") ||
!icsContent.EndsWith("END:VCALENDAR"))
throw new Exception("Invalid calendar file");
try
{
var tempPath = Path.Combine(Program.AppDataDirectory, "event.ics");
File.WriteAllText(tempPath, icsContent);
Process.Start(new ProcessStartInfo
{
FileName = tempPath,
UseShellExecute = true
})?.Dispose();
}
catch (Exception ex)
{
logger.Error(ex, "Failed to open calendar file");
}
}
} }
} }

View File

@@ -28,6 +28,7 @@ namespace VRCX
public abstract void CopyImageToClipboard(string path); public abstract void CopyImageToClipboard(string path);
public abstract void FlashWindow(); public abstract void FlashWindow();
public abstract void SetUserAgent(); public abstract void SetUserAgent();
public abstract void OpenCalendarFile(string icsContent);
// Folders // Folders
public abstract string GetVRChatAppDataLocation(); public abstract string GetVRChatAppDataLocation();

View File

@@ -147,5 +147,9 @@ namespace VRCX
public override void SetTrayIconNotification(bool notify) public override void SetTrayIconNotification(bool notify)
{ {
} }
public override void OpenCalendarFile(string icsContent)
{
}
} }
} }

View File

@@ -7,7 +7,8 @@
:width="50" :width="50"
:stroke-width="3" :stroke-width="3"
:percentage="updateProgress" :percentage="updateProgress"
:format="updateProgressText"></el-progress> :format="updateProgressText"
style="padding: 7px"></el-progress>
</div> </div>
<div v-else-if="pendingVRCXUpdate || pendingVRCXInstall" class="pending-update"> <div v-else-if="pendingVRCXUpdate || pendingVRCXInstall" class="pending-update">
<el-button <el-button

View File

@@ -1721,10 +1721,12 @@
"search_no_this_month": "No events this month", "search_no_this_month": "No events this month",
"event_card": { "event_card": {
"category": "Category", "category": "Category",
"interested_user": "Interested User", "interested_user": "Users Interested",
"close_time": "Close Instance After End", "close_time": "Close Instance After End",
"created": "Created Date", "created": "Created Date",
"description": "Description" "description": "Description",
"export_to_calendar": "Export to Calendar",
"download_ics": "Download .ics"
} }
}, },
"moderate_group": { "moderate_group": {

View File

@@ -1,8 +1,64 @@
import { useAppearanceSettingsStore } from '../../../stores'; import { useAppearanceSettingsStore } from '../../../stores';
function padZero(num) {
return String(num).padStart(2, '0');
}
function toIsoLong(date) {
const y = date.getFullYear();
const m = padZero(date.getMonth() + 1);
const d = padZero(date.getDate());
const hh = padZero(date.getHours());
const mm = padZero(date.getMinutes());
const ss = padZero(date.getSeconds());
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
function toLocalShort(date, dateFormat, hour12) {
return date
.toLocaleDateString(dateFormat, {
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
hourCycle: hour12 ? 'h12' : 'h23'
})
.replace(' AM', 'am')
.replace(' PM', 'pm')
.replace(',', '');
}
function toLocalLong(date, dateFormat, hour12) {
return date.toLocaleDateString(dateFormat, {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: hour12 ? 'h12' : 'h23'
});
}
function toLocalTime(date, dateFormat, hour12) {
return date.toLocaleTimeString(dateFormat, {
hour: 'numeric',
minute: 'numeric',
hourCycle: hour12 ? 'h12' : 'h23'
});
}
function toLocalDate(date, dateFormat) {
return date.toLocaleDateString(dateFormat, {
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
}
/** /**
* @param {string} dateStr * @param {string} dateStr
* @param {'long'|'short'} format * @param {'long'|'short'|'time'|'date'} format
* @returns {string} * @returns {string}
*/ */
function formatDateFilter(dateStr, format) { function formatDateFilter(dateStr, format) {
@@ -22,60 +78,23 @@ function formatDateFilter(dateStr, format) {
return '-'; return '-';
} }
function padZero(num) {
return String(num).padStart(2, '0');
}
function toIsoLong(date) {
const y = date.getFullYear();
const m = padZero(date.getMonth() + 1);
const d = padZero(date.getDate());
const hh = padZero(date.getHours());
const mm = padZero(date.getMinutes());
const ss = padZero(date.getSeconds());
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
let dateFormat = 'en-gb'; let dateFormat = 'en-gb';
if (!isoFormat && currentCulture) { if (!isoFormat && currentCulture) {
dateFormat = currentCulture; dateFormat = currentCulture;
} }
function toLocalShort(date) {
return date
.toLocaleDateString(dateFormat, {
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
hourCycle: hour12 ? 'h12' : 'h23'
})
.replace(' AM', 'am')
.replace(' PM', 'pm')
.replace(',', '');
}
if (isoFormat) { if (isoFormat && format === 'long') {
if (format === 'long') { return toIsoLong(dt);
return toIsoLong(dt); } else if (format === 'long') {
} return toLocalLong(dt, dateFormat, hour12);
if (format === 'short') { } else if (format === 'short') {
return toLocalShort(dt); return toLocalShort(dt, dateFormat, hour12);
} } else if (format === 'time') {
return toLocalTime(dt, dateFormat, hour12);
} else if (format === 'date') {
return toLocalDate(dt, dateFormat);
} else { } else {
if (format === 'long') { console.warn(`Unknown date format: ${format}`);
return dt.toLocaleDateString(dateFormat, {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: hour12 ? 'h12' : 'h23'
});
}
if (format === 'short') {
return toLocalShort(dt);
}
} }
return '-'; return '-';

View File

@@ -33,6 +33,8 @@ function timeToText(sec, isNeedSeconds = false) {
n %= 60; n %= 60;
} }
if (isNeedSeconds || (arr.length === 0 && n < 60)) { if (isNeedSeconds || (arr.length === 0 && n < 60)) {
// round to 5 seconds
n = Math.floor((n + 2.5) / 5) * 5;
arr.push(`${n}s`); arr.push(`${n}s`);
} }
return arr.join(' '); return arr.join(' ');

View File

@@ -190,6 +190,7 @@ declare global {
FlashWindow(): Promise<void>; FlashWindow(): Promise<void>;
SetUserAgent(): Promise<void>; SetUserAgent(): Promise<void>;
SetTrayIconNotification(notify: boolean): Promise<void>; SetTrayIconNotification(notify: boolean): Promise<void>;
OpenCalendarFile(icsContent: string): Promise<void>;
// Common Functions // Common Functions
GetColourFromUserID(userId: string): Promise<number>; GetColourFromUserID(userId: string): Promise<number>;

View File

@@ -14,6 +14,16 @@
</div> </div>
</template> </template>
<el-descriptions-item>
<el-button type="default" :icon="Calendar" size="small" @click="openCalendarEvent(event)">{{
t('dialog.group_calendar.event_card.export_to_calendar')
}}</el-button>
</el-descriptions-item>
<el-descriptions-item>
<el-button type="default" :icon="Download" size="small" @click="downloadEventIcs(event)">{{
t('dialog.group_calendar.event_card.download_ics')
}}</el-button>
</el-descriptions-item>
<el-descriptions-item :label="t('dialog.group_calendar.event_card.category')"> <el-descriptions-item :label="t('dialog.group_calendar.event_card.category')">
{{ capitalizeFirst(event.category) }} {{ capitalizeFirst(event.category) }}
</el-descriptions-item> </el-descriptions-item>
@@ -24,7 +34,7 @@
{{ event.closeInstanceAfterEndMinutes + ' min' }} {{ event.closeInstanceAfterEndMinutes + ' min' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :label="t('dialog.group_calendar.event_card.created')"> <el-descriptions-item :label="t('dialog.group_calendar.event_card.created')">
{{ dayjs(event.createdAt).format('YYYY-MM-DD HH:mm') }} {{ formatDateFilter(event.createdAt, 'long') }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :label="t('dialog.group_calendar.event_card.description')"> <el-descriptions-item :label="t('dialog.group_calendar.event_card.description')">
{{ event.description }} {{ event.description }}
@@ -53,12 +63,13 @@
</template> </template>
<script setup> <script setup>
import { Check } from '@element-plus/icons-vue'; import { Calendar, Check, Download } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs'; import { AppDebug } from '../../../service/appConfig';
import { formatDateFilter } from '../../../shared/utils';
import { useGroupStore } from '../../../stores'; import { useGroupStore } from '../../../stores';
const { t } = useI18n(); const { t } = useI18n();
@@ -107,12 +118,51 @@
if (props.mode === 'timeline') { if (props.mode === 'timeline') {
return formatTimeRange(props.event.startsAt, props.event.endsAt); return formatTimeRange(props.event.startsAt, props.event.endsAt);
} else { } else {
return `${dayjs(props.event.startsAt).format('MM-DD ddd HH:mm')} - ${dayjs(props.event.endsAt).format('HH:mm')}`; return `${formatDateFilter(props.event.startsAt, 'short')} - ${formatDateFilter(props.event.endsAt, 'short')}`;
} }
}); });
async function openCalendarEvent(event) {
const content = await getCalendarIcs(event);
if (!content) return;
await AppApi.OpenCalendarFile(content);
}
async function getCalendarIcs(event) {
const url = `${AppDebug.endpointDomain}/calendar/${event.ownerId}/${event.id}.ics`;
try {
const response = await webApiService.execute({
url,
method: 'GET'
});
if (response.status !== 200) {
throw new Error(`Error: ${response.data}`);
}
return response.data;
} catch (error) {
ElMessage({
message: `Failed to download .ics file, ${error.message}`,
type: 'error'
});
console.error('Failed to download .ics file:', error);
}
}
async function downloadEventIcs(event) {
const content = await getCalendarIcs(event);
if (!content) return;
const blob = new Blob([content], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${event.id}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
const formatTimeRange = (startsAt, endsAt) => const formatTimeRange = (startsAt, endsAt) =>
`${dayjs(startsAt).format('HH:mm')} - ${dayjs(endsAt).format('HH:mm')}`; `${formatDateFilter(startsAt, 'time')} - ${formatDateFilter(endsAt, 'time')}`;
const capitalizeFirst = (str) => str?.charAt(0).toUpperCase() + str?.slice(1); const capitalizeFirst = (str) => str?.charAt(0).toUpperCase() + str?.slice(1);

View File

@@ -122,6 +122,7 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { formatDateFilter } from '../../../shared/utils';
import { groupRequest } from '../../../api'; import { groupRequest } from '../../../api';
import { replaceBioSymbols } from '../../../shared/utils'; import { replaceBioSymbols } from '../../../shared/utils';
import { useGroupStore } from '../../../stores'; import { useGroupStore } from '../../../stores';
@@ -297,7 +298,7 @@
const timeGroups = {}; const timeGroups = {};
eventsForDay.forEach((event) => { eventsForDay.forEach((event) => {
const startTimeKey = dayjs(event.startsAt).format('HH:mm'); const startTimeKey = formatDateFilter(event.startsAt, 'time');
if (!timeGroups[startTimeKey]) { if (!timeGroups[startTimeKey]) {
timeGroups[startTimeKey] = []; timeGroups[startTimeKey] = [];
} }
@@ -314,7 +315,7 @@
.sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt))); .sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt)));
}); });
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD'); const formatDateKey = (date) => formatDateFilter(date, 'date');
function getGroupName(event) { function getGroupName(event) {
if (!event) return ''; if (!event) return '';