Compare commits

..

24 Commits

Author SHA1 Message Date
D. Berge
4c2a2617a1 Adapt Project component to Vuex use for fetching data.
The Project component is now responsible for fetching and
updating the data used by most project tabs, with the
exception of ProjectSummary, QC, Graphs and Map. It is
also the only one listening for server events and reacting
to them.

Individual tabs are still responsible for sending data to
the server, at least for the time being.
2023-10-25 16:19:18 +02:00
D. Berge
5021888d03 Adapt Log component to Vuex use for fetching data 2023-10-25 16:18:41 +02:00
D. Berge
bf633f7fdf Refactor Calendar component.
- adapts it to Vuex use for fetching data
- displays extra events in 4-day and day views
- allows classifying by event label in 4-day and day views
2023-10-25 16:16:01 +02:00
D. Berge
847f49ad7c Adapt SequenceList component to Vuex use for fetching data 2023-10-25 16:15:17 +02:00
D. Berge
171feb9dd2 Adapt Plan component to Vuex use for fetching data 2023-10-25 16:14:45 +02:00
D. Berge
503a0de12f Adapt LineList component to Vuex use for fetching data 2023-10-25 16:13:56 +02:00
D. Berge
cf89a43f64 Add project configuration to Vuex store 2023-10-25 16:11:24 +02:00
D. Berge
680e376ed1 Add Vuex sequence module 2023-10-25 16:11:24 +02:00
D. Berge
a26974670a Add Vuex plan module 2023-10-25 16:11:24 +02:00
D. Berge
16a6cb59dc Add Vuex line module 2023-10-25 16:11:24 +02:00
D. Berge
829e206831 Add Vuex label module 2023-10-25 09:59:04 +02:00
D. Berge
83244fcd1a Add Vuex event module 2023-10-25 09:51:28 +02:00
D. Berge
851369a0b4 Invalidate planner endpoint cache when setting remarks 2023-10-23 14:58:41 +02:00
D. Berge
5065d62443 Update planner endpoint documentation 2023-10-23 14:57:27 +02:00
D. Berge
2d1e1e9532 Modify return payload of planner endpoint.
Previous:

[
  { sequence: …},
  { sequence: …},
  …
]

Current:

{
  remarks: "…",
  sequences: [
    { sequence: …},
    { sequence: …},
    …
  ]
}
2023-10-23 14:53:32 +02:00
D. Berge
051049581a Merge branch '278-rewrite-events-queue' into 'devel'
Resolve "Rewrite events queue"

Closes #278

See merge request wgp/dougal/software!46
2023-10-17 10:28:21 +00:00
D. Berge
da5ae18b0b Merge branch '269-support-requesting-a-partial-update-from-the-events-log-endpoint' into devel 2023-10-17 12:27:31 +02:00
D. Berge
ac9353c101 Add database upgrade file 31. 2023-10-17 12:27:06 +02:00
D. Berge
c4c5c44bf1 Add comment 2023-10-17 12:20:19 +02:00
D. Berge
d3659ebf02 Merge branch '269-support-requesting-a-partial-update-from-the-events-log-endpoint' into 'devel'
Resolve "Support requesting a partial update from the events log endpoint"

Closes #269

See merge request wgp/dougal/software!47
2023-10-17 10:18:41 +00:00
D. Berge
6b5070e634 Add event changes API endpoint description 2023-10-17 12:15:41 +02:00
D. Berge
09ff96ceee Add events change API endpoint 2023-10-17 11:15:36 +02:00
D. Berge
f231acf109 Add events change middleware 2023-10-17 11:15:06 +02:00
D. Berge
e576e1662c Add library function returning event changes after given epoch 2023-10-17 11:13:58 +02:00
47 changed files with 1791 additions and 369 deletions

View File

@@ -0,0 +1,104 @@
-- Add event_log_changes function
--
-- New schema version: 0.4.4
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects all schemas in the database.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This adds a function event_log_changes which returns the subset of
-- events from event_log_full which have been modified on or after a
-- given timestamp.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_survey_schema (schema_name text) AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', schema_name;
-- We need to set the search path because some of the trigger
-- functions reference other tables in survey schemas assuming
-- they are in the search path.
EXECUTE format('SET search_path TO %I,public', schema_name);
CREATE OR REPLACE FUNCTION event_log_changes(ts0 timestamptz)
RETURNS SETOF event_log_full
LANGUAGE sql
AS $$
SELECT *
FROM event_log_full
WHERE lower(validity) > ts0 OR upper(validity) IS NOT NULL AND upper(validity) > ts0
ORDER BY lower(validity);
$$;
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.4.4' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.4.3' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
FOR row IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'survey_%'
ORDER BY schema_name
LOOP
CALL pg_temp.upgrade_survey_schema(row.schema_name);
END LOOP;
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_survey_schema (schema_name text);
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.4"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.4.4"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -2,8 +2,8 @@
<div class="line-status" v-if="sequences.length == 0">
<slot name="empty"></slot>
</div>
<div class="line-status" v-else-if="sequenceHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence"
<div class="line-status" v-else-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence" v-if="sequenceHref"
class="sequence"
:class="sequence.status"
:style="style(sequence)"
@@ -11,15 +11,41 @@
:to="sequenceHref(sequence)"
>
</router-link>
<router-link v-for="sequence in plannedSequences" :key="sequence.sequence" v-if="plannedSequenceHref"
class="sequence planned"
:style="style(sequence)"
:title="title(sequence, 'planned')"
:to="plannedSequenceHref(sequence)"
>
</router-link>
<router-link v-for="(line, key) in pendingReshoots" :key="key" v-if="pendingReshootHref"
class="sequence reshoot"
:style="style(line)"
:title="title(line, 'reshoot')"
:to="pendingReshootHref(line)"
>
</router-link>
</div>
<div class="line-status" v-else>
<div v-for="sequence in sequences"
<div v-for="sequence in sequences" :key="sequence.sequence"
class="sequence"
:class="sequence.status"
:style="style(sequence)"
:title="title(sequence)"
>
</div>
<div v-for="sequence in plannedSequences" :key="sequence.sequence"
class="sequence planned"
:style="style(sequence)"
:title="title(sequence, 'planned')"
>
</div>
<div v-for="(line, key) in pendingReshoots" :key="key"
class="sequence reshoot"
:style="style(line)"
:title="title(line, 'reshoot')"
>
</div>
</div>
</template>
@@ -48,6 +74,8 @@
background-color blue
&.planned
background-color magenta
&.reshoot
background repeating-linear-gradient(-45deg, rgba(255,0,255,0.302), brown 5px, rgba(247, 247, 247, 0.1) 5px, rgba(242, 241, 241, 0.08) 10px), repeating-linear-gradient(45deg, rgba(255,0,255,0.302), brown 5px, rgba(247, 247, 247, 0.1) 5px, rgba(242, 241, 241, 0.08) 10px)
</style>
<script>
@@ -58,7 +86,11 @@ export default {
props: {
preplot: Object,
sequences: Array,
"sequence-href": Function
"sequence-href": Function,
"planned-sequences": Array,
"planned-sequence-href": Function,
"pending-reshoots": Array,
"pending-reshoot-href": Function
},
methods: {
@@ -68,13 +100,13 @@ export default {
? s.fsp_final
: s.status == "ntbp"
? (s.fsp_final || s.fsp)
: s.fsp; /* status == "raw" */
: s.fsp; /* status == "raw" or planned sequence or pending reshoot */
const lsp = s.status == "final"
? s.lsp_final
: s.status == "ntbp"
? (s.lsp_final || s.lsp)
: s.lsp; /* status == "raw" */
: s.lsp; /* status == "raw" or planned sequence or pending reshoot */
const pp0 = Math.min(this.preplot.fsp, this.preplot.lsp);
const pp1 = Math.max(this.preplot.fsp, this.preplot.lsp);
@@ -91,20 +123,24 @@ export default {
return values;
},
title (s) {
const status = s.status == "final"
? "Final"
: s.status == "raw"
? "Acquired"
: s.status == "ntbp"
? "NTBP"
: s.status == "planned"
? "Planned"
: s.status;
title (s, type) {
if (s.status || type == "planned") {
const status = s.status == "final"
? "Final"
: s.status == "raw"
? "Acquired"
: s.status == "ntbp"
? "NTBP"
: type == "planned"
? "Planned"
: s.status;
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()
return `Sequence ${s.sequence} ${status} (${s.fsp_final || s.fsp}${s.lsp_final || s.lsp})${remarks}`;
return `Sequence ${s.sequence} ${status} (${s.fsp_final || s.fsp}${s.lsp_final || s.lsp})${remarks}`;
} else if (type == "reshoot") {
return `Pending reshoot (${s.fsp}${s.lsp})${s.remarks? "\n"+s.remarks : ""}`;
}
}
}

View File

@@ -5,6 +5,11 @@ import api from './modules/api'
import user from './modules/user'
import snack from './modules/snack'
import project from './modules/project'
import event from './modules/event'
import label from './modules/label'
import sequence from './modules/sequence'
import plan from './modules/plan'
import line from './modules/line'
import notify from './modules/notify'
Vue.use(Vuex)
@@ -15,6 +20,11 @@ export default new Vuex.Store({
user,
snack,
project,
event,
label,
sequence,
plan,
line,
notify
}
})

View File

@@ -0,0 +1,129 @@
/** Fetch events from server
*/
async function refreshEvents ({commit, dispatch, state, rootState}, [modifiedAfter] = []) {
if (!modifiedAfter) {
modifiedAfter = state.timestamp;
}
if (state.loading) {
commit('abortEventsLoading');
}
commit('setEventsLoading');
const pid = rootState.project.projectId;
const url = modifiedAfter
? `/project/${pid}/event/changes/${(new Date(modifiedAfter)).toISOString()}?unique=t`
: `/project/${pid}/event`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
if (modifiedAfter) {
commit('setModifiedEvents', res);
} else {
commit('setEvents', res);
}
commit('setEventsTimestamp');
}
commit('clearEventsLoading');
}
/** Return a subset of events from state.events
*/
async function getEvents ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text, label}]) {
let filteredEvents = [...state.events];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredEvents.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredEvents.reverse();
}
});
}
if (sequence) {
filteredEvents = filteredEvents.filter( event => event.sequence == sequence );
}
if (date0 && date1) {
filteredEvents = filteredEvents.filter( event =>
event.tstamp.substr(0, 10) >= date0 && event.tstamp.substr(0, 10) <= date1
);
} else if (date0) {
filteredEvents = filteredEvents.filter( event => event.tstamp.substr(0, 10) == date0 );
}
if (text) {
const tstampFilter = (value, search, item) => {
return textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
tstamp: tstampFilter,
sequence: numberFilter,
point: numberFilter,
remarks: textFilter,
labels: (value, search, item) => value.some(label => textFilter(label, search, item))
};
filteredEvents = filteredEvents.filter ( event => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(event[key], text, event)) {
return true;
}
}
return false;
});
}
if (label) {
filteredEvents = filteredEvents.filter( event => event.labels?.includes(label) );
}
const count = filteredEvents.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredEvents = filteredEvents.slice(offset, offset+itemsPerPage);
}
return {events: filteredEvents, count};
}
export default { refreshEvents, getEvents };

View File

@@ -0,0 +1,14 @@
function events (state) {
return state.events;
}
function eventCount (state) {
return state.events?.length ?? 0;
}
function eventsLoading (state) {
return !!state.loading;
}
export default { events, eventCount, eventsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,73 @@
function setEvents (state, events) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.events = Object.freeze(events);
}
/** Selectively replace / insert / delete events
* from state.events.
*
* modifiedEvents is the result of
* /api/project/:project/event/changes?unique=t
*/
function setModifiedEvents (state, modifiedEvents) {
const events = [...state.events];
for (let evt of modifiedEvents) {
const idx = events.findIndex(i => i.id == evt.id);
if (idx != -1) {
if (evt.is_deleted) {
events.splice(idx, 1);
} else {
delete evt.is_deleted;
events.splice(idx, 1, evt);
}
} else {
if (!evt.is_deleted) {
delete evt.is_deleted;
events.unshift(evt);
}
}
}
setEvents(state, events);
}
function setEventsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
function clearEventsLoading (state) {
state.loading = null;
}
function setEventsTimestamp (state, timestamp = new Date()) {
if (timestamp === true) {
const tstamp = state.events
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setEventsETag (state, etag) {
state.etag = etag;
}
function abortEventsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setEvents,
setModifiedEvents,
setEventsLoading,
clearEventsLoading,
abortEventsLoading,
setEventsTimestamp,
setEventsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
events: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,106 @@
/** Fetch labels from server
*/
async function refreshLabels ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortLabelsLoading');
}
commit('setLabelsLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/label`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setLabels', res);
commit('setLabelsTimestamp');
}
commit('clearLabelsLoading');
}
/** Return a subset of labels from state.labels.
*
* Note that, unlike other actions in the get* family,
* the return value is not isomorphic to the state.
*
* While state.labels is an object, getLabels() returns
* an array with each item have the shape:
*
* { label: "labelName", view: {…}, model: {…} }
*
* This is intended to be useful, for instance, for a table
* of labels.
*/
async function getLabels ({commit, dispatch, state}, [projectId, {sortBy, sortDesc, itemsPerPage, page, text, label}]) {
let filteredLabels = Object.entries(state.labels).map(i => {
return {
label: i[0],
...i[1]
}
});
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredLabels.sort( (el0, el1) => {
const a = key == "label" ? el0[0] : el0[1].view[key];
const b = key == "label" ? el1[0] : el1[1].view[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredLabels.reverse();
}
});
}
if (label) {
filteredLabels = filteredLabels.filter( label => label.label == label );
}
if (text) {
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
label: numberFilter,
description: textFilter,
};
filteredLabels = filteredLabels.filter ( item => {
return textFilter(item.label, text, item) ?? textFilter(item.view.description, text, item);
});
}
const count = filteredLabels.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredLabels = filteredLabels.slice(offset, offset+itemsPerPage);
}
return {labels: filteredLabels, count};
}
export default { refreshLabels, getLabels };

View File

@@ -0,0 +1,22 @@
function labels (state) {
return state.labels;
}
/** Return labels that can be added by users.
*
* As opposed to system labels.
*/
function userLabels (state) {
return Object.fromEntries(Object.entries(state.labels).filter(i => i[1].model.user));
}
function labelCount (state) {
return state.labels?.length ?? 0;
}
function labelsLoading (state) {
return !!state.loading;
}
export default { labels, userLabels, labelCount, labelsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setLabels (state, labels) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.labels = Object.freeze(labels);
}
function setLabelsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearLabelsLoading (state) {
state.loading = null;
}
function setLabelsTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the labels
// result or in the database schema, but we could add
// one.
if (timestamp === true) {
const tstamp = state.labels
.map( i => i.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setLabelsETag (state, etag) {
state.etag = etag;
}
function abortLabelsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setLabels,
setLabelsLoading,
clearLabelsLoading,
setLabelsTimestamp,
setLabelsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
labels: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,117 @@
/** Fetch lines from server
*/
async function refreshLines ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortLinesLoading');
}
commit('setLinesLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/line`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setLines', res);
commit('setLinesTimestamp');
}
commit('clearLinesLoading');
}
/** Return a subset of lines from state.lines
*/
async function getLines ({commit, dispatch, state}, [projectId, {line, fsp, lsp, incr, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredLines = [...state.lines];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredLines.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredLines.reverse();
}
});
}
if (line) {
filteredLines = filteredLines.filter( line => line.line == line );
}
if (fsp) {
filteredLines = filteredLines.filter( line => line.fsp == fsp );
}
if (lsp) {
filteredLines = filteredLines.filter( line => line.lsp == lsp );
}
if (text) {
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const incrFilter = (value, search, item) => {
const inc = /^(incr(ement)?|↑|\+)/i;
const dec = /^(decr(ement)?|↓|-)/i;
return (inc.test(search) && value) || (dec.test(search) && !value)
}
const searchFunctions = {
line: numberFilter,
fsp: numberFilter,
lsp: numberFilter,
remarks: textFilter,
incr: incrFilter,
ntba: (value, search, item) => text.toLowerCase() == "ntba" && value
};
filteredLines = filteredLines.filter ( line => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(line[key], text, line)) {
return true;
}
}
return false;
});
}
const count = filteredLines.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredLines = filteredLines.slice(offset, offset+itemsPerPage);
}
return {lines: filteredLines, count};
}
export default { refreshLines, getLines };

View File

@@ -0,0 +1,14 @@
function lines (state) {
return state.lines;
}
function lineCount (state) {
return state.lines?.length ?? 0;
}
function linesLoading (state) {
return !!state.loading;
}
export default { lines, lineCount, linesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setLines (state, lines) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.lines = Object.freeze(lines);
}
function setLinesLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearLinesLoading (state) {
state.loading = null;
}
function setLinesTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the lines
// result or in the database schema, but we could perhaps add
// one.
if (timestamp === true) {
const tstamp = state.lines
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setLinesETag (state, etag) {
state.etag = etag;
}
function abortLinesLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setLines,
setLinesLoading,
clearLinesLoading,
setLinesTimestamp,
setLinesETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
lines: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -0,0 +1,114 @@
/** Fetch sequences from server
*/
async function refreshPlan ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortPlanLoading');
}
commit('setPlanLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/plan`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setPlan', res);
commit('setPlanTimestamp');
}
commit('clearPlanLoading');
}
/** Return a subset of sequences from state.sequences
*/
async function getPlannedSequences ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredPlannedSequences = [...state.sequences];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredPlannedSequences.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredPlannedSequences.reverse();
}
});
}
if (sequence) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence => sequence.sequence == sequence );
}
if (date0 && date1) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence =>
sequence.ts0.substr(0, 10) >= date0 && sequence.ts1.substr(0, 10) <= date1
);
} else if (date0) {
filteredPlannedSequences = filteredPlannedSequences.filter( sequence => sequence.ts0.substr(0, 10) == date0 || sequence.ts1.substr(0, 10) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return textFilter(value.toISOString(), search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
sequence: numberFilter,
line: numberFilter,
remarks: textFilter,
ts0: tstampFilter,
ts1: tstampFilter
};
filteredPlannedSequences = filteredPlannedSequences.filter ( sequence => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(sequence[key], text, sequence)) {
return true;
}
}
return false;
});
}
const count = filteredPlannedSequences.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredPlannedSequences = filteredPlannedSequences.slice(offset, offset+itemsPerPage);
}
return {sequences: filteredPlannedSequences, count};
}
export default { refreshPlan, getPlannedSequences };

View File

@@ -0,0 +1,18 @@
function planRemarks (state) {
return state.remarks;
}
function plannedSequences (state) {
return state.sequences;
}
function plannedSequenceCount (state) {
return state.sequences?.length ?? 0;
}
function plannedSequencesLoading (state) {
return !!state.loading;
}
export default { planRemarks, plannedSequences, plannedSequenceCount, plannedSequencesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,59 @@
function transform (item) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
return item;
}
// ATTENTION: This relies on the new planner endpoint
// as per issue #281.
function setPlan (state, plan) {
// We don't need or want the planned sequences array to be reactive
state.sequences = Object.freeze(plan.sequences.map(transform));
state.remarks = plan.remarks;
}
function setPlanLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearPlanLoading (state) {
state.loading = null;
}
function setPlanTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the plan
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.plan
.map( item => item.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setPlanETag (state, etag) {
state.etag = etag;
}
function abortPlanLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setPlan,
setPlanLoading,
clearPlanLoading,
setPlanTimestamp,
setPlanETag
};

View File

@@ -0,0 +1,9 @@
const state = () => ({
sequences: Object.freeze([]),
remarks: null,
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -1,10 +1,11 @@
async function getProject ({commit, dispatch}, projectId) {
const res = await dispatch('api', [`/project/${String(projectId).toLowerCase()}`]);
const res = await dispatch('api', [`/project/${String(projectId).toLowerCase()}/configuration`]);
if (res) {
commit('setProjectName', res.name);
commit('setProjectId', res.pid);
commit('setProjectId', res.id?.toLowerCase());
commit('setProjectSchema', res.schema);
commit('setProjectConfiguration', res);
const recentProjects = JSON.parse(localStorage.getItem("recentProjects") || "[]")
recentProjects.unshift(res);
localStorage.setItem("recentProjects", JSON.stringify(recentProjects.slice(0, 3)));
@@ -12,6 +13,7 @@ async function getProject ({commit, dispatch}, projectId) {
commit('setProjectName', null);
commit('setProjectId', null);
commit('setProjectSchema', null);
commit('setProjectConfiguration', {});
}
}

View File

@@ -11,4 +11,8 @@ function projectSchema (state) {
return state.projectSchema;
}
export default { projectId, projectName, projectSchema };
function projectConfiguration (state) {
return state.projectConfiguration;
}
export default { projectId, projectName, projectSchema, projectConfiguration };

View File

@@ -11,4 +11,8 @@ function setProjectSchema (state, schema) {
state.projectSchema = schema;
}
export default { setProjectId, setProjectName, setProjectSchema };
function setProjectConfiguration (state, configuration) {
state.projectConfiguration = Object.freeze(configuration);
}
export default { setProjectId, setProjectName, setProjectSchema, setProjectConfiguration };

View File

@@ -1,7 +1,8 @@
const state = () => ({
projectId: null,
projectName: null,
projectSchema: null
projectSchema: null,
projectConfiguration: {}
});
export default state;

View File

@@ -0,0 +1,122 @@
/** Fetch sequences from server
*/
async function refreshSequences ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortSequencesLoading');
}
commit('setSequencesLoading');
const pid = rootState.project.projectId;
const url = `/project/${pid}/sequence?files=true`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setSequences', res);
commit('setSequencesTimestamp');
}
commit('clearSequencesLoading');
}
/** Return a subset of sequences from state.sequences
*/
async function getSequences ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text}]) {
let filteredSequences = [...state.sequences];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredSequences.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredSequences.reverse();
}
});
}
if (sequence) {
filteredSequences = filteredSequences.filter( sequence => sequence.sequence == sequence );
}
if (date0 && date1) {
filteredSequences = filteredSequences.filter( sequence =>
(sequence.ts0_final ?? sequence.ts0)?.substr(0, 10) >= date0 &&
(sequence.ts1_final ?? sequence.ts1)?.substr(0, 10) <= date1
);
} else if (date0) {
filteredSequences = filteredSequences.filter( sequence => (sequence.ts0_final ?? sequence.ts0)?.substr(0, 10) == date0 || (sequence.ts1_final ?? sequence.ts1)?.substr(0, 10) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return search?.length >= 5 && textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const searchFunctions = {
ts0: tstampFilter,
ts1: tstampFilter,
ts0_final: tstampFilter,
ts1_final: tstampFilter,
sequence: numberFilter,
line: numberFilter,
fsp: numberFilter,
lsp: numberFilter,
fsp_final: numberFilter,
fsp_final: numberFilter,
remarks: textFilter,
remarks_final: textFilter
};
filteredSequences = filteredSequences.filter ( sequence => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(sequence[key], text, sequence)) {
return true;
}
}
return false;
});
}
const count = filteredSequences.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredSequences = filteredSequences.slice(offset, offset+itemsPerPage);
}
return {sequences: filteredSequences, count};
}
export default { refreshSequences, getSequences };

View File

@@ -0,0 +1,14 @@
function sequences (state) {
return state.sequences;
}
function sequenceCount (state) {
return state.sequences?.length ?? 0;
}
function sequencesLoading (state) {
return !!state.loading;
}
export default { sequences, sequenceCount, sequencesLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,49 @@
function setSequences (state, sequences) {
// We don't need or want the events array to be reactive, since
// it can be tens of thousands of items long.
state.sequences = Object.freeze(sequences);
}
function setSequencesLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearSequencesLoading (state) {
state.loading = null;
}
function setSequencesTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the sequences
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.sequences
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setSequencesETag (state, etag) {
state.etag = etag;
}
function abortSequencesLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setSequences,
setSequencesLoading,
clearSequencesLoading,
setSequencesTimestamp,
setSequencesETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
sequences: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -39,6 +39,12 @@
{{ $refs.calendar.title }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn v-if="categoriesAvailable"
small
class="mx-4"
v-model="useCategories"
@click="useCategories = !useCategories"
>Labels {{useCategories ? "On" : "Off"}}</v-btn>
<v-menu bottom right>
<template v-slot:activator="{ on, attrs }">
<v-btn
@@ -72,16 +78,23 @@
<v-calendar
ref="calendar"
v-model="focus"
:events="events"
:events="items"
:event-color="getEventColour"
color="primary"
:type="type"
:type="view"
:locale-first-day-of-year="4"
:weekdays="weekdays"
:show-week="true"
:category-days="categoryDays"
:categories="categories"
@click:date="showLogForDate"
@click:event="showLogForEvent"
></v-calendar>
@change="setSpan"
>
<template v-slot:event="{ event }">
<div style="height:100%;overflow:scroll;" v-html="event.name"></div>
</template>
</v-calendar>
</v-sheet>
</div>
</template>
@@ -97,8 +110,9 @@ export default {
weekdays: [1, 2, 3, 4, 5, 6, 0],
type: "week",
focus: "",
events: [
],
items: [],
useCategories: false,
span: {},
options: {
sortBy: "sequence"
}
@@ -117,28 +131,126 @@ export default {
return labels[this.type];
},
...mapGetters(['loading'])
view () {
return this.useCategories ? "category" : this.type;
},
categoriesAvailable () {
return this.type == "day" || this.type == "4day";
},
categoryDays () {
if (this.useCategories) {
const days = {
month: 30,
week: 7,
"4day": 4,
day: 1
};
return days[this.type];
}
},
visibleItems () {
return this.items.filter(i => {
const end = i.end ?? i.start;
if (i.start > this.span.end) {
return false;
}
if (end < this.span.start) {
return false;
}
return true;
});
},
categories () {
return [...new Set(this.visibleItems.map(i => i.category ?? "General"))];
},
...mapGetters(['sequencesLoading', 'sequences', 'events'])
},
watch: {
sequences () {
const isFirstLoad = !this.items.length;
this.getEvents();
if (isFirstLoad) {
this.setLast();
}
},
events () {
const isFirstLoad = !this.items.length;
this.getEvents();
if (isFirstLoad) {
this.setLast();
}
},
type () {
this.getEvents();
},
categoriesAvailable (value) {
if (!value) {
this.useCategories = false;
}
}
},
methods: {
async getEvents () {
const query = new URLSearchParams(this.options);
const url = `/project/${this.$route.params.project}/sequence?${query.toString()}`;
const finalSequences = await this.api([url]) || [];
this.events = finalSequences.map(s => {
const sequences = this.sequences.map(s => {
const e = {};
//e.start = s.ts0.substring(0,10)+" "+s.ts0.substring(11,19)
//e.end = s.ts1.substring(0,10)+" "+s.ts1.substring(11,19)
e.routerLink = { name: "logBySequence", params: { sequence: s.sequence } };
e.start = new Date(s.ts0);
e.end = new Date(s.ts1);
e.timed = true;
e.colour = "orange";
e.name = `Sequence ${s.sequence}`;
e.name = `<b>Sequence ${s.sequence}</b><br/>Line ${s.line}<br/><abbr title="Shotpoints">SP</abbr> ${s.fgsp ?? s.fsp}${s.lgsp ?? s.lsp}`;
e.category = "Sequence"
return e;
});
const lineChanges = this.events.filter(i => i.meta?.["*ReportLineChangeTime*"]?.value && i.meta?.["*ReportLineChangeTime*"]?.type != "excess").map(i => {
const e = {};
const duration = i.meta?.["*ReportLineChangeTime*"]?.value;
e.end = new Date(i.tstamp);
e.start = new Date(e.end - duration);
e.timed = true;
e.colour = "pink";
e.name = "Line change";
e.category = "Production"
return e;
});
const excludedLabels = [ "FSP", "FGSP", "LSP", "LGSP", "QC" ];
const otherEvents = this.events.filter(i => !excludedLabels.some(l => i.labels.includes(l))).map(i => {
const e = {};
e.start = new Date(i.tstamp);
e.colour = "brown";
e.timed = true;
e.name = this.$options.filters.markdownInline(i.remarks);
e.category = i.labels[0];
return e;
});
this.items = [...sequences];
if (this.type == "day" || this.type == "4day") {
this.items.push(...lineChanges, ...otherEvents);
}
},
getEventColour (event) {
@@ -150,11 +262,15 @@ export default {
},
setFirst () {
this.focus = this.events[this.events.length-1].start;
if (this.items.length) {
this.focus = this.items[this.items.length-1].start;
}
},
setLast () {
this.focus = this.events[0].start;
if (this.items.length) {
this.focus = this.items[0].start;
}
},
prev () {
@@ -175,6 +291,13 @@ export default {
}
},
setSpan (span) {
this.span = {
start: new Date(span.start.date),
end: new Date((new Date(span.end.date)).valueOf() + 86400000)
};
},
...mapActions(["api"])
@@ -182,9 +305,7 @@ export default {
async mounted () {
await this.getEvents();
if (this.events.length) {
this.setLast();
}
this.setLast();
}
}

View File

@@ -11,6 +11,7 @@
label="Filter"
single-line
clearable
hint="Filter by line number, first or last shotpoint or remarks. Use incr or + / decr or - to show only incrementing / decrementing lines"
></v-text-field>
</v-toolbar>
</v-card-title>
@@ -106,12 +107,14 @@
<v-data-table
:headers="headers"
:items="items"
item-key="line"
:items-per-page.sync="itemsPerPage"
:server-items-length="lineCount"
item-key="line"
:search="filter"
:loading="loading"
:fixed-header="true"
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
:loading="linesLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
:item-class="itemClass"
:show-select="selectOn"
v-model="selectedRows"
@@ -124,6 +127,10 @@
:preplot="item"
:sequences="sequences.filter(s => s.line == item.line)"
:sequence-href="(s) => `/projects/${$route.params.project}/log/sequence/${s.sequence}`"
:planned-sequences="plannedSequences.filter(s => s.line == item.line)"
:planned-sequence-href="() => `/projects/${$route.params.project}/plan`"
:pending-reshoots="null"
:pending-reshoot-href="null"
>
<template v-slot:empty>
<div v-if="!item.ntba" class="sequence" title="Virgin"></div>
@@ -161,7 +168,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="linesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -251,9 +258,10 @@ export default {
items: [],
selectOn: false,
selectedRows: [],
filter: null,
num_lines: null,
sequences: [],
filter: "",
options: {},
lineCount: null,
//sequences: [],
activeItem: null,
edit: null, // {line, key, value}
queuedReload: false,
@@ -273,11 +281,22 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
},
watch: {
options: {
handler () {
this.fetchLines();
},
deep: true
},
async lines () {
await this.fetchLines();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.line == oldVal.line);
@@ -296,39 +315,9 @@ export default {
}
},
async serverEvent (event) {
if (event.payload.pid == this.$route.params.project) {
if (event.channel == "preplot_lines" || event.channel == "preplot_points") {
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.getLines();
} else {
this.queuedReload = true;
}
} else if ([ "planned_lines", "raw_lines", "final_lines" ].includes(event.channel)) {
if (!this.loading && !this.queuedReload) {
this.getSequences();
} else {
this.queuedReload = true;
}
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getLines();
this.getSequences();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getLines();
this.getSequences();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchLines();
}
},
@@ -468,43 +457,28 @@ export default {
}
},
async getNumLines () {
const projectInfo = await this.api([`/project/${this.$route.params.project}`]);
this.num_lines = projectInfo.lines;
},
async getLines () {
const url = `/project/${this.$route.params.project}/line`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
},
async getSequences () {
const urlS = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([urlS]) || [];
const urlP = `/project/${this.$route.params.project}/plan`;
const planned = await this.api([urlP]) || [];
planned.forEach(i => i.status = "planned");
this.sequences.push(...planned);
},
setActiveItem (item) {
this.activeItem = this.activeItem == item
? null
: item;
},
...mapActions(["api"])
async fetchLines (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getLines([this.$route.params.project, options]);
this.items = res.lines;
this.lineCount = res.count;
},
...mapActions(["api", "getLines"])
},
mounted () {
this.getLines();
this.getNumLines();
this.getSequences();
this.fetchLines();
// Initialise stylesheet
const el = document.createElement("style");

View File

@@ -93,7 +93,21 @@
append-icon="mdi-magnify"
label="Filter"
single-line
hide-details></v-text-field>
clearable
hide-details>
<template v-slot:prepend-inner>
<v-chip v-if="labelSearch"
class="mr-1"
small
close
@click:close="labelSearch=null"
:color="labels[labelSearch] && labels[labelSearch].view.colour"
:title="labels[labelSearch] && labels[labelSearch].view.description"
:dark="labels[labelSearch] && labels[labelSearch].view.dark"
:light="labels[labelSearch] && labels[labelSearch].view.light"
>{{labelSearch}}</v-chip>
</template>
</v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
@@ -215,13 +229,14 @@
:headers="headers"
:items="rows"
:items-per-page.sync="itemsPerPage"
:server-items-length="eventCount"
item-key="key"
:item-class="itemClass"
sort-by="tstamp"
:sort-desc="true"
:search="filter"
:custom-filter="searchTable"
:loading="loading"
:loading="eventsLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
@click:row="setActiveItem"
@@ -249,12 +264,12 @@
:dark="labels[label] && labels[label].view.dark"
:light="labels[label] && labels[label].view.light"
:key="label"
:href="$route.path+'?label='+encodeURIComponent(label)"
@click="labelSearch=label"
>{{label}}</v-chip>
</span>
<dougal-event-edit-history v-if="entry.has_edits && writeaccess"
:id="entry.id"
:disabled="loading"
:disabled="eventsLoading"
:labels="labels"
></dougal-event-edit-history>
<span v-if="entry.meta.readonly"
@@ -385,7 +400,6 @@ export default {
}
],
items: [],
labels: {},
options: {},
filter: "",
filterableLabels: [ "QC", "QCAccepted" ],
@@ -394,7 +408,6 @@ export default {
eventDialog: false,
eventLabelsDialog: false,
defaultEventTimestamp: null,
presetRemarks: null,
remarksMenu: null,
remarksMenuItem: null,
editedEvent: {},
@@ -444,17 +457,6 @@ export default {
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 )
@@ -466,6 +468,10 @@ export default {
.sort( (a, b) => b[1]-a[1] );
},
presetRemarks () {
return this.projectConfiguration?.events?.presetRemarks ?? [];
},
defaultSequence () {
if (this.$route.params.sequence) {
return Number(this.$route.params.sequence.split(";").pop());
@@ -474,19 +480,24 @@ export default {
}
},
...mapGetters(['user', 'writeaccess', 'loading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent']),
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent', 'events', 'labels', 'userLabels']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
options: {
handler () {
//this.getEvents();
async handler () {
await this.fetchEvents();
},
deep: true
},
async events () {
console.log("Events changed");
await this.fetchEvents();
},
eventDialog (val) {
if (val) {
// If not online
@@ -494,30 +505,14 @@ export default {
}
},
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;
}
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchEvents();
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getEvents();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getEvents();
}
labelSearch () {
this.fetchEvents();
},
itemsPerPage (newVal, oldVal) {
@@ -574,50 +569,15 @@ export default {
}
},
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 ?? {};
async fetchEvents (opts = {}) {
const options = {
text: this.filter,
label: this.labelSearch,
...this.options
};
const res = await this.getEvents([this.$route.params.project, options]);
this.items = res.events;
this.eventCount = res.count;
},
newItem (from = {}) {
@@ -691,7 +651,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -709,7 +669,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -756,7 +716,7 @@ export default {
if (!err && res.ok) {
this.showSnack([`${ids.length} events deleted`, "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -772,7 +732,7 @@ export default {
if (!err && res.ok) {
this.showSnack(["Event deleted", "red"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
this.fetchEvents({cache: "reload"});
}
}
@@ -806,19 +766,6 @@ export default {
},
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;
@@ -857,14 +804,11 @@ export default {
*/
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "refreshEvents", "getEvents"])
},
async mounted () {
await this.getLabelDefinitions();
this.getEventCount();
this.getEvents();
this.getPresetRemarks();
this.fetchEvents();
window.addEventListener('keyup', this.handleKeyboardEvent);
},

View File

@@ -44,6 +44,7 @@
label="Filter"
single-line
clearable
hint="Filter by sequence, line, first or last shotpoints, remarks or start/end time"
></v-text-field>
</v-toolbar>
</v-card-title>
@@ -109,11 +110,14 @@
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="sequenceCount"
item-key="sequence"
:search="filter"
:loading="loading"
:fixed-header="true"
:loading="plannedSequencesLoading"
fixed-header
no-data-text="No planned lines. Add lines via the context menu from either the Lines or Sequences view."
:item-class="(item) => (activeItem == item && !edit) ? 'blue accent-1 elevation-3' : ''"
:footer-props="{showFirstLastPage: true}"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
@@ -275,7 +279,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="plannedSequencesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -417,7 +421,8 @@ export default {
remarks: null,
editRemarks: false,
filter: null,
num_lines: null,
options: {},
sequenceCount: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
@@ -552,11 +557,22 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
},
watch: {
options: {
handler () {
this.fetchPlannedSequences();
},
deep: true
},
async plannedSequences () {
await this.fetchPlannedSequences();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.sequence == oldVal.sequence);
@@ -587,41 +603,9 @@ export default {
}
},
async serverEvent (event) {
if (event.channel == "planned_lines" && event.payload.pid == this.$route.params.project) {
// Ignore non-ops
/*
if (event.payload.old === null && event.payload.new === null) {
return;
}
*/
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.getPlannedLines();
} else {
this.queuedReload = true;
}
} else if (event.channel == "info" && event.payload.pid == this.$route.params.project) {
if (event.payload?.new?.key == "plan" && ("remarks" in (event.payload?.new?.value || {}))) {
this.remarks = event.payload?.new.value.remarks;
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getPlannedLines();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getPlannedLines();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchPlannedSequences();
}
},
@@ -890,7 +874,6 @@ export default {
const url = `/project/${this.$route.params.project}/plan/${this.contextMenuItem.sequence}`;
const init = {method: "DELETE"};
await this.api([url, init]);
await this.getPlannedLines();
},
editItem (item, key, value) {
@@ -942,21 +925,6 @@ export default {
}
},
async getPlannedLines () {
const url = `/project/${this.$route.params.project}/plan`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
for (const item of this.items) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
this.wxQuery(item).then( (wx) => {
item.meta = {...item.meta, wx};
});
}
},
async getPlannerConfig () {
const url = `/project/${this.$route.params.project}/configuration/planner`;
this.plannerConfig = await this.api([url]) || {
@@ -967,14 +935,15 @@ export default {
}
},
async getPlannerRemarks () {
const url = `/project/${this.$route.params.project}/info/plan/remarks`;
this.remarks = await this.api([url]) || "";
},
async getSequences () {
const url = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([url]) || [];
async fetchPlannedSequences (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getPlannedSequences([this.$route.params.project, options]);
this.items = res.sequences;
this.sequenceCount = res.count;
this.remarks = this.planRemarks;
},
setActiveItem (item) {
@@ -983,13 +952,12 @@ export default {
: item;
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "getPlannedSequences"])
},
async mounted () {
await this.getPlannerConfig();
this.getPlannedLines();
this.getPlannerRemarks();
await this.fetchPlannedSequences();
}
}

View File

@@ -37,7 +37,7 @@ export default {
return this.loading || this.projectId;
},
...mapGetters(["loading", "projectId", "serverEvent"])
...mapGetters(["loading", "projectId", "projectSchema", "serverEvent"])
},
watch: {
@@ -45,16 +45,39 @@ export default {
if (event.channel == "project" && event.payload?.operation == "DELETE" && event.payload?.schema == "public") {
// Project potentially deleted
await this.getProject(this.$route.params.project);
} else if (event.payload?.schema == this.projectSchema) {
if (event.channel == "event") {
this.refreshEvents();
} else if (event.channel == "planned_lines") {
this.refreshPlan();
} else if (["raw_lines", "final_lines", "final_shots"].includes(event.channel)) {
this.refreshSequences();
} else if (["preplot_lines", "preplot_points"].includes(event.channel)) {
this.refreshLines();
} else if (event.channel == "info") {
if ((event.payload?.new ?? event.payload?.old)?.key == "plan") {
this.refreshPlan();
}
} else if (event.channel == "project") {
this.getProject(this.$route.params.project);
}
}
}
},
methods: {
...mapActions(["getProject"])
...mapActions(["getProject", "refreshLines", "refreshSequences", "refreshEvents", "refreshLabels", "refreshPlan"])
},
async mounted () {
await this.getProject(this.$route.params.project);
if (this.projectFound) {
this.refreshLines();
this.refreshSequences();
this.refreshEvents();
this.refreshLabels();
this.refreshPlan();
}
}
}

View File

@@ -148,15 +148,16 @@
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="sequenceCount"
item-key="sequence"
:server-items-length="num_rows"
:search="filter"
:custom-filter="customFilter"
:loading="loading"
:fixed-header="true"
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
show-expand
:item-class="(item) => activeItem == item ? 'blue accent-1 elevation-3' : ''"
:search="filter"
x-custom-filter="customFilter"
:loading="sequencesLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
show-expand
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
@@ -176,7 +177,7 @@
icon
small
title="Cancel edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit.value = item.remarks; edit = null"
>
<v-icon small>mdi-close</v-icon>
@@ -185,7 +186,7 @@
icon
small
title="Save edits"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
@@ -196,7 +197,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -210,7 +211,7 @@
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
@@ -228,7 +229,7 @@
icon
small
title="Cancel edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit.value = item.remarks_final; edit = null"
>
<v-icon small>mdi-close</v-icon>
@@ -237,7 +238,7 @@
icon
small
title="Save edits"
:disabled="loading"
:disabled="sequencesLoading"
@click="edit = null"
>
<v-icon small>mdi-content-save-edit-outline</v-icon>
@@ -248,7 +249,7 @@
icon
small
title="Edit"
:disabled="loading"
:disabled="sequencesLoading"
@click="editItem(item, 'remarks_final')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
@@ -262,7 +263,7 @@
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
:disabled="sequencesLoading"
v-model="edit.value"
>
</v-textarea>
@@ -566,7 +567,7 @@ export default {
items: [],
filter: "",
options: {},
num_rows: null,
sequenceCount: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
@@ -593,17 +594,22 @@ export default {
return this.queuedItems.find(i => i.payload.sequence == this.contextMenuItem.sequence);
},
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'writeaccess', 'sequencesLoading', 'sequences'])
},
watch: {
options: {
handler () {
this.getSequences();
this.fetchSequences();
},
deep: true
},
async sequences () {
await this.fetchSequences();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.sequence == oldVal.sequence);
@@ -617,39 +623,9 @@ export default {
}
},
async serverEvent (event) {
const subscriptions = ["raw_lines", "final_lines", "final_shots"];
if (subscriptions.includes(event.channel) && event.payload.pid == this.$route.params.project) {
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.getSequences();
} else {
this.queuedReload = true;
}
} else if (event.channel == "queue_items") {
const project =
event.payload?.project ??
event.payload?.new?.payload?.project ??
event.payload?.old?.payload?.project;
if (project == this.$route.params.project) {
this.getQueuedItems();
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getSequences();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getSequences();
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchSequences();
}
},
@@ -818,19 +794,14 @@ export default {
this.num_rows = projectInfo.sequences;
},
async getSequences () {
const query = new URLSearchParams(this.options);
query.set("filter", this.filter);
query.set("files", true);
if (this.options.itemsPerPage < 0) {
query.delete("itemsPerPage");
}
const url = `/project/${this.$route.params.project}/sequence?${query.toString()}`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
async fetchSequences (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getSequences([this.$route.params.project, options]);
this.items = res.sequences;
this.sequenceCount = res.count;
},
async getQueuedItems () {
@@ -878,11 +849,11 @@ export default {
return false;
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "getSequences"])
},
mounted () {
this.getSequences();
this.fetchSequences();
this.getNumLines();
this.getQueuedItems();
}

View File

@@ -181,6 +181,9 @@ app.map({
post: [ mw.auth.access.write, mw.event.post ],
put: [ mw.auth.access.write, mw.event.put ],
delete: [ mw.auth.access.write, mw.event.delete ],
'changes/:since': {
get: [ mw.event.changes ]
},
// TODO Rename -/:sequence → sequence/:sequence
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
get: [ mw.event.sequence.get ],

View File

@@ -43,15 +43,26 @@ const rels = [
matches: [ ],
callback (url, data) {
if (data.payload?.table == "info") {
const pid = data.payload?.pid;
const key = (data.payload?.new ?? data.payload?.old)?.key;
const rx = /^\/project\/([^\/]+)\/info\/([^\/?]+)[\/?]?/;
const match = url.match(rx);
if (match) {
if (match[1] == data.payload.pid) {
if (match[1] == pid) {
if (match[2] == data.payload?.old?.key || match[2] == data.payload?.new?.key) {
return true;
}
}
}
if (key == "plan") {
const rx = /^\/project\/([^\/]+)\/plan[\/?]?/;
const match = url.match(rx);
if (match) {
return match[1] == pid;
}
}
}
return false;
}

View File

@@ -0,0 +1,14 @@
const { event } = require('../../../lib/db');
const json = async function (req, res, next) {
try {
const response = await event.changes(req.params.project, req.params.since, req.query);
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
};
module.exports = json;

View File

@@ -6,5 +6,6 @@ module.exports = {
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
delete: require('./delete')
delete: require('./delete'),
changes: require('./changes')
}

View File

@@ -1,9 +1,14 @@
const { plan } = require('../../../../lib/db');
const { plan, info } = require('../../../../lib/db');
const json = async function (req, res, next) {
try {
const response = await plan.list(req.params.project, req.query);
const sequences = await plan.list(req.params.project, req.query) ?? [];
const remarks = await info.get(req.params.project, "plan/remarks", req.query, req.user.role) ?? null;
const response = {
remarks,
sequences
};
res.status(200).send(response);
next();
} catch (err) {

View File

@@ -0,0 +1,61 @@
const { setSurvey } = require('../connection');
const { replaceMarkers } = require('../../utils');
function parseValidity (row) {
if (row.validity) {
const rx = /^(.)("([\d :.+-]+)")?,("([\d :.+-]+)")?([\]\)])$/;
const m = row.validity.match(rx);
row.validity = [ m[1], m[3], m[5], m[6] ];
}
return row;
}
function transform (row) {
if (row.validity[2]) {
return {
uid: row.uid,
id: row.id,
is_deleted: true
}
} else {
row.is_deleted = false;
row.has_edits = row.id != row.uid;
row.modified_on = row.validity[1];
delete row.uid;
delete row.validity;
return row;
}
}
function unique (rows) {
const o = {};
rows.forEach(row => o[row.id] = row);
return Object.values(o);
}
/**
* Get the event change history from a given epoch (ts0),
* for all events.
*/
async function changes (projectId, ts0, opts = {}) {
if (!projectId || !ts0) {
throw {status: 400, message: "Invalid request" };
return;
}
const client = await setSurvey(projectId);
const text = `
SELECT *
FROM event_log_changes($1);
`;
const res = await client.query(text, [ts0]);
client.release();
return opts.unique
? unique(res.rows.map(i => transform(replaceMarkers(parseValidity(i)))))
: res.rows.map(i => transform(replaceMarkers(parseValidity(i))));
}
module.exports = changes;

View File

@@ -5,5 +5,6 @@ module.exports = {
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
del: require('./delete')
del: require('./delete'),
changes: require('./changes')
}

View File

@@ -16,7 +16,7 @@ async function list (projectId, opts = {}) {
: opts.sequences.split(/[^0-9]+/).map(Number)) || null;
const date0 = opts.date0 ?? null;
const date1 = opts.date1 ?? null;
const jpq = opts.jpq || null;
const jpq = opts.jpq || null; // jpq: JSONPath Query
const label = opts.label ?? null;
const text = `

View File

@@ -180,6 +180,16 @@ components:
required: true
example: 14707
Since:
description: Starting epoch
name: since
in: path
schema:
type: string
format: date-time
required: true
example: 1970-01-01T00:00:00Z
QueryLimit:
description: Maximum number of results to return
name: limit
@@ -206,6 +216,16 @@ components:
pattern: "(([^\\s,;:]+)(\\s*[,;:\\s]\\s*)?)+"
example: "line,point,tstamp"
Unique:
description: |
Return unique results. Any value at all represents `true`.
name: unique
in: query
schema:
type: string
pattern: ".+"
example: "t"
schemas:
Duration:
@@ -602,14 +622,26 @@ components:
Flag to indicate that this event is read-only. It cannot be edited by the user or deleted. Typically this concerns system-generated events such as QC results or midnight shots.
additionalProperties: true
EventIDAbstract:
type: object
properties:
id:
type: number
description: Event ID.
EventUIDAbstract:
type: object
properties:
uid:
type: number
description: Event instance unique ID. When an event is modified, the new entry acquires a different `uid` while keeping the same `id` as the original event.
EventAbstract:
allOf:
-
type: object
properties:
id:
type: number
description: Event ID.
$ref: "#/components/schemas/EventIDAbstract"
-
$ref: "#/components/schemas/EventNew"
@@ -659,6 +691,47 @@ components:
* The third element is either an ISO-8601 timestamp or `null`. The latter indicates +∞. These are the events returned by endpoints that do not concern themselves with event history.
* The fourth element is one of `]` or `)`. As before, it indicates either an open or closed interval.
EventChangesIsDeletedAbstract:
type: object
properties:
is_deleted:
type: boolean
description: >
Flag to indicate whether this event or event instance (depending on the presence of a `uid` attribute) has been deleted.
EventChangesModified:
description: An event modification.
allOf:
-
$ref: "#/components/schemas/EventAbstract"
-
$ref: "#/components/schemas/EventChangesIsDeletedAbstract"
EventChangesDeleted:
description: |
Identification of a deleted event or event instance.
**Note:** the details of the deleted event are not included, only its `id` and `uid`.
allOf:
-
$ref: "#/components/schemas/EventIDAbstract"
-
$ref: "#/components/schemas/EventUIDAbstract"
-
$ref: "#/components/schemas/EventChangesIsDeletedAbstract"
EventChanges:
description: List of event changes since the given epoch.
type: array
items:
anyOf:
-
$ref: "#/components/schemas/EventChangesDeleted"
-
$ref: "#/components/schemas/EventChangesModified"
SeisExportEntryFSP:
type: object
properties:
@@ -1159,9 +1232,55 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PlannedSequence"
type: object
properties:
remarks:
type: string
description: Planner remarks
sequences:
type: array
items:
$ref: "#/components/schemas/PlannedSequence"
text/csv:
schema:
type: string
format: csv
description: |
Returns a CSV response containing one row for each planned sequence, with the following columns:
* `sequence`: Sequence number
* `line`: Line number
* `fsp`: First shotpoint
* `lsp`: Last shotpoint
* `ts0`: Estimated timestamp of the first shotpoint
* `ts1`: Estimated timestamp of the last shotpoint
* `name`: Line name
* `remarks`: Arbitrary comments
* `num_points`: Number of shotpoints
* `duration`: Estimated duration in seconds
* `length`: Line length in metres
* `azimuth`: Line azimuth
* `lon0`: Longitude of the first shotpoint
* `lat0`: Latitude of the first shotpoint
* `lon1` Longitude of the last shotpoint
* `lat1`: Latitude of the last shotpoint
example: |
"sequence","line","fsp","lsp","ts0","ts1","name","remarks","num_points","duration","length","azimuth","lon0","lat0","lon1","lat1"
81,5162,2422,1158,"2023-10-22T11:09:24.912Z","2023-10-22T12:56:03.395Z","2051621081S00000","",633,6398,15799.988472147348,26.4703415983101,2.474872,59.086695,2.596266,59.214146
82,5178,2444,1146,"2023-10-22T12:56:03.000Z","2023-10-22T14:45:33.607Z","2051781082S00000","",650,6570,16225.02094944685,26.470137885560813,2.469632,59.085264,2.594277,59.216147
text/html:
schema:
type: string
format: html
description: |
An HTML representation of the plan.
application/pdf:
schema:
type: string
contentMediaType: application/pdf
description: |
A PDF representation of the plan.
post:
description: Add a new sequence to the plan.
@@ -1382,6 +1501,31 @@ paths:
$ref: "#/components/responses/401"
/project/{project}/changes/{since}:
get:
summary: Get event change history since epoch.
tags: [ "log" ]
security:
- BearerAuthGuest: []
- CookieAuthGuest: []
parameters:
- $ref: "#/components/parameters/Project"
- $ref: "#/components/parameters/Since"
- $ref: "#/components/parameters/Unique"
responses:
"200":
description: List of project event changes. If `unique` is given, only the latest version of each event will be returned, otherwise the entire modification history is given, potentially including the same event `id` multiple times.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/EventChanges"
"401":
$ref: "#/components/responses/401"
/project/{project}/label:
get:
summary: Get project labels.