Added ability to download entire profile SQLite file.
Build and Deploy Web App / build-and-deploy (push) Successful in 1m9s Details
Build and Deploy API / build-and-deploy (push) Successful in 2m33s Details

This commit is contained in:
andrewlalis 2025-12-03 11:01:33 -05:00
parent 2d99e96df3
commit bed9562d6b
9 changed files with 216 additions and 33 deletions

View File

@ -47,6 +47,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile); a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile);
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile); a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties); a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/download", &handleDownloadProfile);
import attachment.api; import attachment.api;
// Note: the download endpoint is public! We authenticate via token in query params instead of header here. // Note: the download endpoint is public! We authenticate via token in query params instead of header here.
h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment); h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment);
@ -161,6 +162,7 @@ private class CorsFilter : HttpRequestFilter {
response.headers.add("Access-Control-Allow-Origin", webOrigin); response.headers.add("Access-Control-Allow-Origin", webOrigin);
response.headers.add("Access-Control-Allow-Methods", "*"); response.headers.add("Access-Control-Allow-Methods", "*");
response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.headers.add("Access-Control-Expose-Headers", "Content-Disposition");
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }
@ -200,6 +202,11 @@ private class ExceptionHandlingFilter : HttpRequestFilter {
error(e); error(e);
response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.status = HttpStatus.INTERNAL_SERVER_ERROR;
response.writeBodyString("An error occurred: " ~ e.msg); response.writeBodyString("An error occurred: " ~ e.msg);
} catch (Throwable e) {
errorF!"A throwable was caught! %s %s"(e.msg, e.info);
response.status = HttpStatus.INTERNAL_SERVER_ERROR;
response.writeBodyString("An error occurred.");
throw e;
} }
} }
} }

View File

@ -5,6 +5,7 @@ import asdf;
import handy_http_primitives; import handy_http_primitives;
import handy_http_data.json; import handy_http_data.json;
import handy_http_handlers.path_handler : getPathParamAs; import handy_http_handlers.path_handler : getPathParamAs;
import slf4d;
import profile.model; import profile.model;
import profile.service; import profile.service;
@ -63,3 +64,40 @@ void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse r
ProfileProperty[] props = propsRepo.findAll(); ProfileProperty[] props = propsRepo.findAll();
writeJsonBody(response, props); writeJsonBody(response, props);
} }
void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileContext profileCtx = getProfileContextOrThrow(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
Optional!ProfileDownloadData data = profileRepo.getProfileData(profileCtx.profile.name);
if (data.isNull) {
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Profile data not found.");
return;
}
import streams : StreamResult;
import std.conv : to;
response.headers.add("Content-Type", data.value.contentType);
response.headers.add("Content-Disposition", "attachment; filename=" ~ data.value.filename);
response.headers.add("Content-Length", data.value.size.to!string);
// Transfer the file:
StreamResult result;
ubyte[4096] buffer;
ulong bytesWritten = 0;
while (bytesWritten < data.value.size) {
result = data.value.inputStream.readFromStream(buffer);
if (result.hasError) {
errorF!"Failed to read from stream: %s"(result.error);
return;
}
if (result.count == 0) {
return; // Done!
}
result = response.outputStream.writeToStream(buffer[0 .. result.count]);
if (result.hasError) {
errorF!"Failed to write to stream: %s"(result.error);
return;
}
bytesWritten += result.count;
}
}

View File

@ -1,9 +1,17 @@
module profile.data; module profile.data;
import handy_http_primitives : Optional; import handy_http_primitives : Optional;
import streams.interfaces : InputStream;
import profile.model; import profile.model;
struct ProfileDownloadData {
string filename;
string contentType;
ulong size;
InputStream!ubyte inputStream;
}
/// Repository for interacting with the set of profiles belonging to a user. /// Repository for interacting with the set of profiles belonging to a user.
interface ProfileRepository { interface ProfileRepository {
Optional!Profile findByName(string name); Optional!Profile findByName(string name);
@ -12,6 +20,7 @@ interface ProfileRepository {
void deleteByName(string name); void deleteByName(string name);
ProfileDataSource getDataSource(in Profile profile); ProfileDataSource getDataSource(in Profile profile);
string getFilesPath(in Profile profile); string getFilesPath(in Profile profile);
Optional!ProfileDownloadData getProfileData(string name);
} }
/// Repository for accessing the properties of a profile. /// Repository for accessing the properties of a profile.

View File

@ -3,6 +3,8 @@ module profile.data_impl_sqlite;
import slf4d; import slf4d;
import d2sqlite3; import d2sqlite3;
import handy_http_primitives; import handy_http_primitives;
import streams.interfaces : InputStream, inputStreamObjectFor;
import streams.types : FileInputStream;
import profile.data; import profile.data;
import profile.model; import profile.model;
@ -78,6 +80,25 @@ class FileSystemProfileRepository : ProfileRepository {
return buildPath(getProfilesDir(), profile.name ~ "_files"); return buildPath(getProfilesDir(), profile.name ~ "_files");
} }
Optional!ProfileDownloadData getProfileData(string name) {
import std.string : toStringz, format;
import std.datetime;
string path = getProfilePath(name);
if (!exists(path)) return Optional!ProfileDownloadData.empty;
ProfileDownloadData data;
const now = Clock.currTime(UTC());
data.filename = format!"%s_%02d-%02d-%02d_%02d-%02d-%02dz.sqlite"(
name,
now.year, now.month, now.day,
now.hour, now.minute, now.second
);
data.contentType = "application/vnd.sqlite3";
data.size = std.file.getSize(path);
data.inputStream = inputStreamObjectFor(FileInputStream(toStringz(path)));
return Optional!ProfileDownloadData.of(data);
}
private string getProfilesDir() { private string getProfilesDir() {
return buildPath(this.usersDir, username, "profiles"); return buildPath(this.usersDir, username, "profiles");
} }

View File

@ -44,6 +44,28 @@ export abstract class ApiClient {
return await r.text() return await r.text()
} }
protected async getFile(path: string): Promise<void> {
const r = await this.doRequest('GET', path)
const blob = await r.blob()
const objURL = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objURL
a.download = this.extractFileName(r) ?? 'file.dat'
document.body.appendChild(a)
a.click() // Trigger the download.
document.body.removeChild(a)
URL.revokeObjectURL(objURL)
}
private extractFileName(response: Response): string | null {
const contentDisposition = response.headers.get('Content-Disposition')
if (!contentDisposition) return null
const chunks = contentDisposition.split(';').map((c) => c.trim())
const filenameChunk = chunks.find((c) => c.startsWith('filename='))
if (!filenameChunk) return null
return filenameChunk.split('=')[1].trim()
}
protected async postJson<T>(path: string, body: object | undefined = undefined): Promise<T> { protected async postJson<T>(path: string, body: object | undefined = undefined): Promise<T> {
const r = await this.doRequest('POST', path, body) const r = await this.doRequest('POST', path, body)
return await r.json() return await r.json()

View File

@ -30,6 +30,10 @@ export class ProfileApiClient extends ApiClient {
getProperties(profileName: string): Promise<ProfileProperty[]> { getProperties(profileName: string): Promise<ProfileProperty[]> {
return super.getJson(`/profiles/${profileName}/properties`) return super.getJson(`/profiles/${profileName}/properties`)
} }
downloadData(profileName: string): Promise<void> {
return super.getFile(`/profiles/${profileName}/download`)
}
} }
/** /**

View File

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Account, AccountApiClient } from '@/api/account' import { AccountApiClient, type Account } from '@/api/account'
import HomeModule from '@/components/HomeModule.vue' import HomeModule from '@/components/HomeModule.vue'
import { computed, onMounted, ref, type ComputedRef } from 'vue' import { computed, onMounted, ref, type ComputedRef } from 'vue'
import { useRoute } from 'vue-router'
import 'chartjs-adapter-date-fns' import 'chartjs-adapter-date-fns'
import type { Currency } from '@/api/data' import type { Currency } from '@/api/data'
import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics' import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics'
@ -11,6 +10,7 @@ import type { TimeFrame, BalanceTimeSeries } from './analytics/util'
import FormGroup from '@/components/common/form/FormGroup.vue' import FormGroup from '@/components/common/form/FormGroup.vue'
import FormControl from '@/components/common/form/FormControl.vue' import FormControl from '@/components/common/form/FormControl.vue'
import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns' import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns'
import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
@ -25,7 +25,8 @@ interface AnalyticsChartType {
} }
const AnalyticsChartTypes: AnalyticsChartType[] = [ const AnalyticsChartTypes: AnalyticsChartType[] = [
{ id: 'balance-time-series', name: 'Account Balances' }, { id: 'account-balances', name: 'Account Balances' },
{ id: 'total-balances', name: 'Total Balances' },
] ]
const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0]) const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
@ -33,20 +34,21 @@ const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
const availableCurrencies = computed(() => { const availableCurrencies = computed(() => {
const currencies: Currency[] = [] const currencies: Currency[] = []
for (const acc of accounts.value) { for (const acc of accounts.value) {
if (currencies.findIndex(c => c.code === acc.currency.code) === -1) { if (currencies.findIndex((c) => c.code === acc.currency.code) === -1) {
currencies.push(acc.currency) currencies.push(acc.currency)
} }
} }
return currencies return currencies
}) })
const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => { const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
if (!balanceTimeSeriesData.value) return [] if (!balanceTimeSeriesData.value) return []
const eligibleAccounts = accounts.value.filter(a => a.currency.code === currency.value?.code) const eligibleAccounts = accounts.value.filter((a) => a.currency.code === currency.value?.code)
const series: BalanceTimeSeries[] = [] const series: BalanceTimeSeries[] = []
for (const accountData of balanceTimeSeriesData.value.accounts) { for (const accountData of balanceTimeSeriesData.value.accounts) {
const account = eligibleAccounts.find(a => a.id === accountData.accountId) const account = eligibleAccounts.find((a) => a.id === accountData.accountId)
if (account !== undefined) { if (account !== undefined) {
const filteredTimeSeries = accountData.balanceTimeSeries.filter(s => { const filteredTimeSeries = accountData.balanceTimeSeries.filter((s) => {
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) { if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
return false return false
} }
@ -57,13 +59,31 @@ const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
}) })
series.push({ series.push({
label: account.name, label: account.name,
snapshots: filteredTimeSeries snapshots: filteredTimeSeries,
}) })
} }
} }
return series return series
}) })
const totalBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
if (!balanceTimeSeriesData.value) return []
const totalsData = balanceTimeSeriesData.value.totals.find(
(t) => t.currencyCode === currency.value?.code,
)
if (!totalsData) return []
const filteredTimeSeries = totalsData.balanceTimeSeries.filter((s) => {
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
return false
}
if (timeFrame.value.end && !isBefore(s.timestamp, timeFrame.value.end)) {
return false
}
return true
})
return [{ label: totalsData.currencyCode, snapshots: filteredTimeSeries }]
})
onMounted(async () => { onMounted(async () => {
const api = new AccountApiClient(route) const api = new AccountApiClient(route)
const analyticsApi = new AnalyticsApiClient(route) const analyticsApi = new AnalyticsApiClient(route)
@ -75,31 +95,62 @@ onMounted(async () => {
}) })
</script> </script>
<template> <template>
<HomeModule title="Analytics" style="max-width: 800px;"> <HomeModule
title="Analytics"
style="max-width: 800px; min-height: 200px"
>
<FormGroup> <FormGroup>
<FormControl label="Chart"> <FormControl label="Chart">
<select v-model="selectedChart"> <select v-model="selectedChart">
<option v-for="ct in AnalyticsChartTypes" :key="ct.id" :value="ct"> <option
v-for="ct in AnalyticsChartTypes"
:key="ct.id"
:value="ct"
>
{{ ct.name }} {{ ct.name }}
</option> </option>
</select> </select>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<BalanceTimeSeriesChart v-if="currency && balanceTimeSeriesData && selectedChart.id === 'balance-time-series'" <BalanceTimeSeriesChart
title="Account Balances" :currency="currency" :time-frame="timeFrame" :data="accountBalancesData" /> v-if="currency && balanceTimeSeriesData && selectedChart.id === 'account-balances'"
title="Account Balances"
:currency="currency"
:time-frame="timeFrame"
:data="accountBalancesData"
/>
<BalanceTimeSeriesChart
v-if="currency && balanceTimeSeriesData && selectedChart.id === 'total-balances'"
title="Total Balances"
:currency="currency"
:time-frame="timeFrame"
:data="totalBalancesData"
/>
<FormGroup> <FormGroup>
<FormControl label="Currency"> <FormControl label="Currency">
<select v-model="currency" :disabled="availableCurrencies.length < 2"> <select
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency"> v-model="currency"
:disabled="availableCurrencies.length < 2"
>
<option
v-for="currency in availableCurrencies"
:key="currency.code"
:value="currency"
>
{{ currency.code }} {{ currency.code }}
</option> </option>
</select> </select>
</FormControl> </FormControl>
<FormControl label="Time Frame"> <FormControl label="Time Frame">
<select v-model="timeFrame"> <select v-model="timeFrame">
<option :value="{}" selected>All Time</option> <option
:value="{}"
selected
>
All Time
</option>
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option> <option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
<option :value="{ start: startOfMonth(new Date()) }">This Month</option> <option :value="{ start: startOfMonth(new Date()) }">This Month</option>
<option :value="{ start: startOfYear(new Date()) }">Year to Date</option> <option :value="{ start: startOfYear(new Date()) }">Year to Date</option>

View File

@ -35,6 +35,12 @@ async function deleteProfile() {
} }
} }
} }
async function downloadData() {
if (!profile.value) return
const api = new ProfileApiClient()
await api.downloadData(profile.value.name)
}
</script> </script>
<template> <template>
<HomeModule <HomeModule
@ -66,10 +72,17 @@ async function deleteProfile() {
@click="router.push('/profiles')" @click="router.push('/profiles')"
>Choose another profile</AppButton >Choose another profile</AppButton
> >
<AppButton
icon="download"
@click="downloadData()"
size="sm"
>Download Data</AppButton
>
<AppButton <AppButton
button-style="secondary" button-style="secondary"
icon="trash" icon="trash"
@click="deleteProfile()" @click="deleteProfile()"
size="sm"
>Delete</AppButton >Delete</AppButton
> >
</template> </template>

View File

@ -1,15 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { CategoryScale, Chart, type ChartData, type ChartDataset, type ChartOptions, Filler, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip } from 'chart.js'; import {
import { computed, onMounted, ref } from 'vue'; CategoryScale,
import type { BalanceTimeSeries, TimeFrame } from './util'; Chart,
import { integerMoneyToFloat, type Currency } from '@/api/data'; type ChartData,
import { getTime } from 'date-fns'; type ChartDataset,
import { Line } from 'vue-chartjs'; type ChartOptions,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js'
import { computed, onMounted, ref } from 'vue'
import type { BalanceTimeSeries, TimeFrame } from './util'
import { integerMoneyToFloat, type Currency } from '@/api/data'
import { getTime } from 'date-fns'
import { Line } from 'vue-chartjs'
const props = defineProps<{ const props = defineProps<{
title: string, title: string
timeFrame: TimeFrame, timeFrame: TimeFrame
currency: Currency, currency: Currency
data: BalanceTimeSeries[] data: BalanceTimeSeries[]
}>() }>()
@ -25,7 +39,7 @@ Chart.register(
LinearScale, LinearScale,
PointElement, PointElement,
TimeScale, TimeScale,
Filler Filler,
) )
const COLORS = [ const COLORS = [
@ -46,17 +60,17 @@ onMounted(() => {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: props.title text: props.title,
} },
}, },
scales: { scales: {
x: { x: {
type: 'time', type: 'time',
time: { time: {
unit: 'day' unit: 'day',
} },
} },
} },
} }
}) })
@ -71,7 +85,7 @@ function buildChartData(): ChartData<'line'> {
const points = series.snapshots.map((p) => { const points = series.snapshots.map((p) => {
return { return {
x: getTime(p.timestamp), x: getTime(p.timestamp),
y: integerMoneyToFloat(p.balance, props.currency) y: integerMoneyToFloat(p.balance, props.currency),
} }
}) })
datasets.push({ datasets.push({
@ -91,5 +105,9 @@ function buildChartData(): ChartData<'line'> {
} }
</script> </script>
<template> <template>
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" /> <Line
v-if="chartData && chartOptions"
:data="chartData"
:options="chartOptions"
/>
</template> </template>