Compare commits

...

15 Commits

Author SHA1 Message Date
D. Berge
e9a228ca32 Add download control for all events to Log view.
The log until now offered a download control only in sequence
view mode. With this change, download is available (albeit not
in all formats) for the entire log.

To download events for a selection of dates (constrained by day,
week or month) the user should use the Calendar view instead.
2023-10-29 11:56:26 +01:00
D. Berge
3e33f10ea5 Add download control to Calendar view.
Will download the event log for the currently selected calendar
period (day, week, month, …) in a choice of formats.
2023-10-29 11:53:24 +01:00
D. Berge
54fe18035f Update API specification 2023-10-29 11:51:18 +01:00
D. Berge
1ade981902 Use setContentDisposition() 2023-10-29 11:37:16 +01:00
D. Berge
013a52ff55 Add setContentDisposition() utility function.
It checks if a request has a `filename` search parameter and if
so, set the Content-Disposition response header to attachment
with the provided filename.
2023-10-29 11:35:26 +01:00
D. Berge
a74fd56f15 Merge branch '286-support-structured-data-log-entries' into 'devel'
Resolve "Support structured data log entries"

Closes #286

See merge request wgp/dougal/software!51
2023-10-28 18:06:08 +00:00
D. Berge
a369f2dd7b Add structured values support to <dougal-event-edit/> 2023-10-28 20:02:13 +02:00
D. Berge
83c3f9b401 Add <dougal-event-select/> component.
This is a refactoring of <dougal-event-edit/> focusing on the
preset remark selection combo box and context menu with the
addition of support for structured values via the
<dougal-event-properties/> component.
2023-10-28 19:57:56 +02:00
D. Berge
798178af55 Add <dougal-event-properties/> component.
It provides an input form for structured values.
2023-10-28 19:56:08 +02:00
D. Berge
320f67fd06 Merge branch '285-refactor-navigation-bar' into 'devel'
Resolve "Refactor navigation bar"

Closes #285

See merge request wgp/dougal/software!50
2023-10-28 11:29:31 +00:00
D. Berge
9f2e25278b Replace hard-coded navigation bar with dynamic alternative.
Navigation bars should be coded as their own component and
added to the meta section of the Vue Router's route(s) in which
they are to be used.
2023-10-28 13:26:57 +02:00
D. Berge
d2f8444042 Add <v-app-bar/> extension info to router.
The idea is for the <dougal-navigation/> component to dynamically
load the extension, if any, defined in the route's meta attribute.
2023-10-28 13:24:56 +02:00
D. Berge
28beef81de Create new <v-app-bar/> extension component.
Intended to be used in the v-slot:extension slot of <v-app-bar/>.
2023-10-28 13:22:58 +02:00
D. Berge
9a2fdeab0e Fix typo in SQL query.
Fixes #284.
2023-10-28 12:06:42 +02:00
D. Berge
9a3cf7997e Track changes to projects list.
The main application component listens for project events and
updates the Vuex store when a project related event is seen.
2023-10-28 11:25:37 +02:00
14 changed files with 575 additions and 93 deletions

View File

@@ -35,7 +35,7 @@
</style> </style>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import DougalNavigation from './components/navigation'; import DougalNavigation from './components/navigation';
import DougalFooter from './components/footer'; import DougalFooter from './components/footer';
@@ -53,7 +53,8 @@ export default {
computed: { computed: {
snackText () { return this.$store.state.snack.snackText }, snackText () { return this.$store.state.snack.snackText },
snackColour () { return this.$store.state.snack.snackColour } snackColour () { return this.$store.state.snack.snackColour },
...mapGetters(["serverEvent"])
}, },
watch: { watch: {
@@ -75,17 +76,25 @@ export default {
if (!newVal) { if (!newVal) {
this.$store.commit('setSnackText', ""); this.$store.commit('setSnackText', "");
} }
},
async serverEvent (event) {
if (event.channel == "project" && event.payload?.schema == "public") {
// Projects changed in some way or another
await this.refreshProjects();
}
} }
}, },
methods: { methods: {
...mapActions(["setCredentials"]) ...mapActions(["setCredentials", "refreshProjects"])
}, },
mounted () { async mounted () {
// Local Storage values are always strings // Local Storage values are always strings
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true"; this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
this.setCredentials() await this.setCredentials();
this.refreshProjects();
} }
}; };

View File

@@ -0,0 +1,48 @@
<template>
<v-tabs :value="tab" show-arrows>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'DougalAppBarExtensionProject',
data() {
return {
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
]
};
},
computed: {
page () {
return this.$route.path.split(/\/+/)[3];
},
tab () {
return this.tabs.findIndex(t => t.href == this.page);
},
},
methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
}
}
}
</script>

View File

@@ -123,29 +123,11 @@
<v-row dense> <v-row dense>
<v-col cols="12"> <v-col cols="12">
<v-combobox <dougal-event-select
ref="remarks" v-bind.sync="entryRemarks"
v-model="entryRemarks" :preset-remarks="presetRemarks"
:disabled="loading" @update:labels="(v) => this.entryLabels = v"
:search-input.sync="entryRemarksInput" ></dougal-event-select>
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
</v-col> </v-col>
</v-row> </v-row>
@@ -290,6 +272,7 @@
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import DougalContextMenu from '@/components/context-menu'; import DougalContextMenu from '@/components/context-menu';
import DougalEventSelect from '@/components/event-select';
function stringSort (a, b) { function stringSort (a, b) {
return a == b return a == b
@@ -308,6 +291,7 @@ function flattenRemarks(items, keywords=[], labels=[]) {
if (!item.items) { if (!item.items) {
result.push({ result.push({
text: item.text, text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]), labels: labels.concat(item.labels??[]),
keywords keywords
}) })
@@ -342,7 +326,8 @@ export default {
name: 'DougalEventEdit', name: 'DougalEventEdit',
components: { components: {
DougalContextMenu DougalContextMenu,
DougalEventSelect
}, },
props: { props: {
@@ -354,6 +339,7 @@ export default {
sequence: { type: Number }, sequence: { type: Number },
point: { type: Number }, point: { type: Number },
remarks: { type: String }, remarks: { type: String },
meta: { type: Object },
labels: { type: Array, default: () => [] }, labels: { type: Array, default: () => [] },
latitude: { type: Number }, latitude: { type: Number },
longitude: { type: Number }, longitude: { type: Number },
@@ -371,18 +357,11 @@ export default {
entrySequence: null, entrySequence: null,
entryPoint: null, entryPoint: null,
entryRemarks: null, entryRemarks: null,
entryRemarksInput: null,
entryLatitude: null, entryLatitude: null,
entryLongitude: null entryLongitude: null
}), }),
computed: { computed: {
remarksAvailable () {
return this.entryRemarksInput == this.entryRemarks?.text ||
this.entryRemarksInput == this.entryRemarks
? []
: flattenRemarks(this.presetRemarks);
},
allSelected () { allSelected () {
return this.entryLabels.length === this.items.length return this.entryLabels.length === this.items.length
@@ -394,11 +373,6 @@ export default {
return true; return true;
} }
// The user is editing the remarks
if (this.entryRemarksText != this.entryRemarksInput) {
return true;
}
// Selected label set distinct from input labels // Selected label set distinct from input labels
if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) { if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) {
return true; return true;
@@ -502,11 +476,8 @@ export default {
this.entrySequence = this.sequence; this.entrySequence = this.sequence;
this.entryPoint = this.point; this.entryPoint = this.point;
this.entryRemarks = this.remarks;
this.entryLabels = [...(this.labels??[])]; this.entryLabels = [...(this.labels??[])];
this.makeEntryRemarks();
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
} }
}, },
@@ -577,22 +548,13 @@ export default {
}; };
}, },
searchRemarks (item, queryText, itemText) { makeEntryRemarks () {
const needle = queryText.toLowerCase(); this.entryRemarks = {
const text = item.text.toLowerCase(); template: null,
const keywords = item.keywords.map(i => i.toLowerCase()); schema: {},
const labels = item.labels.map(i => i.toLowerCase()); values: [],
return text.includes(needle) || ...this.meta?.structured_values,
keywords.some(i => i.includes(needle)) || text: this.remarks
labels.some(i => i.includes(needle));
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.entryRemarks = event;
this.remarksMenu = false;
} }
}, },
@@ -657,14 +619,24 @@ export default {
save () { save () {
// In case the focus goes directly from the remarks field // In case the focus goes directly from the remarks field
// to the Save button. // to the Save button.
if (this.entryRemarksInput != this.entryRemarksText) {
this.entryRemarks = this.entryRemarksInput; let meta;
if (this.entryRemarks.values?.length) {
meta = {
structured_values: {
template: this.entryRemarks.template,
schema: this.entryRemarks.schema,
values: this.entryRemarks.values
}
};
} }
const data = { const data = {
id: this.id, id: this.id,
remarks: this.entryRemarksText, remarks: this.entryRemarksText,
labels: this.entryLabels labels: this.entryLabels,
meta
}; };
/* NOTE This is the purist way. /* NOTE This is the purist way.

View File

@@ -0,0 +1,142 @@
<template>
<v-card flat>
<v-card-subtitle v-text="text">
</v-card-subtitle>
<v-card-text style="max-height:350px;overflow:scroll;">
<v-form>
<template v-for="key in fieldKeys">
<template v-if="schema[key].enum">
<v-select v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
></v-select>
<v-select v-else :key="key"
v-model="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
></v-select>
</template>
<template v-else>
<v-text-field v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
type="number"
:min="schema[key].minimum"
:max="schema[key].maximum"
:step="schema[key].multiplier"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
>
</v-text-field>
<v-text-field v-else-if="schema[key].type == 'string'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
<v-checkbox v-else-if="schema[key].type == 'boolean'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@change="updateFieldValue(key, $event)"
>
</v-checkbox>
<v-text-field v-else :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
</template>
</template>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalEventPropertiesEdit",
components: {
},
props: {
value: String,
template: String,
schema: Object,
values: Array
},
data () {
return {
}
},
computed: {
fieldKeys () {
return Object.entries(this.schema).sort((a, b) => a[1].title > b[1].title ? 1 : -1).map(i => i[0]);
},
fieldValues () {
const keys = Object.keys(this.schema ?? this.values);
return Object.fromEntries(
keys.map( (k, idx) =>
[ k, this.values?.[idx] ?? this.schema[k].default ]));
},
/*
fields () {
// TODO Remove this and rename fields → schema
return this.schema;
},
*/
text () {
if (this.template) {
const rx = /{{([a-z_][a-z0-9_]*)}}/ig;
return this.template.replace(rx, (match, p1) => this.fieldValues[p1] ?? "(n/a)");
}
}
},
watch: {
values () {
this.$emit("input", this.text);
},
template () {
this.$emit("input", this.text);
},
schema () {
this.$emit("input", this.text);
}
},
methods: {
updateFieldValue(key, ev) {
const values = {...this.fieldValues};
values[key] = ev;
this.$emit("update:values", Object.values(values));
}
},
mount () {
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<v-combobox
ref="remarks"
:value="text"
@input="handleComboBox"
:search-input.sync="entryRemarksInput"
:items="remarksAvailable"
:filter="searchRemarks"
item-text="text"
return-object
label="Remarks"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
prepend-icon="mdi-text-box-outline"
append-outer-icon="mdi-magnify"
@click:append-outer="(e) => remarksMenu = e"
></v-combobox>
<dougal-context-menu
:value="remarksMenu"
@input="handleRemarksMenu"
:items="presetRemarks"
absolute
></dougal-context-menu>
<v-expansion-panels v-if="haveProperties"
class="px-8"
:value="0"
>
<v-expansion-panel>
<v-expansion-panel-header>Properties</v-expansion-panel-header>
<v-expansion-panel-content>
<dougal-event-properties-edit
:value="text"
@input="$emit('update:text', $event)"
:template="template"
:schema="schema"
:values="values"
@update:values="$emit('update:values', $event)"
>
</dougal-event-properties-edit>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventPropertiesEdit from '@/components/event-properties';
export default {
name: "DougalEventSelect",
components: {
DougalContextMenu,
DougalEventPropertiesEdit
},
props: {
text: String,
template: String,
schema: Object,
values: Array,
presetRemarks: Array
},
data () {
return {
entryRemarksInput: null,
remarksMenu: false,
}
},
computed: {
remarksAvailable () {
return this.entryRemarksInput == this.text
? []
: this.flattenRemarks(this.presetRemarks);
},
haveProperties () {
for (const key in this.schema) {
return true;
}
return false;
}
},
watch: {
},
methods: {
flattenRemarks (items, keywords=[], labels=[]) {
const result = [];
if (items) {
for (const item of items) {
if (!item.items) {
result.push({
text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]),
keywords
})
} else {
const k = [...keywords, item.text];
const l = [...labels, ...(item.labels??[])];
result.push(...this.flattenRemarks(item.items, k, l))
}
}
}
return result;
},
searchRemarks (item, queryText, itemText) {
const needle = queryText.toLowerCase();
const text = item.text.toLowerCase();
const keywords = item.keywords.map(i => i.toLowerCase());
const labels = item.labels.map(i => i.toLowerCase());
return text.includes(needle) ||
keywords.some(i => i.includes(needle)) ||
labels.some(i => i.includes(needle));
},
handleComboBox (event) {
if (typeof event == "object") {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
} else {
this.$emit("update:text", event);
this.$emit("update:template", null);
this.$emit("update:properties", null);
this.$emit("update:labels", []);
}
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
this.remarksMenu = false;
}
},
},
mount () {
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
}
}
</script>

View File

@@ -71,17 +71,10 @@
</v-menu> </v-menu>
<!--
<v-btn small text class="ml-2" title="Log out" link to="/?logout=1">
<v-icon small>mdi-logout</v-icon>
</v-btn>
-->
</template> </template>
</template> </template>
<template v-slot:extension v-if="$route.matched.find(i => i.name == 'Project')"> <template v-slot:extension v-if="appBarExtension">
<v-tabs :value="tab" show-arrows align-with-title> <div :is="appBarExtension"></div>
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
</v-tabs>
</template> </template>
</v-app-bar> </v-app-bar>
@@ -95,24 +88,17 @@ export default {
data() { data() {
return { return {
drawer: false, drawer: false,
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },
{ href: "qc", text: "QC" },
{ href: "graphs", text: "Graphs" },
{ href: "map", text: "Map" }
],
path: [] path: []
}; };
}, },
computed: { computed: {
tab () {
return this.tabs.findIndex(t => t.href == this.$route.path.split(/\/+/)[3]); appBarExtension () {
return this.$route.matched
.filter(i => i.meta?.appBarExtension)
.map(i => i.meta.appBarExtension)
.pop()?.component;
}, },
...mapGetters(['user', 'loading']) ...mapGetters(['user', 'loading'])
@@ -131,9 +117,6 @@ export default {
}, },
methods: { methods: {
tabLink (href) {
return `/projects/${this.$route.params.project}/${href}`;
},
breadcrumbs () { breadcrumbs () {
this.path = this.$route.matched this.path = this.$route.matched

View File

@@ -16,7 +16,7 @@ import Log from '../views/Log.vue'
import QC from '../views/QC.vue' import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue' import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue' import Map from '../views/Map.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
Vue.use(VueRouter) Vue.use(VueRouter)
@@ -100,7 +100,10 @@ Vue.use(VueRouter)
text: (ctx) => ctx.$store.state.project.projectName || "…", text: (ctx) => ctx.$store.state.project.projectName || "…",
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/` href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`
} }
] ],
appBarExtension: {
component: DougalAppBarExtensionProject
}
}, },
children: [ children: [
{ {

View File

@@ -37,7 +37,65 @@
</v-btn> </v-btn>
<v-toolbar-title v-if="$refs.calendar"> <v-toolbar-title v-if="$refs.calendar">
{{ $refs.calendar.title }} {{ $refs.calendar.title }}
<span
class="secondary--text"
style="font-size:small;"
>
({{downloadableItemCount}} log entries)
</span>
</v-toolbar-title> </v-toolbar-title>
<v-menu class="ml-5" v-if="calendarDates">
<template v-slot:activator="{on, attrs}">
<v-btn
class="ml-5"
small
:title="`Download events for the period ${calendarDates.start} ${calendarDates.end}`"
v-on="on"
v-bind="attrs"
>
<span class="d-none d-lg-inline">Download as</span>
<v-icon right small>mdi-cloud-download</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
:href="downloadUrl({mime: 'application/vnd.seis+json', filename: `event-log-${calendarDates.start}-${calendarDates.end}-multiseis.json`})"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet supported
<v-list-item
:href="downloadUrl({mime: 'application/geo+json'})"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="downloadUrl({mime: 'application/json', filename: `event-log-${calendarDates.start}-${calendarDates.end}.json`})"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/yaml', filename: `event-log-${calendarDates.start}-${calendarDates.end}.yaml`})"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'text/csv', sortBy: 'tstamp', sortDesc: false, filename: `event-log-${calendarDates.start}-${calendarDates.end}.csv`})"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet supportd
<v-list-item
:href="downloadUrl({mime: 'text/html'})"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/pdf'})"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn v-if="categoriesAvailable" <v-btn v-if="categoriesAvailable"
small small
@@ -169,6 +227,23 @@ export default {
return [...new Set(this.visibleItems.map(i => i.category ?? "General"))]; return [...new Set(this.visibleItems.map(i => i.category ?? "General"))];
}, },
calendarDates () {
// The this.items.length reference is only needed to force recalculation
// of this computed property, as this.$refs is not reactive.
// https://github.com/vuejs/vue/issues/3842
if (this.items.length && this.$refs.calendar) {
return {
start: this.$refs.calendar.renderProps.start.date,
end: this.$refs.calendar.renderProps.end.date
}
}
},
downloadableItemCount () {
return this.events.filter(i => i.tstamp.substr(0, 10) >= this.calendarDates?.start &&
i.tstamp.substr(0, 10) <= this.calendarDates?.end).length;
},
...mapGetters(['sequencesLoading', 'sequences', 'events']) ...mapGetters(['sequencesLoading', 'sequences', 'events'])
}, },
@@ -298,6 +373,18 @@ export default {
}; };
}, },
downloadUrl (qry) {
if (this.calendarDates) {
const url = new URL(`/api/project/${this.$route.params.project}/event`, document.location.href);
for (const key in qry) {
url.searchParams.set(key, qry[key]);
}
url.searchParams.set("date0", this.calendarDates.start);
url.searchParams.set("date1", this.calendarDates.end);
return url.toString();
}
},
...mapActions(["api"]) ...mapActions(["api"])

View File

@@ -86,6 +86,49 @@
>PDF</v-list-item> >PDF</v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
<v-menu v-else>
<template v-slot:activator="{on, attrs}">
<v-btn class="ml-5" small v-on="on" v-bind="attrs">
<span class="d-none d-lg-inline">Download as</span>
<v-icon right small>mdi-cloud-download</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fvnd.seis%2Bjson&filename=event-log-multiseis.json`"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fgeo%2Bjson&filename=event-log.geojson`"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fjson&filename=event-log.json`"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fyaml&filename=event-log.yaml`"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fcsv&sortBy=tstamp&sortDesc=false&filename=event-log.csv`"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fhtml&filename=event-log.html`"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fpdf&filename=event-log.pdf`"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-text-field <v-text-field

View File

@@ -3,6 +3,7 @@ const yaml = require('./yaml');
const geojson = require('./geojson'); const geojson = require('./geojson');
const seis = require('./seis'); const seis = require('./seis');
const csv = require('./csv'); const csv = require('./csv');
const setContentDisposition = require('../../../../lib/utils/setContentDisposition');
module.exports = async function (req, res, next) { module.exports = async function (req, res, next) {
@@ -19,6 +20,7 @@ module.exports = async function (req, res, next) {
if (mimetype) { if (mimetype) {
res.set("Content-Type", mimetype); res.set("Content-Type", mimetype);
setContentDisposition(req, res);
await handlers[mimetype](req, res, next); await handlers[mimetype](req, res, next);
} else { } else {
res.status(406).send(); res.status(406).send();

View File

@@ -25,7 +25,6 @@ async function list (projectId, opts = {}) {
WHERE WHERE
($1::numeric IS NULL OR sequence = $1) AND ($1::numeric IS NULL OR sequence = $1) AND
($2::numeric[] IS NULL OR sequence = ANY( $2 )) AND ($2::numeric[] IS NULL OR sequence = ANY( $2 )) AND
($3::timestamptz IS NULL OR date(tstamp) = $3) AND
($3::timestamptz IS NULL OR ($3::timestamptz IS NULL OR
(($4::timestamptz IS NULL AND date(tstamp) = $3) OR (($4::timestamptz IS NULL AND date(tstamp) = $3) OR
date(tstamp) BETWEEN SYMMETRIC $3 AND $4)) AND date(tstamp) BETWEEN SYMMETRIC $3 AND $4)) AND

View File

@@ -8,5 +8,6 @@ module.exports = {
removeNulls: require('./removeNulls'), removeNulls: require('./removeNulls'),
logicalPath: require('./logicalPath'), logicalPath: require('./logicalPath'),
ranges: require('./ranges'), ranges: require('./ranges'),
unique: require('./unique') unique: require('./unique'),
setContentDisposition: require('./setContentDisposition')
}; };

View File

@@ -0,0 +1,8 @@
function setContentDisposition (req, res) {
if (req.query.filename) {
res.set("Content-Disposition", `attachment; filename="${req.query.filename}"`);
}
}
module.exports = setContentDisposition;

View File

@@ -216,6 +216,26 @@ components:
pattern: "(([^\\s,;:]+)(\\s*[,;:\\s]\\s*)?)+" pattern: "(([^\\s,;:]+)(\\s*[,;:\\s]\\s*)?)+"
example: "line,point,tstamp" example: "line,point,tstamp"
QueryMime:
name: mime
description: |
Ask for the representation of the requested resource to be provided in the indicated MIME type. Overrides the [`Accept`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) request header values (if any).
Note that the server is not obliged to honour the requested type and a default (usually `application/json`) might be provided instead. Always check the response's [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header.
in: query
schema:
type: string
QueryFilename:
name: filename
description: |
Cause the [`Content-Disposition`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) response header to be set and suggest a file name to download as.
Note that the [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) response header is not affected by this parameter.
in: query
schema:
type: string
CSVDelimiter: CSVDelimiter:
description: Delimiter value to use in CSV requests or responses. description: Delimiter value to use in CSV requests or responses.
name: delimiter name: delimiter
@@ -1394,6 +1414,8 @@ paths:
- $ref: "#/components/parameters/CSVDelimiter" - $ref: "#/components/parameters/CSVDelimiter"
- $ref: "#/components/parameters/CSVFields" - $ref: "#/components/parameters/CSVFields"
- $ref: "#/components/parameters/CSVHeader" - $ref: "#/components/parameters/CSVHeader"
- $ref: "#/components/parameters/QueryMime"
- $ref: "#/components/parameters/QueryFilename"
responses: responses:
"200": "200":
description: List of project events description: List of project events