Files
dougal-software/lib/www/client/source/src/views/Log.vue

875 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container fluid>
<v-card>
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>
<span class="d-none d-lg-inline">
{{
$route.params.sequence
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
? `Sequences ${$route.params.sequence.split(";").sort().join(", ")}`
: `Sequence ${$route.params.sequence}`
: $route.params.date0
? $route.params.date1
? `Between ${$route.params.date0} and ${$route.params.date1}`
: `On ${$route.params.date0}`
: "All events"
}}
</span>
<span class="d-lg-none">
{{
$route.params.sequence
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
? `${$route.params.sequence.split(";").sort().join(", ")}`
: `${$route.params.sequence}`
: $route.params.date0
? $route.params.date1
? `${$route.params.date0} ${$route.params.date1}`
: `${$route.params.date0}`
: ""
}}
</span>
</v-toolbar-title>
<dougal-event-edit v-if="writeaccess"
v-model="eventDialog"
v-bind="editedEvent"
:available-labels="userLabels"
:preset-remarks="presetRemarks"
@new="newEvent"
@changed="saveEvent"
>
</dougal-event-edit>
<dougal-event-edit-labels v-if="writeaccess"
v-model="eventLabelsDialog"
:labels="userLabels"
:selected="contextMenuItem ? contextMenuItem.labels||[] : []"
@selectionChanged="(data) => patchEvent(contextMenuItem.id, data)"
>
</dougal-event-edit-labels>
<v-menu v-if="$route.params.sequence">
<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/-/${$route.params.sequence}?mime=application%2Fvnd.seis%2Bjson`"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fgeo%2Bjson`"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fjson`"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=text%2Fhtml`"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fpdf`"
title="Download as a Portable Document File"
>PDF</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter"
single-line
hide-details></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<!-- BEGIN Context menu for log entries -->
<v-menu v-if="writeaccess"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<!-- Add comment: goes on every entry -->
<v-list dense v-if="contextMenuItem">
<v-list-item
@click="() => addEvent(contextMenuItem.items ? contextMenuItem.items[0] : contextMenuItem)"
>
<v-list-item-icon><v-icon>mdi-pencil-plus</v-icon></v-list-item-icon>
<v-list-item-title>Add comment</v-list-item-title>
</v-list-item>
<!-- BEGIN These options don't apply to read-only items -->
<template v-if="!isRowReadonly(contextMenuItem)">
<!-- BEGIN These are whole row entries NOTE: Why? TODO Reconsider this -->
<template v-if="contextMenuItem.key">
<!-- If the item has a shotpoint, that's what we edit -->
<!-- NOTE We may use this in the future to bulk edit comments
related to the same shot / moment, but not now.
<v-list-item @click="true" v-if="contextMenuItem.sequence">
<v-list-item-icon><v-icon>mdi-pencil</v-icon></v-list-item-icon>
<v-list-item-title>Edit shotpoint</v-list-item-title>
</v-list-item>
-->
<!-- Otherwise, it's the timestamp -->
<!-- NOTE We may use this in the future to bulk edit comments
related to the same shot / moment, but not now.
<v-list-item @click="true" v-else>
<v-list-item-icon><v-icon>mdi-pencil</v-icon></v-list-item-icon>
<v-list-item-title>Edit timestamp</v-list-item-title>
</v-list-item>
-->
</template>
<!-- END These are whole row entries -->
<!-- BEGIN These apply to individual comments -->
<!-- Edit comment -->
<v-list-item @click="editEvent(contextMenuItem)" v-if="contextMenuItem.remarks">
<v-list-item-icon><v-icon>mdi-pencil</v-icon></v-list-item-icon>
<v-list-item-title>Edit comment</v-list-item-title>
</v-list-item>
<!-- Edit labels -->
<!-- NOTE The edit comment dialogue takes care of this just fine, but let's
leave it for now and see if people use it. -->
<v-list-item @click="() => eventLabelsDialog=true" v-if="contextMenuItem.labels">
<v-list-item-icon><v-icon>mdi-tag-multiple</v-icon></v-list-item-icon>
<v-list-item-title>Edit labels</v-list-item-title>
</v-list-item>
<!-- END These apply to individual comments -->
</template>
<!-- END These options don't apply to read-only items -->
<!-- View item on map if it has a geometry -->
<v-list-item v-if="viewOnMap(contextMenuItem)" :href="viewOnMap(contextMenuItem)">
<v-list-item-icon><v-icon>mdi-map</v-icon></v-list-item-icon>
<v-list-item-title>View on map</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<!-- Delete command: single comment -->
<v-list-item @click="() => removeEvent(contextMenuItem)" v-if="deletableEntries(contextMenuItem) == 1">
<v-list-item-icon><v-icon>mdi-delete</v-icon></v-list-item-icon>
<v-list-item-title class="warning--text">Delete comment</v-list-item-title>
</v-list-item>
<!-- Delete command: read-only item (no action) -->
<v-list-item v-else-if="deletableEntries(contextMenuItem) == 0" disabled>
<v-list-item-icon><v-icon>mdi-delete-off</v-icon></v-list-item-icon>
<v-list-item-title>This entry is read-only</v-list-item-title>
</v-list-item>
<!-- Delete command: multiple comments -->
<v-list-item @click="() => removeEvent(contextMenuItem)" v-else>
<v-list-item-icon><v-icon>mdi-delete</v-icon></v-list-item-icon>
<v-list-item-title class="error--text">Delete all comments</v-list-item-title>
</v-list-item>
<!-- BEGIN This section only applies to QC events -->
<template v-if="contextMenuItem.meta.qc_id">
<v-divider></v-divider>
<!-- Mark QC accepted -->
<v-list-item @click="() => acceptQc(contextMenuItem)" v-if="!isAcceptedQc(contextMenuItem)">
<v-list-item-icon><v-icon>mdi-check</v-icon></v-list-item-icon>
<v-list-item-title>Mark QC accepted</v-list-item-title>
</v-list-item>
<!-- Unmark QC accepted -->
<v-list-item @click="() => acceptQc(contextMenuItem, false)" v-else>
<v-list-item-icon><v-icon>mdi-restore</v-icon></v-list-item-icon>
<v-list-item-title>Unmark QC accepted</v-list-item-title>
</v-list-item>
</template>
<!-- END This section only applies to QC events -->
</v-list>
</v-menu>
<!-- END Context menu for log entries -->
<v-data-table
dense
:headers="headers"
:items="rows"
:items-per-page.sync="itemsPerPage"
item-key="key"
:item-class="itemClass"
sort-by="tstamp"
:sort-desc="true"
:search="filter"
:custom-filter="searchTable"
:loading="loading"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
<template v-slot:item.tstamp="{value}">
<span style="white-space:nowrap;" v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</span>
</template>
<template v-slot:item.remarks="{item}">
<template>
<div>
<div v-for="entry in item.items"
@contextmenu.stop="(e) => contextMenu(e, {entry})"
>
<span v-if="entry.labels.length">
<v-chip v-for="label in entry.labels"
class="mr-1 px-2 underline-on-hover"
x-small
:color="labels[label] && labels[label].view.colour"
:title="labels[label] && labels[label].view.description"
:dark="labels[label] && labels[label].view.dark"
:light="labels[label] && labels[label].view.light"
:key="label"
:href="$route.path+'?label='+encodeURIComponent(label)"
>{{label}}</v-chip>
</span>
<dougal-event-edit-history v-if="entry.has_edits && writeaccess"
:id="entry.id"
:disabled="loading"
:labels="labels"
></dougal-event-edit-history>
<span v-if="entry.meta.readonly"
class="entry text--secondary"
v-html="$options.filters.markdownInline(entry.remarks)">
</span>
<span v-else
class="entry underline-on-hover"
v-html="$options.filters.markdownInline(entry.remarks)">
</span>
</div>
</div>
</template>
</template>
<template v-slot:footer.prepend>
<v-checkbox v-for="label in filterableLabels"
:key="label"
class="mr-3"
v-model="shownLabels"
:value="label"
:title="`Show ${label} events`"
dense
hide-details
>
<template v-slot:label>
<v-chip
x-small
:color="labels[label] && labels[label].view.colour"
:title="labels[label] && labels[label].view.description"
:dark="labels[label] && labels[label].view.dark"
:light="labels[label] && labels[label].view.light"
>{{label}}
</v-chip>
</template>
</v-checkbox>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</template>
<style>
tr.align-top td:not(.align-default) {
vertical-align: top;
}
</style>
<style scoped>
.hover {
opacity: 0.4;
}
.hover:hover {
opacity: 1;
}
.hover.off:hover {
opacity: 0.4;
}
.underline-on-hover:hover {
text-decoration: underline;
}
.entry {
cursor: default;
}
.entry.underline-on-hover:hover {
text-decoration: underline;
}
</style>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventEdit from '@/components/event-edit'
import DougalEventEditLabels from '@/components/event-edit-labels'
import DougalEventEditHistory from '@/components/event-edit-history'
function ArraysEqual (a, b) {
return a.every(i => b.includes(i)) && b.every(i => a.includes(i));
}
function EventKey (e) {
return e.type+e.id;
}
function clone (obj) {
return JSON.parse(JSON.stringify(obj));
}
export default {
name: "Log",
components: {
DougalEventEdit,
DougalEventEditLabels,
DougalEventEditHistory,
DougalContextMenu
},
data () {
return {
headers: [
{
value: "tstamp",
text: "Timestamp",
width: "20ex"
},
{
value: "sequence",
text: "Sequence",
align: "end",
width: "10ex"
},
{
value: "point",
text: "Shotpoint",
align: "end",
width: "10ex"
},
{
value: "remarks",
text: "Text",
width: "100%",
cellClass: "align-default"
}
],
items: [],
labels: {},
options: {},
filter: "",
filterableLabels: [ "QC", "QCAccepted" ],
shownLabels: [ "QC", "QCAccepted" ],
eventCount: null,
eventDialog: false,
eventLabelsDialog: false,
defaultEventTimestamp: null,
presetRemarks: null,
remarksMenu: null,
remarksMenuItem: null,
editedEvent: {},
labelSearch: null,
queuedReload: false,
itemsPerPage: 25,
// Row highlighter
activeItem: null,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null
}
},
computed: {
rows () {
const rows = {};
this.items
.filter(i => {
for (const label of this.filterableLabels) {
if (!this.shownLabels.includes(label) && i.labels.includes(label)) {
return false;
}
}
return true;
})
.forEach(i => {
const key = (i.sequence && i.point) ? (i.sequence+"@"+i.point) : i.tstamp;
if (!rows[key]) {
rows[key] = {
key,
tstamp: i.tstamp,
sequence: i.sequence,
point: i.point,
items: []
}
}
const row = rows[key];
row.items.push(i);
});
return Object.values(rows);
},
userLabels () {
const filtered = {};
for (const key in this.labels) {
if (this.labels[key].model.user) {
filtered[key] = this.labels[key];
}
}
return filtered;
},
popularLabels () {
const tuples = this.items.flatMap( i => i.labels )
.filter( l => (this.labels[l]??{})?.model?.user )
.reduce( (acc, cur) => {
return cur in acc ? ++acc[cur][1] : acc[cur]=[cur,1], acc
}, {});
return Object.values(tuples)
.sort( (a, b) => b[1]-a[1] );
},
defaultSequence () {
if (this.$route.params.sequence) {
return Number(this.$route.params.sequence.split(";").pop());
} else {
return this.sequence;
}
},
...mapGetters(['user', 'writeaccess', 'loading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
options: {
handler () {
//this.getEvents();
},
deep: true
},
eventDialog (val) {
if (val) {
// If not online
this.defaultEventTimestamp = Date.now();
}
},
async serverEvent (event) {
if (event.channel == "event" && event.payload.schema == this.projectSchema) {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getEvents();
} else {
this.queuedReload = true;
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getEvents();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getEvents();
}
},
itemsPerPage (newVal, oldVal) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
},
user (newVal, oldVal) {
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
}
},
methods: {
debug (value) {
console.log("DEBUG", value);
},
contextMenu (e, {item, entry}) {
e.preventDefault();
this.contextMenuShow = false;
this.contextMenuX = e.clientX;
this.contextMenuY = e.clientY;
this.contextMenuItem = entry ?? item;
this.$nextTick( () => this.contextMenuShow = true );
},
/** Check if this item (or entry) has multiple
* deletable items, just one, or none (i.e., it is read-only)
*
* @return {Number} Number of deletable items.
*/
deletableEntries (item) {
return item.items
? item.items.filter(e => !e.meta.readonly).length
: item.meta.readonly
? 0
: 1;
},
isRowReadonly (item) {
return !this.deletableEntries(item);
},
itemClass (item) {
if (this.activeItem == item) {
return 'align-top blue accent-1 elevation-3';
} else if (item.sequence && item.point && item.tstamp) {
return this.$vuetify.theme.isDark
? 'align-top blue-grey darken-4'
: 'align-top blue-grey lighten-5';
} else {
return 'align-top';
}
},
async getEventCount () {
//this.eventCount = await this.api([`/project/${this.$route.params.project}/event/?count`]);
this.eventCount = null;
},
async getEvents (opts = {}) {
const query = new URLSearchParams(this.options);
if (this.options.itemsPerPage < 0) {
query.delete("itemsPerPage");
}
if (this.$route.params.sequence) {
query.set("sequence", this.$route.params.sequence);
}
if (this.$route.params.date0) {
query.set("date0", this.$route.params.date0);
}
if (this.$route.params.date1) {
query.set("date1", this.$route.params.date1);
}
const url = `/project/${this.$route.params.project}/event?${query.toString()}`;
this.queuedReload = false;
this.items = await this.api([url, opts]) || [];
},
async getLabelDefinitions () {
const url = `/project/${this.$route.params.project}/label`;
//const labelSet = {};
this.labels = await this.api([url]) ?? {};
//labels.forEach( l => labelSet[l.name] = l.data );
//this.labels = labelSet;
},
async getPresetRemarks () {
const url = `/project/${this.$route.params.project}/configuration`;
this.presetRemarks = (await this.api([url]))?.events?.presetRemarks ?? {};
},
newItem (from = {}) {
const type = (from.sequence && from.point) ? "sequence" : "timed";
const tstamp = from.tstamp || (new Date).toISOString();
const sequence = from.sequence || null; // FIXME TODO Use vuex
const point = from.point || null; // FIXME TODO Use vuex
const geometry = from.geometry || null; // FIXME TODO Use vuex
return {
type,
id: null,
tstamp,
sequence,
point,
remarks: "",
labels: [],
geometry
};
},
cloneEvent (template = {}) {
this.editedEvent = clone(template);
// NOTE Can we actually use "id", it being a reserved HTML attribute?
this.editedEvent.id = template.id;
if (template.meta?.geometry?.type == "Point") {
this.editedEvent.longitude = template.meta.geometry.coordinates[0];
this.editedEvent.latitude = template.meta.geometry.coordinates[1];
}
},
/** Add a brand new event.
*
* Used when adding a new event to the database,
* assumed to have occurred in the immediate past,
* so we populate it with the last received real-time
* information (timestamp, shotpoint, position, etc.)
*/
newEvent () {
this.editedEvent = {
tstamp: this.timestamp,
sequence: this.sequence,
point: this.point,
longitude: (this.position??[])[0],
latitude: (this.position??[])[1]
}
},
/** Add an event based on another.
*
* Used when adding an event to a timestamp or
* point for which we already have an event.
*/
addEvent (template) {
this.cloneEvent(template);
this.editedEvent.id = null;
this.editedEvent.remarks = null;
this.editedEvent.labels = null;
this.eventDialog = true;
},
editEvent (template) {
this.cloneEvent(template);
this.eventDialog = true;
},
async patchEvent (id, data) {
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
const url = `/project/${this.$route.params.project}/event/${id}`;
await this.api([url, {
method: "PATCH",
body: data
}, callback]);
},
// TODO POST or PATCH depending on whether this is a new event
// (no id) or an edit (id present)
async saveEvent (event) {
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
if (event) {
if (event.id) {
const id = event.id;
delete event.id;
this.putEvent(id, event, callback); // No await
} else {
this.postEvent(event, callback); // No await
}
}
},
async putEvent (id, event, callback) {
const url = `/project/${this.$route.params.project}/event/${id}`;
await this.api([url, {
method: "PATCH",
body: event
}, callback]);
},
async postEvent (event, callback) {
const url = `/project/${this.$route.params.project}/event`;
await this.api([url, {
method: "POST",
body: event
}, callback]);
},
async removeEvent (target) {
if (Array.isArray(target?.items)) {
return await this.removeEvent(target.items);
}
if (Array.isArray(target)) {
if (target.length == 1) {
return await this.removeEvent(target[0]);
}
const ids = target.map(i => i?.id ?? i);
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack([`${ids.length} events deleted`, "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
Promise.all(ids.forEach( id => {
const url = `/project/${this.$route.params.project}/event/${id}`;
return this.api([url, {method: "DELETE"}]);
})).then(callback);
} else {
const id = target?.id ?? target;
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack(["Event deleted", "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
const url = `/project/${this.$route.params.project}/event/${id}`;
await this.api([url, {
method: "DELETE",
}, callback]);
}
},
addPresetRemark ({text}) {
if (this.remarksMenuItem) {
this.remarksMenuItem.remarks = text;
}
this.remarksMenuItem = null;
},
availableLabels (usedLabels) {
return Object.keys(this.labels)
.filter(k => !usedLabels.includes(k) && this.labels[k].model.user);
},
handleKeyboardEvent (e) {
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
// Add timed event if offline or shot event if online
this.eventDialog = true;
} else if (e.ctrlKey && !e.altKey && e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
// Add timed event (even if online)
this.eventDialog = true;
}
},
searchTable (value, search, item) {
if (!value && !search) return true;
const s = search.toLowerCase();
if (typeof value === 'string') {
return value.toLowerCase().includes(s);
} else if (typeof value === 'number') {
return value == search;
} else {
return item.items.some( i => i.remarks.toLowerCase().includes(s) ) ||
item.items.some( i => i.labels.some( l => l.toLowerCase().includes(s) ));
}
},
viewOnMap(item) {
if (item?.meta && item.meta?.geometry?.type == "Point") {
const [ lon, lat ] = item.meta.geometry.coordinates;
return `map#15/${lon.toFixed(6)}/${lat.toFixed(6)}`;
} else if (item?.items) {
return this.viewOnMap(item.items[0]);
}
},
isAcceptedQc (item) {
return item.labels.includes('QCAccepted');
},
async acceptQc (item, accept = true) {
const url = accept
? `/project/${this.$route.params.project}/qc/results/accept`
: `/project/${this.$route.params.project}/qc/results/unaccept`;
await this.api([url, {
method: "POST",
body: [ item.id ]
}]);
},
setActiveItem (item) {
// Disable setting the active item for now,
// it's kind of annoying.
return;
/*
this.activeItem = this.activeItem == item
? null
: item;
*/
},
...mapActions(["api", "showSnack"])
},
async mounted () {
await this.getLabelDefinitions();
this.getEventCount();
this.getEvents();
this.getPresetRemarks();
window.addEventListener('keyup', this.handleKeyboardEvent);
},
beforeDestroy () {
window.removeEventListener('keyup', this.handleKeyboardEvent);
}
}
</script>