Skip to content

Commit 4975b63

Browse files
ikraamgclaude
andcommitted
Added schedule import and export
Necessary to allow users to back up and restore schedules as JSON files. Provides a recovery path for users who lost data due to the previous mount point mismatch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dc8a0e9 commit 4975b63

6 files changed

Lines changed: 237 additions & 1 deletion

File tree

trmnl-ha/DOCS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,8 @@ Create cron-based schedules via the Web UI for automatic captures.
319319

320320
**Manual Trigger:** Click **Send Now** to execute immediately.
321321

322+
**Export / Import:** Use the **Export** and **Import** buttons in the Schedules tab to back up or restore schedules as JSON files. Import replaces all existing schedules.
323+
322324
### Cron Syntax
323325

324326
```

trmnl-ha/ha-trmnl/html/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,31 @@
7373
>
7474
+ New
7575
</button>
76+
77+
<button
78+
onclick="window.app.exportSchedules()"
79+
class="px-3 py-2 rounded-t-lg text-sm transition-all border"
80+
style="background-color: #f5f5f5; color: #666; border-color: #e0e0e0"
81+
onmouseover="this.style.backgroundColor='#e8e8e8'; this.style.borderColor='var(--primary-light)'"
82+
onmouseout="this.style.backgroundColor='#f5f5f5'; this.style.borderColor='#e0e0e0'"
83+
title="Export all schedules as JSON"
84+
>
85+
↓ Export
86+
</button>
87+
88+
<button
89+
onclick="window.app.importSchedules()"
90+
class="px-3 py-2 rounded-t-lg text-sm transition-all border"
91+
style="background-color: #f5f5f5; color: #666; border-color: #e0e0e0"
92+
onmouseover="this.style.backgroundColor='#e8e8e8'; this.style.borderColor='var(--primary-light)'"
93+
onmouseout="this.style.backgroundColor='#f5f5f5'; this.style.borderColor='#e0e0e0'"
94+
title="Import schedules from JSON file"
95+
>
96+
↑ Import
97+
</button>
98+
99+
<!-- Hidden file input for import -->
100+
<input type="file" id="importFileInput" accept=".json" class="hidden" />
76101
</div>
77102

78103
<!-- Tab Content -->

trmnl-ha/ha-trmnl/html/js/api-client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,32 @@ export class SendSchedule {
176176
}
177177
}
178178

179+
/**
180+
* Imports schedules (full replace) from a JSON array
181+
*/
182+
export class ImportSchedules {
183+
baseUrl: string
184+
185+
constructor(baseUrl = './api/schedules/import') {
186+
this.baseUrl = baseUrl
187+
}
188+
189+
async call(schedules: Schedule[]): Promise<{ success: boolean; count: number }> {
190+
const response = await fetch(this.baseUrl, {
191+
method: 'POST',
192+
headers: { 'Content-Type': 'application/json' },
193+
body: JSON.stringify(schedules),
194+
})
195+
196+
if (!response.ok) {
197+
const data = await response.json() as { error?: string }
198+
throw new Error(data.error ?? `Failed to import: ${response.statusText}`)
199+
}
200+
201+
return response.json()
202+
}
203+
}
204+
179205
/**
180206
* Fetches a screenshot preview
181207
*/

trmnl-ha/ha-trmnl/html/js/app.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { PreviewGenerator } from './preview-generator.js'
2323
import { CropModal } from './crop-modal.js'
2424
import { ConfirmModal } from './confirm-modal.js'
2525
import { DevicePresetsManager } from './device-presets.js'
26-
import { SendSchedule, LoadPalettes, ByosLogin } from './api-client.js'
26+
import { SendSchedule, LoadPalettes, ByosLogin, ImportSchedules } from './api-client.js'
2727
import type { PaletteOption } from './palette-options.js'
2828
import type {
2929
Schedule,
@@ -552,6 +552,110 @@ class App {
552552
return null
553553
}
554554

555+
// =============================================================================
556+
// EXPORT / IMPORT
557+
// =============================================================================
558+
559+
/** Downloads all schedules as a JSON file */
560+
exportSchedules(): void {
561+
const schedules = this.#scheduleManager.schedules
562+
563+
if (schedules.length === 0) {
564+
this.#confirmModal.alert({
565+
title: 'Nothing to Export',
566+
message: 'There are no schedules to export.',
567+
type: 'info',
568+
})
569+
return
570+
}
571+
572+
const json = JSON.stringify(schedules, null, 2)
573+
const blob = new Blob([json], { type: 'application/json' })
574+
const date = new Date().toISOString().slice(0, 10)
575+
const filename = `trmnl-schedules-${date}.json`
576+
577+
const a = document.createElement('a')
578+
a.href = URL.createObjectURL(blob)
579+
a.download = filename
580+
a.click()
581+
URL.revokeObjectURL(a.href)
582+
}
583+
584+
/** Prompts user to select a JSON file and imports schedules */
585+
async importSchedules(): Promise<void> {
586+
const fileInput = document.getElementById('importFileInput') as HTMLInputElement | null
587+
if (!fileInput) return
588+
589+
// Reset so the same file can be re-selected
590+
fileInput.value = ''
591+
592+
// Wait for user to pick a file
593+
const file = await new Promise<File | null>((resolve) => {
594+
const handler = () => {
595+
fileInput.removeEventListener('change', handler)
596+
resolve(fileInput.files?.[0] ?? null)
597+
}
598+
fileInput.addEventListener('change', handler)
599+
fileInput.click()
600+
})
601+
602+
if (!file) return
603+
604+
let parsed: unknown
605+
try {
606+
const text = await file.text()
607+
parsed = JSON.parse(text)
608+
} catch {
609+
await this.#confirmModal.alert({
610+
title: 'Invalid File',
611+
message: 'The selected file is not valid JSON.',
612+
type: 'error',
613+
})
614+
return
615+
}
616+
617+
if (!Array.isArray(parsed)) {
618+
await this.#confirmModal.alert({
619+
title: 'Invalid Format',
620+
message: 'Expected a JSON array of schedules.',
621+
type: 'error',
622+
})
623+
return
624+
}
625+
626+
const existing = this.#scheduleManager.schedules.length
627+
const incoming = parsed.length
628+
629+
const confirmed = await this.#confirmModal.confirm({
630+
title: 'Import Schedules',
631+
message: `This will replace all ${existing} existing schedule(s) with ${incoming} imported schedule(s). This cannot be undone.`,
632+
confirmText: 'Import',
633+
cancelText: 'Cancel',
634+
confirmClass: 'text-white rounded-md transition' +
635+
' ' + 'bg-blue-600 hover:bg-blue-700',
636+
})
637+
638+
if (!confirmed) return
639+
640+
try {
641+
const result = await new ImportSchedules().call(parsed)
642+
await this.#scheduleManager.loadAll()
643+
this.renderUI()
644+
645+
await this.#confirmModal.alert({
646+
title: 'Import Complete',
647+
message: `Successfully imported ${result.count} schedule(s).`,
648+
type: 'success',
649+
})
650+
} catch (err) {
651+
await this.#confirmModal.alert({
652+
title: 'Import Failed',
653+
message: `Failed to import schedules: ${(err as Error).message}`,
654+
type: 'error',
655+
})
656+
}
657+
}
658+
555659
// =============================================================================
556660
// UI RENDERING
557661
// =============================================================================

trmnl-ha/ha-trmnl/lib/http-router.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
createSchedule as defaultCreateSchedule,
2121
updateSchedule as defaultUpdateSchedule,
2222
deleteSchedule as defaultDeleteSchedule,
23+
replaceAllSchedules,
2324
} from './scheduleStore.js'
2425
import {
2526
login as defaultByosLogin,
@@ -160,6 +161,10 @@ export class HttpRouter {
160161
return this.#handleSchedulesAPI(request, response)
161162
}
162163

164+
if (pathname === '/api/schedules/import') {
165+
return this.#handleScheduleImportAPI(request, response)
166+
}
167+
163168
if (pathname.startsWith('/api/schedules/')) {
164169
if (pathname.endsWith('/send')) {
165170
return this.#handleScheduleSendAPI(request, response, requestUrl)
@@ -328,6 +333,39 @@ export class HttpRouter {
328333
return true
329334
}
330335

336+
async #handleScheduleImportAPI(
337+
request: IncomingMessage,
338+
response: ServerResponse,
339+
): Promise<boolean> {
340+
response.setHeader('Content-Type', 'application/json')
341+
342+
if (request.method !== 'POST') {
343+
response.writeHead(405)
344+
response.end(toJson({ error: 'Method not allowed' }))
345+
return true
346+
}
347+
348+
try {
349+
const body = await this.#readRequestBody(request)
350+
const parsed: unknown = JSON.parse(body)
351+
352+
if (!Array.isArray(parsed)) {
353+
response.writeHead(400)
354+
response.end(toJson({ error: 'Request body must be a JSON array' }))
355+
return true
356+
}
357+
358+
const schedules = await replaceAllSchedules(parsed as Partial<Schedule>[])
359+
response.writeHead(200)
360+
response.end(toJson({ success: true, count: schedules.length }))
361+
} catch (err) {
362+
response.writeHead(400)
363+
response.end(toJson({ error: (err as Error).message }))
364+
}
365+
366+
return true
367+
}
368+
331369
#handlePresetsAPI(response: ServerResponse): boolean {
332370
const presets = loadPresets()
333371
response.writeHead(200, { 'Content-Type': 'application/json' })

trmnl-ha/ha-trmnl/lib/scheduleStore.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,47 @@ export async function deleteSchedule(
267267
})
268268
}
269269

270+
/**
271+
* Replace all schedules with imported data.
272+
* Generates fresh IDs and timestamps, runs each through migrateSchedule().
273+
*/
274+
export async function replaceAllSchedules(
275+
filePath: string,
276+
imported: Partial<Schedule>[]
277+
): Promise<Schedule[]>
278+
export async function replaceAllSchedules(
279+
imported: Partial<Schedule>[]
280+
): Promise<Schedule[]>
281+
export async function replaceAllSchedules(
282+
filePathOrImported: string | Partial<Schedule>[],
283+
imported?: Partial<Schedule>[]
284+
): Promise<Schedule[]> {
285+
const filePath =
286+
typeof filePathOrImported === 'string'
287+
? filePathOrImported
288+
: DEFAULT_SCHEDULES_FILE
289+
const data =
290+
typeof filePathOrImported === 'string' ? imported! : filePathOrImported
291+
292+
return withLock(filePath, async () => {
293+
const now = new Date().toISOString()
294+
const schedules = data.map((entry) => {
295+
// Strip existing IDs/timestamps — regenerate fresh ones
296+
const { id: _id, createdAt: _ca, updatedAt: _ua, ...rest } = entry
297+
return migrateSchedule({
298+
...rest,
299+
id: generateId(),
300+
createdAt: now,
301+
updatedAt: now,
302+
})
303+
})
304+
305+
await saveSchedules(filePath, schedules)
306+
log.info`Imported ${schedules.length} schedules`
307+
return schedules
308+
})
309+
}
310+
270311
/**
271312
* Generate a unique ID
272313
*/

0 commit comments

Comments
 (0)