mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 02:01:40 +00:00
ADD - build persistent calendar app
This commit is contained in:
+40
-1
@@ -46,7 +46,13 @@ import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
type AppMessage = {
|
||||
type?: string
|
||||
data?: MailEventData | MarketplaceEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload
|
||||
data?:
|
||||
| CalendarReminderData
|
||||
| MailEventData
|
||||
| MarketplaceEventData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
| PhoneOpenPayload
|
||||
}
|
||||
|
||||
type SimPickerPayload = {
|
||||
@@ -73,6 +79,15 @@ type MarketplaceEventData = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
type CalendarReminderData = {
|
||||
device?: PhoneNotificationDevicePayload
|
||||
eventId?: string
|
||||
eventTitle?: string
|
||||
startsAt?: number
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const PHONE_BASE_SCALE = 0.69
|
||||
@@ -195,6 +210,30 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
notifications.show(notification)
|
||||
void marketplace.loadCounts()
|
||||
} else if (event.data?.type === 'calendar:reminder' && event.data.data) {
|
||||
const data = event.data.data as CalendarReminderData
|
||||
const startsAt = Number(data.startsAt) || Date.now()
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'calendar',
|
||||
subtitle: new Intl.DateTimeFormat(phone.lang, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(startsAt),
|
||||
text:
|
||||
data.text ??
|
||||
phone.t('Apps.calendar.reminderNotification', {
|
||||
title: data.eventTitle ?? '',
|
||||
}),
|
||||
title: data.title ?? phone.t('Apps.calendar.name'),
|
||||
}
|
||||
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
} else if (event.data?.type === 'contacts:changed') {
|
||||
void calls.loadContacts()
|
||||
} else if (event.data?.type === 'calls:changed') {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Calendar">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="32" y1="20" x2="224" y2="236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff765f"/>
|
||||
<stop offset="1" stop-color="#e93147"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#8c1021" flood-opacity=".32"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="58" fill="url(#bg)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="42" y="50" width="172" height="164" rx="28" fill="#fff"/>
|
||||
<path d="M42 78c0-15.5 12.5-28 28-28h116c15.5 0 28 12.5 28 28v26H42V78Z" fill="#f8e9eb"/>
|
||||
<rect x="77" y="36" width="14" height="42" rx="7" fill="#fff"/>
|
||||
<rect x="165" y="36" width="14" height="42" rx="7" fill="#fff"/>
|
||||
<g fill="#d5d8df">
|
||||
<rect x="67" y="124" width="26" height="21" rx="7"/><rect x="104" y="124" width="26" height="21" rx="7"/><rect x="141" y="124" width="26" height="21" rx="7"/>
|
||||
<rect x="67" y="157" width="26" height="21" rx="7"/><rect x="141" y="157" width="26" height="21" rx="7"/>
|
||||
</g>
|
||||
<rect x="101" y="153" width="32" height="29" rx="9" fill="#ff4259"/>
|
||||
<path d="m109 167 7 7 11-14" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<circle cx="201" cy="198" r="27" fill="#272b36" stroke="#fff" stroke-width="6"/>
|
||||
<path d="M201 183v16l10 7" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -10,9 +10,11 @@ describe('app registry', () => {
|
||||
(app) => app.route === null || app.route === `/apps/${app.id}`,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
PHONE_APPS.every(
|
||||
(app) => typeof app.iconImage === 'string' && app.iconImage.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({
|
||||
dockOrder: 0,
|
||||
labelKey: 'Apps.phone.name',
|
||||
@@ -28,6 +30,11 @@ describe('app registry', () => {
|
||||
labelKey: 'Apps.weather.name',
|
||||
route: '/apps/weather',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
|
||||
gridOrder: 20,
|
||||
labelKey: 'Apps.calendar.name',
|
||||
route: '/apps/calendar',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({
|
||||
dockOrder: null,
|
||||
gridOrder: 11,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Bomb,
|
||||
Blocks,
|
||||
Camera,
|
||||
CalendarDays,
|
||||
Clock3,
|
||||
Gamepad2,
|
||||
Grid2X2,
|
||||
@@ -26,6 +27,7 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp'
|
||||
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
|
||||
import cameraIcon from '@/assets/img/app-icons/camera.webp'
|
||||
import clockIcon from '@/assets/img/app-icons/clock.webp'
|
||||
import calendarIcon from '@/assets/img/app-icons/calendar.svg'
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import mapIcon from '@/assets/img/app-icons/map.webp'
|
||||
import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
@@ -49,6 +51,19 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 20,
|
||||
icon: markRaw(CalendarDays),
|
||||
iconClass: 'app-icon--calendar',
|
||||
iconImage: calendarIcon,
|
||||
id: 'calendar',
|
||||
labelKey: 'Apps.calendar.name',
|
||||
route: '/apps/calendar',
|
||||
},
|
||||
{
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCalendarStore } from '@/stores/calendar'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('calendar store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads a bounded calendar range', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: [], success: true })
|
||||
const calendar = useCalendarStore()
|
||||
|
||||
expect(await calendar.load(1_000_000, 2_000_000)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('calendar:list', {
|
||||
endsAt: 2000,
|
||||
startsAt: 1000,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends server timestamps and revisions when updating', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const calendar = useCalendarStore()
|
||||
|
||||
await calendar.update(
|
||||
{
|
||||
endsAt: 0,
|
||||
id: 'event-id',
|
||||
note: '',
|
||||
remindedAt: null,
|
||||
reminderMinutes: null,
|
||||
revision: 4,
|
||||
startsAt: 0,
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
endsAt: 2_000_000,
|
||||
note: 'Bring documents',
|
||||
reminderMinutes: 60,
|
||||
startsAt: 1_000_000,
|
||||
title: 'Meeting',
|
||||
},
|
||||
)
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('calendar:update', {
|
||||
endsAt: 2000,
|
||||
id: 'event-id',
|
||||
note: 'Bring documents',
|
||||
reminderMinutes: 60,
|
||||
revision: 4,
|
||||
startsAt: 1000,
|
||||
title: 'Meeting',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
function toServerDraft(draft: CalendarEventDraft): Record<string, unknown> {
|
||||
return {
|
||||
endsAt: Math.floor(draft.endsAt / 1000),
|
||||
note: draft.note,
|
||||
reminderMinutes: draft.reminderMinutes,
|
||||
startsAt: Math.floor(draft.startsAt / 1000),
|
||||
title: draft.title,
|
||||
}
|
||||
}
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', {
|
||||
state: () => ({
|
||||
error: '' as string,
|
||||
events: [] as CalendarEvent[],
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async create(draft: CalendarEventDraft): Promise<boolean> {
|
||||
const response = await nuiCall<{ id: string }>(
|
||||
'calendar:create',
|
||||
toServerDraft(draft),
|
||||
)
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
return response.success
|
||||
},
|
||||
async deleteEvent(id: string): Promise<boolean> {
|
||||
const response = await nuiCall('calendar:delete', { id })
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) {
|
||||
this.events = this.events.filter((event) => event.id !== id)
|
||||
}
|
||||
return response.success
|
||||
},
|
||||
async load(startsAt: number, endsAt: number): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<CalendarEvent[]>('calendar:list', {
|
||||
endsAt: Math.floor(endsAt / 1000),
|
||||
startsAt: Math.floor(startsAt / 1000),
|
||||
})
|
||||
this.loading = false
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
if (response.success) this.events = response.data ?? []
|
||||
return response.success
|
||||
},
|
||||
async update(
|
||||
event: CalendarEvent,
|
||||
draft: CalendarEventDraft,
|
||||
): Promise<boolean> {
|
||||
const response = await nuiCall('calendar:update', {
|
||||
...toServerDraft(draft),
|
||||
id: event.id,
|
||||
revision: event.revision,
|
||||
})
|
||||
this.error = response.success ? '' : (response.error ?? 'default')
|
||||
return response.success
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -522,6 +522,47 @@ const defaultLocales: LocaleTree = {
|
||||
upload_timeout: 'The media upload timed out.',
|
||||
},
|
||||
},
|
||||
calendar: {
|
||||
name: 'Calendar',
|
||||
eyebrow: 'Your time. Clearly planned.',
|
||||
schedule: 'Schedule',
|
||||
event: 'Event',
|
||||
appointment: 'Appointment',
|
||||
newEvent: 'New event',
|
||||
editEvent: 'Edit event',
|
||||
deleteEvent: 'Delete event',
|
||||
details: 'Event details',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'What is planned?',
|
||||
date: 'Date',
|
||||
starts: 'Starts',
|
||||
ends: 'Ends',
|
||||
reminder: 'Reminder',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Add details, an address or anything to remember...',
|
||||
noEvents: 'Nothing planned',
|
||||
noEventsBody: 'This day is still free. Add an event whenever you are ready.',
|
||||
signInTitle: 'Your iFruit calendar',
|
||||
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
|
||||
reminderNotification: 'Upcoming: {title}',
|
||||
reminders: {
|
||||
none: 'No reminder',
|
||||
atStart: 'At start time',
|
||||
tenMinutes: '10 minutes before',
|
||||
thirtyMinutes: '30 minutes before',
|
||||
oneHour: '1 hour before',
|
||||
oneDay: '1 day before',
|
||||
},
|
||||
errors: {
|
||||
invalid_event: 'Enter a title and make sure the end is after the start.',
|
||||
invalid_range: 'This calendar period is invalid.',
|
||||
conflict: 'This event changed on another phone. Open it again.',
|
||||
rate_limited: 'Too many changes. Try again shortly.',
|
||||
not_authenticated: 'Sign in to your iFruit account first.',
|
||||
request_failed: 'Calendar is temporarily unavailable.',
|
||||
default: 'The calendar request failed.',
|
||||
},
|
||||
},
|
||||
clock: {
|
||||
name: 'Clock',
|
||||
lap: 'Lap',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type PhoneAppId =
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'mail'
|
||||
| 'map'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export type CalendarEvent = {
|
||||
endsAt: number
|
||||
id: string
|
||||
note: string
|
||||
remindedAt: number | null
|
||||
reminderMinutes: number | null
|
||||
revision: number
|
||||
startsAt: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export type CalendarEventDraft = {
|
||||
endsAt: number
|
||||
note: string
|
||||
reminderMinutes: number | null
|
||||
startsAt: number
|
||||
title: string
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
camera: { enabled: true, sounds: true },
|
||||
clock: { enabled: true, sounds: true },
|
||||
calendar: { enabled: true, sounds: true },
|
||||
weather: { enabled: true, sounds: true },
|
||||
mail: { enabled: true, sounds: true },
|
||||
map: { enabled: true, sounds: true },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -134,6 +134,7 @@ let mockNotes = [
|
||||
updatedAt: Date.now() - 3_600_000,
|
||||
},
|
||||
]
|
||||
let calendarEvents = []
|
||||
const deviceData = {}
|
||||
let mockMedia = [
|
||||
{
|
||||
@@ -430,10 +431,65 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
linkedAccount = null
|
||||
mockNotes = []
|
||||
mockMedia = []
|
||||
calendarEvents = []
|
||||
for (const key of Object.keys(deviceData)) delete deviceData[key]
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('calendar:') && !authenticated) {
|
||||
response.json({ success: false, error: 'not_authenticated' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calendar:list') {
|
||||
const startsAt = Number(request.body.startsAt) * 1000
|
||||
const endsAt = Number(request.body.endsAt) * 1000
|
||||
response.json({
|
||||
success: true,
|
||||
data: calendarEvents.filter(
|
||||
(event) => event.endsAt >= startsAt && event.startsAt < endsAt,
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calendar:create') {
|
||||
const id = `calendar-${Date.now()}`
|
||||
calendarEvents.push({
|
||||
...request.body,
|
||||
endsAt: Number(request.body.endsAt) * 1000,
|
||||
id,
|
||||
remindedAt: null,
|
||||
revision: 1,
|
||||
startsAt: Number(request.body.startsAt) * 1000,
|
||||
})
|
||||
response.json({ success: true, data: { id } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calendar:update') {
|
||||
const index = calendarEvents.findIndex(
|
||||
(event) => event.id === request.body.id,
|
||||
)
|
||||
if (index < 0 || calendarEvents[index].revision !== request.body.revision) {
|
||||
response.json({ success: false, error: 'conflict' })
|
||||
return
|
||||
}
|
||||
calendarEvents[index] = {
|
||||
...calendarEvents[index],
|
||||
...request.body,
|
||||
endsAt: Number(request.body.endsAt) * 1000,
|
||||
remindedAt: null,
|
||||
revision: calendarEvents[index].revision + 1,
|
||||
startsAt: Number(request.body.startsAt) * 1000,
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calendar:delete') {
|
||||
calendarEvents = calendarEvents.filter(
|
||||
(event) => event.id !== request.body.id,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'pages:list') {
|
||||
const query = String(request.body.search ?? '').toLowerCase()
|
||||
let items = pagesPosts
|
||||
|
||||
@@ -102,3 +102,13 @@ Config.LocalPages = {
|
||||
Categories = { "recommendation", "wanted", "service", "event", "place", "community" },
|
||||
CityMarktSharesPerDay = 1,
|
||||
}
|
||||
|
||||
Config.Calendar = {
|
||||
TitleMaxLength = 120,
|
||||
NoteMaxLength = 2000,
|
||||
MaximumDurationSeconds = 7 * 24 * 60 * 60,
|
||||
MaximumQuerySeconds = 370 * 24 * 60 * 60,
|
||||
PastEditSeconds = 365 * 24 * 60 * 60,
|
||||
FutureSeconds = 5 * 365 * 24 * 60 * 60,
|
||||
ReminderPollSeconds = 15,
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ Locales["en"] = {
|
||||
upload_failed = "The media upload failed.", upload_timeout = "The media upload timed out.",
|
||||
},
|
||||
},
|
||||
calendar = {
|
||||
name = "Calendar", reminder = "Upcoming: {title}",
|
||||
},
|
||||
clock = {
|
||||
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add alarm", location = "Los Santos",
|
||||
tabs = { world = "Clock", alarm = "Alarm", stopwatch = "Stopwatch", timer = "Timer" },
|
||||
|
||||
@@ -48,6 +48,7 @@ server_scripts {
|
||||
'source/server/marketplace.lua',
|
||||
'source/server/pages.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/calendar.lua',
|
||||
}
|
||||
|
||||
files {
|
||||
|
||||
@@ -55,6 +55,10 @@ local server_callbacks = {
|
||||
"pages:share-citymarkt",
|
||||
"pages:react",
|
||||
"pages:delete",
|
||||
"calendar:list",
|
||||
"calendar:create",
|
||||
"calendar:update",
|
||||
"calendar:delete",
|
||||
"sim:insert",
|
||||
"sim:eject",
|
||||
"contacts:list",
|
||||
@@ -313,6 +317,13 @@ RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
|
||||
SendNUIMessage({ type = "marketplace:new-message", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:calendar:reminder", function(data)
|
||||
local calendar_locale = get_locale().Nui.Apps.calendar
|
||||
data.title = calendar_locale.name
|
||||
data.text = calendar_locale.reminder:gsub("{title}", tostring(data.eventTitle))
|
||||
SendNUIMessage({ type = "calendar:reminder", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:sim:picker", function(data)
|
||||
sim_picker_open = true
|
||||
SetNuiFocus(true, true)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-BZAEmZnj.js"></script>
|
||||
<script type="module" crossorigin src="./assets/sky-index-Dj6qNtrv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-DnowIFpD.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local reminder_minutes = {
|
||||
[0] = true,
|
||||
[10] = true,
|
||||
[30] = true,
|
||||
[60] = true,
|
||||
[1440] = true,
|
||||
}
|
||||
|
||||
local function text_length(value)
|
||||
return type(value) == "string" and utf8.len(value) or nil
|
||||
end
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
end
|
||||
|
||||
local function event_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
note = row.note,
|
||||
startsAt = (tonumber(row.starts_at_unix) or 0) * 1000,
|
||||
endsAt = (tonumber(row.ends_at_unix) or 0) * 1000,
|
||||
reminderMinutes = row.reminder_minutes and tonumber(row.reminder_minutes) or nil,
|
||||
remindedAt = row.reminded_at_unix and tonumber(row.reminded_at_unix) * 1000 or nil,
|
||||
revision = tonumber(row.revision) or 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function list_events(account_id, starts_at, ends_at)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `title`, `note`, `reminder_minutes`, `revision`,
|
||||
UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`,
|
||||
UNIX_TIMESTAMP(`ends_at`) AS `ends_at_unix`,
|
||||
UNIX_TIMESTAMP(`reminded_at`) AS `reminded_at_unix`
|
||||
FROM `sky_phone_calendar_events`
|
||||
WHERE `account_id` = ?
|
||||
AND `ends_at` >= FROM_UNIXTIME(?)
|
||||
AND `starts_at` < FROM_UNIXTIME(?)
|
||||
ORDER BY `starts_at`, `ends_at`, `id`
|
||||
LIMIT 500
|
||||
]], { account_id, starts_at, ends_at })
|
||||
local events = {}
|
||||
for _, row in ipairs(rows) do
|
||||
events[#events + 1] = event_dto(row)
|
||||
end
|
||||
return events
|
||||
end
|
||||
|
||||
local function valid_range(starts_at, ends_at)
|
||||
local now = os.time()
|
||||
return starts_at >= now - Config.Calendar.PastEditSeconds
|
||||
and starts_at <= now + Config.Calendar.FutureSeconds
|
||||
and ends_at > starts_at
|
||||
and ends_at - starts_at <= Config.Calendar.MaximumDurationSeconds
|
||||
end
|
||||
|
||||
local function validate_event(data)
|
||||
if type(data) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local title_length = text_length(data.title)
|
||||
local note_length = text_length(data.note)
|
||||
local starts_at = math.floor(tonumber(data.startsAt) or 0)
|
||||
local ends_at = math.floor(tonumber(data.endsAt) or 0)
|
||||
local reminder = data.reminderMinutes
|
||||
if not title_length
|
||||
or title_length < 1
|
||||
or title_length > Config.Calendar.TitleMaxLength
|
||||
or not note_length
|
||||
or note_length > Config.Calendar.NoteMaxLength
|
||||
or not valid_range(starts_at, ends_at)
|
||||
or (reminder ~= nil and not reminder_minutes[tonumber(reminder)])
|
||||
then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
title = data.title,
|
||||
note = data.note,
|
||||
starts_at = starts_at,
|
||||
ends_at = ends_at,
|
||||
reminder_minutes = reminder == nil and nil or tonumber(reminder),
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:list", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local starts_at = math.floor(tonumber(data and data.startsAt) or 0)
|
||||
local ends_at = math.floor(tonumber(data and data.endsAt) or 0)
|
||||
if starts_at <= 0 or ends_at <= starts_at or ends_at - starts_at > Config.Calendar.MaximumQuerySeconds then
|
||||
return { success = false, error = "invalid_range" }
|
||||
end
|
||||
return { success = true, data = list_events(account.id, starts_at, ends_at) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:create", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local event = validate_event(data)
|
||||
if not event then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = ids[1] and ids[1].id
|
||||
if type(id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a calendar event id.")
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_calendar_events`
|
||||
(`id`, `account_id`, `title`, `note`, `starts_at`, `ends_at`, `reminder_minutes`)
|
||||
VALUES (?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), ?)
|
||||
]], {
|
||||
id,
|
||||
account.id,
|
||||
event.title,
|
||||
event.note,
|
||||
event.starts_at,
|
||||
event.ends_at,
|
||||
event.reminder_minutes,
|
||||
})
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:update", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local event = validate_event(data)
|
||||
if not event or type(data.id) ~= "string" then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
local revision = math.max(1, math.floor(tonumber(data.revision) or 0))
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_calendar_events`
|
||||
SET `title` = ?, `note` = ?, `starts_at` = FROM_UNIXTIME(?),
|
||||
`ends_at` = FROM_UNIXTIME(?), `reminder_minutes` = ?,
|
||||
`reminded_at` = NULL, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `account_id` = ? AND `revision` = ?
|
||||
]], {
|
||||
event.title,
|
||||
event.note,
|
||||
event.starts_at,
|
||||
event.ends_at,
|
||||
event.reminder_minutes,
|
||||
data.id,
|
||||
account.id,
|
||||
revision,
|
||||
})
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "conflict" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:delete", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_calendar_events` WHERE `id` = ? AND `account_id` = ?",
|
||||
{ data.id, account.id }
|
||||
)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.Calendar.ReminderPollSeconds * 1000)
|
||||
local due_events = Bridge.Database.Query([[
|
||||
SELECT `id`, `account_id`, `title`, UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`
|
||||
FROM `sky_phone_calendar_events`
|
||||
WHERE `reminder_minutes` IS NOT NULL
|
||||
AND `reminded_at` IS NULL
|
||||
AND DATE_SUB(`starts_at`, INTERVAL `reminder_minutes` MINUTE) <= CURRENT_TIMESTAMP
|
||||
AND `starts_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)
|
||||
ORDER BY `starts_at`
|
||||
LIMIT 100
|
||||
]], {})
|
||||
for _, event in ipairs(due_events) do
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_calendar_events`
|
||||
SET `reminded_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ? AND `reminded_at` IS NULL
|
||||
]], { event.id })
|
||||
if affected_rows(result) == 1 then
|
||||
SkyPhone.NotifyAccountDevices(event.account_id, "sky_phone:calendar:reminder", {
|
||||
eventId = event.id,
|
||||
eventTitle = event.title,
|
||||
startsAt = (tonumber(event.starts_at_unix) or 0) * 1000,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -604,6 +604,31 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_calendar_events",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "title", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "note", type = "TEXT NOT NULL" },
|
||||
{ name = "starts_at", type = "DATETIME NOT NULL" },
|
||||
{ name = "ends_at", type = "DATETIME NOT NULL" },
|
||||
{ name = "reminder_minutes", type = "SMALLINT UNSIGNED NULL" },
|
||||
{ name = "reminded_at", type = "DATETIME NULL" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_calendar_account", columns = "(`account_id`, `starts_at`)" },
|
||||
{ name = "idx_sky_phone_calendar_reminders", columns = "(`reminded_at`, `starts_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -176,3 +176,21 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`note` TEXT NOT NULL,
|
||||
`starts_at` DATETIME NOT NULL,
|
||||
`ends_at` DATETIME NOT NULL,
|
||||
`reminder_minutes` SMALLINT UNSIGNED NULL,
|
||||
`reminded_at` DATETIME NULL,
|
||||
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_calendar_account` (`account_id`, `starts_at`),
|
||||
KEY `idx_sky_phone_calendar_reminders` (`reminded_at`, `starts_at`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
Reference in New Issue
Block a user