Compare commits

..

53 Commits

Author SHA1 Message Date
D. Berge
42697fe91d Provide a default replacement for @POS@ markers 2021-05-15 01:57:46 +02:00
D. Berge
900d7f7a3e Ensure that a geometry exists 2021-05-15 01:57:46 +02:00
D. Berge
f1953807db Add position filters to Vue.
Given some text and an item containing a Point geometry,
the `position` filter replaces occurences of @POS@ or
@POSITION@ with the item's geometry (it has to be lat/lon).

Occurrences of @DMS@ are replaced with the position in
sexagesimal degrees.

This can be used anywhere a Vue filter can. However, we
have used it in the event comments edit dialogue. The positions
are replaced before saving the comment to the database.
2021-05-15 01:57:46 +02:00
D. Berge
814e071698 Add Markdown support to map tooltips 2021-05-15 01:57:46 +02:00
D. Berge
2aba132220 Add Markdown support to preplot lines comments 2021-05-15 01:57:46 +02:00
D. Berge
15a802227d Add Markdown support to planned lines comments 2021-05-15 01:57:46 +02:00
D. Berge
6745757712 Add Markdown support to log comments 2021-05-15 01:57:45 +02:00
D. Berge
9ff76867c9 Add Markdown support to sequence list comments 2021-05-15 01:57:45 +02:00
D. Berge
e8811560de Add global .markdown class.
It changes textareas to be monospaced.
2021-05-15 01:57:45 +02:00
D. Berge
65b33a6b0f Add Vue Markdown filters.
{{ '**strong** _em_' |markdown }} gives:
<p><strong>strong</strong> <em>em</em></p>

{{ '**strong** _em_' |markdownInline }} gives:
<strong>strong</strong> <em>em</em>
2021-05-15 01:57:45 +02:00
D. Berge
b8b5765b46 Split markdown Nunjucks filter into two new ones.
{{ '**strong** _em_' |markdown }} gives:
<p><strong>strong</strong> <em>em</em></p>

{{ '**strong** _em_' |markdownInline }} gives:
<strong>strong</strong> <em>em</em>
2021-05-15 01:57:45 +02:00
D. Berge
53f4e167f8 Update ‘marked’ version on server 2021-05-15 01:57:45 +02:00
D. Berge
3d8f524d4a Expose PDF output option in user interface 2021-05-15 01:57:45 +02:00
D. Berge
1e68676ac6 Add PDF output option for events log 2021-05-15 01:57:45 +02:00
D. Berge
2c2d594877 Add Selenium webdriver to backend.
Used for generating PDFs via a Firefox instance.
2021-05-15 01:57:45 +02:00
D. Berge
fae849aeab Send specific error message if HTML template not found 2021-05-15 01:57:45 +02:00
D. Berge
1d47495799 Adapt log view controls to small viewports 2021-05-15 01:57:45 +02:00
D. Berge
592632d669 Add timestamp filter to renderer 2021-05-15 01:57:45 +02:00
D. Berge
26c05b9e3c Add Markdown support to template renderer 2021-05-15 01:57:45 +02:00
D. Berge
3f9a40724d Add download menu to sequence logs.
The menu lets the user retrieve a sequence's events
in a variety of formats:

* JSON
* Seis+JSON
* GeoJSON
* HTML
2021-05-15 01:57:45 +02:00
D. Berge
a652a08815 Add GET endpoint for sequence events.
Provides a variety of formats:

* JSON
* Seis+JSON
* GeoJSON
* HTML
2021-05-15 01:57:45 +02:00
D. Berge
61ffd1b766 Refactor the function producing Seis+JSON into its own file.
For reuse.
2021-05-15 01:57:45 +02:00
D. Berge
d9f4583224 Implement GET middleware for events.
Produces a choice of outputs: JSON, GeoJSON, Seis+JSON and HTML.
2021-05-15 01:57:45 +02:00
D. Berge
95647337aa Add Nunjucks renderer.
The render function takes a JSON file and a Nunjucks
template and outputs a rendered version of the JSON
data according to the template.
2021-05-15 01:57:45 +02:00
D. Berge
b1e152179e Add new command: insert_event.py
Used to insert a timed event in the log.
2021-05-15 01:56:49 +02:00
D. Berge
142a820ed7 Process comment markers server-side.
Replace @POS@, @POSITION@ and @DMS@ in the remarks
with the event's position (sexagesimal degrees for
the last one).
2021-05-15 01:54:07 +02:00
D. Berge
838b45ef26 Do not fail if some data files are missing 2021-05-15 01:51:55 +02:00
D. Berge
30914b267a Set the right Content-Type for error outputs 2021-05-13 21:48:46 +02:00
D. Berge
f1cbbdb56b Check if raw P1/11 has records.
Even if it hasn't, the file gets imported anyway
(into the `files` table) but we exit early to avoid
an error when trying to determine shooting direction.

This check is not necessary for final P1/11 or gun data.

Fixes #104.
2021-05-12 20:35:51 +02:00
D. Berge
9973e8f132 Merge branch '94-let-users-assign-a-colour-to-preplot-lines' into 'devel'
Resolve "Let users assign a colour to preplot lines"

Closes #94

See merge request wgp/dougal/software!6
2021-05-09 22:41:51 +00:00
D. Berge
f53c479262 Add option to assign colours to preplot lines.
A ‘Set colour…’ option is available from the context menu;
it presents a dialogue allowing the user to choose a colour
that will be assigned to that preplot line and used as the
background colour for the corresponding row on the table
(may also be used for other things).

Because there is a good chance that the user may decide to
colour a large number of lines and it is cumbersome to do
it one at a time, a multiple selection option has also been
added. The context menu then shows options which will apply
to all selected rows. At this time only the change colour
option is available, but it can be extended easily.
2021-05-10 00:22:57 +02:00
D. Berge
73a415a038 Return preplot metadata via the API 2021-05-10 00:21:56 +02:00
D. Berge
0b24e3224f Let calendar toolbar follow theme.
Fixes #80.
2021-05-09 21:23:34 +02:00
D. Berge
c271256015 Remember map view settings.
Save the layer and overlay selection + map zoom and
position per user per project in the browser's
localStorage.

Closes #96.
2021-05-09 15:29:17 +02:00
D. Berge
4887ddaa26 Do not update page location on map change.
Fixes #77.
See also #96.
2021-05-09 03:52:25 +02:00
D. Berge
788c582f98 Show planned sequences in status field of preplots table.
Closes #86.
2021-05-09 00:20:02 +02:00
D. Berge
df9f7f33cf Retrieve user data for LineList.
Fixes a bug in commit fd2e0399f8.
2021-05-09 00:16:08 +02:00
D. Berge
fd2e0399f8 Remember last applied number of table rows.
A hopefully sensible default is applied, but if the
user changes it, the last selected value is saved
in the browsers localStorage.

Preferences are saved per user, project and table. And
per browser, of course, as those are only saved locally.

Closes #41.
2021-05-08 21:54:55 +02:00
D. Berge
db733ceef8 Add key to feed items 2021-05-08 21:54:06 +02:00
D. Berge
f905eb3fdf Upgrade Vuetify to latest version 2021-05-08 21:53:31 +02:00
D. Berge
e707887702 Change colour of planned lines on map.
As magenta is already used for the real-time track.
2021-05-08 20:36:19 +02:00
D. Berge
c0ace1fe07 Make check mark green if non-leaf QC item has no children.
If a test passes for all items, show the (single) check mark
and colour it green.

Leaf nodes always have their check mark in the default colour.

Related to #90.
2021-05-08 04:08:37 +02:00
D. Berge
7bb3a3910b Show development activity log.
A button in the help dialogue takes the user to the
/feed/… frontend URL, where the latest development
activity is shown, taken from the GitLab RSS feed
for the project.
2021-05-08 00:46:31 +02:00
D. Berge
983113b6cc Add flag to api action to fetch non-JSON data.
If {text:true} or another truthy value is passed as the
`text` option, the api action will use Response.text()
instead of Response.json().
2021-05-08 00:44:05 +02:00
D. Berge
ff66c9a88d Handle planner sequence value for first line in prospect.
The next sequence to shoot is normally retrieved from the
database via getSequence(), but it returns false if no
sequences have been shot yet.

In that case we use a default value of `1` to build the
name of the planned line.

Fixes #81
Fixes #82
2021-05-08 00:20:15 +02:00
D. Berge
56d30d48c5 Adapt help dialogue to small viewports 2021-05-07 23:52:36 +02:00
D. Berge
df3a0b4c50 Be explicit about what type of data is being QC'ed.
The source deviation QCs now tell the user whether raw
or final data is being QC'ed.
2021-05-07 21:29:39 +02:00
D. Berge
f87aa08246 Check if gun data missing for entire line.
The `sequences` object now carries the attribute
`has_smsrc_data`, a boolean which is true iff
there is at least one `smsrc` record in the raw
shots metadata.

This is used by:

1. A new sequence-wise test which reports if gun
   data is missing for the entire sequence.

2. The individual `missing_gun_data` test which
   is inhibited if `has_smsrc_data` for the
   corresponding sequence is false.

Closes #93.
2021-05-07 14:04:48 +02:00
D. Berge
ea499a645b Update package dependencies 2021-05-07 14:04:12 +02:00
D. Berge
0fdb42c593 Do not import files that have just been modified.
We now check that a file is at least a few seconds old
before attempting to import it.

The actual minimum age can be configured in etc/config.yaml or
else is defaults to 10 seconds.

The idea is that this should give the OS enough time to fully
write the file before we import it.

The timestamp being looked at is the modification time.

Fixes #92.
2021-05-07 13:50:32 +02:00
D. Berge
6e5584a433 Make the QC double-tick green if all items accepted.
Closes #90.
2021-05-07 13:38:26 +02:00
D. Berge
0a4df0793d Update package dependencies 2021-05-07 13:37:44 +02:00
D. Berge
1e6cc67b05 Merge branch '63-serve-api-specification' into 'devel'
Resolve "Serve API specification"

Closes #63

See merge request wgp/dougal/software!5
2020-12-30 08:45:55 +00:00
56 changed files with 18532 additions and 723 deletions

View File

@@ -392,6 +392,14 @@ class Datastore:
cursor.execute("BEGIN;")
hash = self.add_file(filepath, cursor)
if not records or len(records) == 0:
print("File has no records (or none have been detected)")
# We add the file to the database anyway to signal that we have
# actually seen it.
self.maybe_commit()
return
incr = p111.point_number(records[0]) <= p111.point_number(records[-1])
# Start by deleting any online data we may have for this sequence

View File

@@ -12,6 +12,7 @@ import os
import sys
import pathlib
import re
import time
import configuration
import p111
from datastore import Datastore
@@ -20,6 +21,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -49,6 +51,12 @@ if __name__ == '__main__':
print(f"Found {filepath}")
if not db.file_in_db(filepath):
age = time.time() - os.path.getmtime(filepath)
if age < file_min_age:
print("Skipping file because too new", filepath)
continue
print("Importing")
match = rx.match(os.path.basename(filepath))

View File

@@ -12,6 +12,7 @@ import os
import sys
import pathlib
import re
import time
import configuration
import p190
from datastore import Datastore
@@ -20,6 +21,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -49,6 +51,12 @@ if __name__ == '__main__':
print(f"Found {filepath}")
if not db.file_in_db(filepath):
age = time.time() - os.path.getmtime(filepath)
if age < file_min_age:
print("Skipping file because too new", filepath)
continue
print("Importing")
match = rx.match(os.path.basename(filepath))

View File

@@ -8,7 +8,9 @@ or modified preplots and (re-)import them into the database.
"""
from glob import glob
import os
import sys
import time
import configuration
import preplots
from datastore import Datastore
@@ -17,6 +19,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -28,6 +31,12 @@ if __name__ == '__main__':
for file in survey["preplots"]:
print(f"Preplot: {file['path']}")
if not db.file_in_db(file["path"]):
age = time.time() - os.path.getmtime(file["path"])
if age < file_min_age:
print("Skipping file because too new", file["path"])
continue
print("Importing")
try:
preplot = preplots.from_file(file)

View File

@@ -12,6 +12,7 @@ import os
import sys
import pathlib
import re
import time
import configuration
import p111
from datastore import Datastore
@@ -20,6 +21,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -57,6 +59,12 @@ if __name__ == '__main__':
ntbp = False
if not db.file_in_db(filepath):
age = time.time() - os.path.getmtime(filepath)
if age < file_min_age:
print("Skipping file because too new", filepath)
continue
print("Importing")
match = rx.match(os.path.basename(filepath))

View File

@@ -12,6 +12,7 @@ import os
import sys
import pathlib
import re
import time
import configuration
import p190
from datastore import Datastore
@@ -20,6 +21,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -52,6 +54,12 @@ if __name__ == '__main__':
print(f"Found {filepath}")
if not db.file_in_db(filepath):
age = time.time() - os.path.getmtime(filepath)
if age < file_min_age:
print("Skipping file because too new", filepath)
continue
print("Importing")
match = rx.match(os.path.basename(filepath))

View File

@@ -12,6 +12,7 @@ import os
import sys
import pathlib
import re
import time
import configuration
import smsrc
from datastore import Datastore
@@ -20,6 +21,7 @@ if __name__ == '__main__':
print("Reading configuration")
surveys = configuration.surveys()
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
print("Connecting to database")
db = Datastore()
@@ -53,6 +55,12 @@ if __name__ == '__main__':
print(f"Found {filepath}")
if not db.file_in_db(filepath):
age = time.time() - os.path.getmtime(filepath)
if age < file_min_age:
print("Skipping file because too new", filepath)
continue
print("Importing")
match = rx.match(os.path.basename(filepath))

48
bin/insert_event.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/python3
from datetime import datetime
from datastore import Datastore
def detect_schema (conn):
with conn.cursor() as cursor:
qry = "SELECT meta->>'_schema' AS schema, tstamp, age(current_timestamp, tstamp) age FROM real_time_inputs WHERE meta ? '_schema' AND age(current_timestamp, tstamp) < '02:00:00' ORDER BY tstamp DESC LIMIT 1"
cursor.execute(qry)
res = cursor.fetchone()
if res and len(res):
return res[0]
return None
if __name__ == '__main__':
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("-s", "--schema", required=False, default=None, help="survey where to insert the event")
ap.add_argument("-t", "--tstamp", required=False, default=None, help="event timestamp")
ap.add_argument("-l", "--label", required=False, default=None, action="append", help="event label")
ap.add_argument('remarks', type=str, nargs="+", help="event message")
args = vars(ap.parse_args())
db = Datastore()
db.connect()
if args["schema"]:
schema = args["schema"]
else:
schema = detect_schema(db.conn)
if args["tstamp"]:
tstamp = args["tstamp"]
else:
tstamp = datetime.utcnow().isoformat()
message = " ".join(args["remarks"])
print("new event:", schema, tstamp, message)
if schema and tstamp and message:
db.set_survey(schema)
with db.conn.cursor() as cursor:
qry = "INSERT INTO events_timed (tstamp, remarks) VALUES (%s, %s);"
cursor.execute(qry, (tstamp, message))
db.maybe_commit()

View File

@@ -96,16 +96,21 @@ if __name__ == '__main__':
with db.conn.cursor() as cursor:
columns = exportables["public"][table]
path = os.path.join(VARDIR, "-"+table)
with open(path, "rb") as fd:
print(" →→ ", path, " ←← ", table, columns)
if columns is not None:
import_table(fd, table, columns, cursor)
else:
try:
print(f"Copying from {path} into {table}")
cursor.copy_from(fd, table)
except psycopg2.errors.UniqueViolation:
print(f"It looks like table {table} may have already been imported. Skipping it.")
try:
with open(path, "rb") as fd:
print(" →→ ", path, " ←← ", table, columns)
if columns is not None:
import_table(fd, table, columns, cursor)
else:
try:
print(f"Copying from {path} into {table}")
cursor.copy_from(fd, table)
except psycopg2.errors.UniqueViolation:
print(f"It looks like table {table} may have already been imported. Skipping it.")
except FileNotFoundError:
print(f"File not found. Skipping {path}")
db.conn.commit()
print("Reading surveys")
for survey in surveys:
@@ -124,15 +129,18 @@ if __name__ == '__main__':
path = os.path.join(pathPrefix, "-"+table)
print(" ←← ", path, " →→ ", table, columns)
with open(path, "rb") as fd:
if columns is not None:
import_table(fd, table, columns, cursor)
else:
try:
print(f"Copying from {path} into {table}")
cursor.copy_from(fd, table)
except psycopg2.errors.UniqueViolation:
print(f"It looks like table {table} may have already been imported. Skipping it.")
try:
with open(path, "rb") as fd:
if columns is not None:
import_table(fd, table, columns, cursor)
else:
try:
print(f"Copying from {path} into {table}")
cursor.copy_from(fd, table)
except psycopg2.errors.UniqueViolation:
print(f"It looks like table {table} may have already been imported. Skipping it.")
except FileNotFoundError:
print(f"File not found. Skipping {path}")
# If we don't commit the data does not actually get copied
db.conn.commit()

View File

@@ -22,3 +22,9 @@ navigation:
# saving routine.
epsg: 23031 # Assume this CRS for unqualified E/N data
imports:
# For a file to be imported, it must have been last modified at
# least this many seconds ago.
file_min_age: 60

View File

@@ -20,11 +20,20 @@
disabled: false
labels: [ "QC", "QCGuns" ]
children:
-
name: "Sequences without gun data"
iterate: "sequences"
id: seq_no_gun_data
check: |
const sequence = currentItem;
currentItem.has_smsrc_data || "Sequence has no gun data"
-
name: "Missing gun data"
id: missing_gun_data
check: |
!!currentItem._("raw_meta.smsrc.guns") || "Missing gun data"
sequences.some(s => s.sequence == currentItem.sequence && s.has_smsrc_data)
? (!!currentItem._("raw_meta.smsrc.guns") || "Missing gun data")
: true
-
name: "No fire"
@@ -192,7 +201,7 @@
check: |
const currentShot = currentItem;
Math.abs(currentShot.error_i) <= parameters.crosslineError
|| `Crossline error: ${currentShot.error_i.toFixed(1)} > ${parameters.crosslineError}`
|| `Crossline error (${currentShot.type}): ${currentShot.error_i.toFixed(1)} > ${parameters.crosslineError}`
-
name: "Inline"
@@ -200,7 +209,7 @@
check: |
const currentShot = currentItem;
Math.abs(currentShot.error_j) <= parameters.inlineError
|| `Inline error: ${currentShot.error_j.toFixed(1)} > ${parameters.inlineError}`
|| `Inline error (${currentShot.type}): ${currentShot.error_j.toFixed(1)} > ${parameters.inlineError}`
-
name: "Centre of source preplot deviation (moving average)"

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,19 @@
"leaflet-arrowheads": "^1.2.2",
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",
"vue-debounce": "^2.5.7",
"vue-router": "^3.4.5",
"vuetify": "^2.3.12",
"vuetify": "^2.4.11",
"vuex": "^3.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.4.0",
"@vue/cli-plugin-router": "~4.4.0",
"@vue/cli-plugin-vuex": "~4.4.0",
"@vue/cli-service": "~4.4.0",
"@vue/cli-service": "^4.5.13",
"sass": "^1.26.11",
"sass-loader": "^8.0.0",
"stylus": "^0.54.8",

View File

@@ -26,6 +26,12 @@
<style lang="stylus">
@import '../node_modules/typeface-roboto/index.css'
@import '../node_modules/@mdi/font/css/materialdesignicons.css'
.markdown.v-textarea textarea
font-family monospace
line-height 1.1 !important
</style>
</style>
<script>

View File

@@ -33,7 +33,8 @@
text
:href="`mailto:${email}?Subject=Question`"
>
Ask a question
<v-icon class="d-lg-none">mdi-help-circle</v-icon>
<span class="d-none d-lg-inline">Ask a question</span>
</v-btn>
<v-btn
@@ -41,7 +42,17 @@
text
href="mailto:dougal-support@aaltronav.eu?Subject=Bug report"
>
Report a bug
<v-icon class="d-lg-none">mdi-bug</v-icon>
<span class="d-none d-lg-inline">Report a bug</span>
</v-btn>
<v-btn
color="info"
text
:href='"/feed/"+feed'
title="View development log"
>
<v-icon>mdi-rss</v-icon>
</v-btn>
<v-spacer></v-spacer>
@@ -52,7 +63,8 @@
text
@click="dialog=false"
>
Close
<v-icon class="d-lg-none">mdi-close-circle</v-icon>
<span class="d-none d-lg-inline">Close</span>
</v-btn>
</v-card-actions>
@@ -69,7 +81,8 @@ export default {
data () {
return {
dialog: false,
email: "dougal-support@aaltronav.eu"
email: "dougal-support@aaltronav.eu",
feed: btoa(encodeURIComponent("https://gitlab.com/wgp/dougal/software.atom?feed_token=XSPpvsYEny8YmH75Nz5W"))
};
}

View File

@@ -46,6 +46,8 @@
background-color green
&.online
background-color blue
&.planned
background-color magenta
</style>
<script>
@@ -96,7 +98,9 @@ export default {
? "Acquired"
: s.status == "ntbp"
? "NTBP"
: s.status;
: s.status == "planned"
? "Planned"
: s.status;
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()

View File

@@ -0,0 +1,11 @@
const marked = require('marked');
function markdown (str) {
return marked(String(str));
}
function markdownInline (str) {
return marked.parseInline(String(str));
}
module.exports = { markdown, markdownInline };

View File

@@ -26,4 +26,70 @@ function withParentProps(item, parent, childrenKey, prop, currentValue) {
return [];
}
export { withParentProps }
function dms (lat, lon) {
const λh = lat < 0 ? "S" : "N";
const φh = lon < 0 ? "W" : "E";
const λn = Math.abs(lat);
const φn = Math.abs(lon);
const λi = Math.trunc(λn);
const φi = Math.trunc(φn);
const λf = λn - λi;
const φf = φn - φi;
const λs = ((λf*3600)%60).toFixed(1);
const φs = ((φf*3600)%60).toFixed(1);
const λm = Math.trunc(λf*60);
const φm = Math.trunc(φf*60);
const λ =
String(λi).padStart(2, "0") + "°" +
String(λm).padStart(2, "0") + "'" +
String(λs).padStart(4, "0") + '" ' +
λh;
const φ =
String(φi).padStart(3, "0") + "°" +
String(φm).padStart(2, "0") + "'" +
String(φs).padStart(4, "0") + '" ' +
φh;
return λ+" "+φ;
}
function geometryAsString (item, opts = {}) {
const key = "key" in opts ? opts.key : "geometry";
const formatDMS = opts.dms;
let str = "";
if (key in item) {
const geometry = item[key];
if (geometry && "coordinates" in geometry) {
if (geometry.type == "Point") {
if (formatDMS) {
str = dms(geometry.coordinates[1], geometry.coordinates[0]);
} else {
str = `${geometry.coordinates[1].toFixed(6)}, ${geometry.coordinates[0].toFixed(6)}`;
}
}
if (str) {
if (opts.url) {
if (typeof opts.url === 'string') {
str = `[${str}](${opts.url.replace("$x", geometry.coordinates[0]).replace("$y", geometry.coordinates[1])})`;
} else {
str = `[${str}](geo:${geometry.coordinates[0]},${geometry.coordinates[1]})`;
}
}
}
}
}
return str;
}
export { withParentProps, geometryAsString }

View File

@@ -5,12 +5,22 @@ import store from './store'
import vuetify from './plugins/vuetify'
import vueDebounce from 'vue-debounce'
import { mapMutations } from 'vuex';
import { markdown, markdownInline } from './lib/markdown';
import { geometryAsString } from './lib/utils';
Vue.config.productionTip = false
Vue.use(vueDebounce);
Vue.filter('markdown', markdown);
Vue.filter('markdownInline', markdownInline);
Vue.filter('position', (str, item, opts) =>
str
.replace(/@POS(ITION)?@/g, geometryAsString(item, opts) || "(position unknown)")
.replace(/@DMS@/g, geometryAsString(item, {...opts, dms:true}) || "(position unknown)")
);
// Vue.filter('position', (str, item, opts) => str.replace(/@POS(ITION)?@/, "☺"));
new Vue({
data () {
return {

View File

@@ -33,6 +33,14 @@ Vue.use(VueRouter)
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
{
path: '/feed/:source',
name: 'Feed',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/Feed.vue')
},
{
pathToRegexpOptions: { strict: true },
path: "/login",

View File

@@ -22,7 +22,7 @@ async function api ({state, commit, dispatch}, [resource, init = {}, cb]) {
await dispatch('setCredentials');
try {
return await res.json();
return init.text ? (await res.text()) : (await res.json());
} catch (err) {
if (err instanceof SyntaxError) {
if (Number(res.headers.get("Content-Length")) === 0) {

View File

@@ -1,7 +1,7 @@
<template>
<div>
<v-sheet height="64">
<v-toolbar flat color="white">
<v-toolbar flat>
<v-menu bottom right>
<template v-slot:activator="{ on, attrs }">

View File

@@ -0,0 +1,104 @@
<template>
<v-container fluid>
<v-overlay absolute opacity="1" :value="!feed.updated">
<v-progress-circular indeterminate size="64"></v-progress-circular>
</v-overlay>
<v-card>
<v-card-title>
{{feed.title}}
<a :href="feed.link"><v-icon class="ml-2">mdi-link</v-icon></a>
</v-card-title>
<v-card-subtitle>Last updated: {{feed.updated}}</v-card-subtitle>
<v-card-text>
<v-timeline align-top dense>
<v-timeline-item v-for="item in feed.items" small :key="item.id">
<v-card :color="item.colour">
<v-card-title primary-title>
<div class="headline">{{item.title}}</div>
</v-card-title>
<v-card-subtitle>{{item.updated}} ({{item.author}})</v-card-subtitle>
<v-card-text v-html="item.summary">
</v-card-text>
<v-card-actions>
<v-btn target="_new" :href="item.link">Complete story</v-btn>
</v-card-actions>
</v-card>
</v-timeline-item>
</v-timeline>
</v-card-text>
</v-card>
</v-container>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: "FeedViewer",
data () {
return {
timer: null,
feed: {}
}
},
methods: {
parse (text) {
const data = {items:[]};
const parser = new DOMParser();
const xml = parser.parseFromString(text, "application/xml");
const feed = xml.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "feed")[0];
const entries = feed.getElementsByTagName("entry");
data.title = feed.getElementsByTagName("title")[0].childNodes[0].textContent;
data.updated = feed.getElementsByTagName("updated")[0].childNodes[0].textContent;
data.link = [...feed.getElementsByTagName("link")].filter(i =>
i.getAttribute("type") == "text/html"
).pop().getAttribute("href");
data.items = [...entries].map(entry => {
const item = {};
const link = entry.getElementsByTagName("link")[0];
if (link) {
item.link = link.getAttribute("href");
}
const author = entry.getElementsByTagName("author")[0];
if (author) {
const name = author.getElementsByTagName("name")[0];
item.author = name ? name.childNodes[0].textContent : author.innerHTML;
}
const summaries = entry.getElementsByTagName("summary");
const summary = [...summaries].find(i => i.getAttribute("type") == "xhtml") || summaries[0];
item.summary = summary.innerHTML;
item.id = entry.getElementsByTagName("id")[0].childNodes[0].textContent;
item.title = entry.getElementsByTagName("title")[0].childNodes[0].textContent;
item.updated = entry.getElementsByTagName("updated")[0].childNodes[0].textContent;
return item;
});
return data;
},
async refresh () {
const text = await this.api([`/rss/?remote=${atob(this.$route.params.source)}`, {text:true}]);
this.feed = this.parse(text);
},
...mapActions(["api"])
},
async mounted () {
await this.refresh();
this.timer = setInterval(this.refresh, 300000);
},
unmounted () {
cancelInterval(this.timer);
this.timer = null;
}
}
</script>

View File

@@ -24,23 +24,85 @@
offset-y
>
<v-list dense v-if="contextMenuItem">
<v-list-item @click="setNTBA">
<v-list-item-title v-if="contextMenuItem.ntba">Unset NTBA</v-list-item-title>
<v-list-item-title v-else>Set NTBA</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba">
<v-list-item-title>Add to plan</v-list-item-title>
<template v-if="!selectOn">
<v-list-item @click="setNTBA">
<v-list-item-title v-if="contextMenuItem.ntba">Unset NTBA</v-list-item-title>
<v-list-item-title v-else>Set NTBA</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba && !isPlanned(contextMenuItem)">
<v-list-item-title>Add to plan</v-list-item-title>
</v-list-item>
<v-list-item @click="removeFromPlan" v-if="isPlanned(contextMenuItem)">
<v-list-item-title>Remove from plan</v-list-item-title>
</v-list-item>
<v-list-item @click="showLineColourDialog">
<v-list-item-title>Set colour</v-list-item-title>
</v-list-item>
</template>
<template v-else>
<v-list-item @click="showLineColourDialog">
<v-list-item-title>Set colour</v-list-item-title>
</v-list-item>
</template>
<v-divider></v-divider>
<v-list-item>
<v-list-item-action-text>Multi-select</v-list-item-action-text>
<v-list-item-action><v-checkbox v-model="selectOn"></v-checkbox></v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
<v-dialog
v-model="colourPickerShow"
max-width="300"
>
<v-card>
<v-card-title>Choose line colour</v-card-title>
<v-card-text>
<v-color-picker
v-model="selectedColour"
show-swatches
hide-canvas
hide-inputs
hide-mode-switch
@update:color="selectedColour.alpha = 0.5"
>
</v-color-picker>
</v-card-text>
<v-card-actions>
<v-btn small
@click="selectedColour=null; setLineColour()"
>
Clear
</v-btn>
<v-spacer></v-spacer>
<v-btn small
@click="setLineColour"
>
Set
</v-btn>
<v-spacer></v-spacer>
<v-btn small
@click="colourPickerShow=false"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-data-table
:headers="headers"
:items="items"
item-key="line"
:items-per-page.sync="itemsPerPage"
:search="filter"
:loading="loading"
:fixed-header="true"
:item-class="(item) => (activeItem == item && !edit) ? 'blue accent-1 elevation-3' : ''"
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
:item-class="itemClass"
:show-select="selectOn"
v-model="selectedRows"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
@@ -77,8 +139,7 @@
@click:append-outer="edit = null"
>
</v-text-field>
<div v-else>
{{item.remarks}}
<div v-else v-html="$options.filters.markdownInline(item.remarks)">
<v-btn v-if="edit === null"
icon
small
@@ -112,7 +173,6 @@
import { mapActions, mapGetters } from 'vuex';
import DougalLineStatus from '@/components/line-status';
export default {
name: "LineList",
@@ -162,23 +222,31 @@ export default {
}
],
items: [],
selectOn: false,
selectedRows: [],
filter: null,
num_lines: null,
sequences: [],
activeItem: null,
edit: null, // {line, key, value}
queuedReload: false,
itemsPerPage: 25,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null
contextMenuItem: null,
// Colour picker stuff
colourPickerShow: false,
selectedColour: null,
styles: null
}
},
computed: {
...mapGetters(['loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
watch: {
@@ -202,15 +270,23 @@ export default {
},
async serverEvent (event) {
if (event.channel == "preplot_lines" && 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.getLines();
} else {
this.queuedReload = true;
if (event.payload.pid == this.$route.params.project) {
if (event.channel == "preplot_lines") {
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;
}
}
}
},
@@ -218,19 +294,47 @@ export default {
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getLines();
this.getSequences();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getLines();
this.getSequences();
}
},
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: {
itemClass (item) {
const colourClass = item.meta.colour ? "bg-clr-"+item.meta.colour.slice(1) : null;
if (colourClass && ![...this.styles.cssRules].some(i => i.selectorText == "."+colourClass)) {
const rule = `.${colourClass} { background-color: ${item.meta.colour}; }`;
this.styles.insertRule(rule);
}
return [
item.meta.colour ? colourClass : "",
(this.activeItem == item && !this.edit) ? 'blue accent-1 elevation-3' : ''
];
},
isPlanned(item) {
return this.sequences.find(i => i.line == item.line && i.status == 'planned');
},
contextMenu (e, {item}) {
e.preventDefault();
this.contextMenuShow = false;
@@ -241,6 +345,7 @@ export default {
},
setNTBA () {
this.removeFromPlan();
this.saveItem({
line: this.contextMenuItem.line,
key: 'ntba',
@@ -255,7 +360,6 @@ export default {
fsp: this.contextMenuItem.fsp,
lsp: this.contextMenuItem.lsp
}
console.log("Plan", payload);
const url = `/project/${this.$route.params.project}/plan`;
const init = {
method: "POST",
@@ -265,6 +369,42 @@ export default {
await this.api([url, init]);
},
async removeFromPlan () {
const plannedLine = this.sequences.find(i => i.status == "planned" && i.line == this.contextMenuItem.line);
if (plannedLine && plannedLine.sequence) {
const url = `/project/${this.$route.params.project}/plan/${plannedLine.sequence}`;
const init = {
method: "DELETE"
}
await this.api([url, init]);
}
},
showLineColourDialog () {
this.selectedColour = this.contextMenuItem.meta.colour
? {hexa: this.contextMenuItem.meta.colour}
: null;
this.colourPickerShow = true;
},
setLineColour () {
const items = this.selectOn ? this.selectedRows : [ this.contextMenuItem ];
const colour = this.selectedColour ? this.selectedColour.hex+"80" : null;
this.selectedRows = [];
this.selectOn = false;
for (const item of items) {
if (colour) {
item.meta.colour = colour;
} else {
delete item.meta.colour;
}
this.saveItem({line: item.line, key: "meta", value: item.meta});
this.colourPickerShow = false;
}
},
editItem (item, key) {
this.edit = {
line: item.line,
@@ -308,8 +448,13 @@ export default {
},
async getSequences () {
const url = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([url]) || [];
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) {
@@ -325,6 +470,11 @@ export default {
this.getLines();
this.getNumLines();
this.getSequences();
// Initialise stylesheet
const el = document.createElement("style");
document.head.appendChild(el);
this.styles = document.styleSheets[document.styleSheets.length-1];
}
}

View File

@@ -5,17 +5,32 @@
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>
{{
$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 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-dialog
@@ -28,6 +43,38 @@
:event-mode="online?'seq':'timed'"
@save="saveEvent"
></dougal-event-edit-dialog>
<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
@@ -43,6 +90,7 @@
<v-data-table
:headers="headers"
:items="rows"
:items-per-page.sync="itemsPerPage"
item-key="tstamp"
sort-by="tstamp"
:sort-desc="true"
@@ -50,6 +98,7 @@
:custom-filter="searchTable"
:loading="loading"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
>
<template v-slot:item.tstamp="{value}">
@@ -65,7 +114,7 @@
@cancel="rowEditorCancel"
@open="rowEditorOpen(item)"
@close="rowEditorClose"
> <div v-html="item.items.map(i => i.remarks).join('<br/>')"></div>
> <div v-html="$options.filters.markdownInline(item.items.map(i => i.remarks).join('<br/>'))"></div>
<template v-slot:input>
<h3>{{
editedRow.sequence
@@ -306,10 +355,11 @@ export default {
},
selectedLabels: [],
labelSearch: null,
queuedReload: false
queuedReload: false,
itemsPerPage: 25
}
},
computed: {
rows () {
const rows = {};
@@ -350,7 +400,7 @@ export default {
}
},
...mapGetters(['loading', 'online', 'sequence', 'line', 'point', 'lineName', 'serverEvent']),
...mapGetters(['user', 'loading', 'online', 'sequence', 'line', 'point', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
@@ -394,6 +444,14 @@ export default {
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;
}
},
@@ -515,6 +573,11 @@ export default {
const promises = [];
for (const editedItem of this.editedRow.items) {
// Process special text in remarks
if (editedItem.remarks) {
editedItem.remarks = this.$options.filters.position(editedItem.remarks, editedItem);
}
// Discard non user writable labels
editedItem.labels = editedItem.labels.filter(l => this.labels[l].model.user);
@@ -678,7 +741,7 @@ export default {
},
async mounted () {
await this.getLabelDefinitions()
await this.getLabelDefinitions();
this.getEventCount();
this.getEvents();
this.getPresetRemarks();

View File

@@ -31,6 +31,7 @@ import 'leaflet-arrowheads'
import { mapActions, mapGetters, mapState } from 'vuex';
import ftstamp from '@/lib/FormatTimestamp'
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
import { markdown, markdownInline } from '@/lib/markdown';
var map;
@@ -94,7 +95,7 @@ const layers = {
},
style (feature) {
return {
color: "magenta",
color: "brown",
opacity: 0.7
}
},
@@ -108,7 +109,7 @@ const layers = {
const speed = (p.length / (new Date(p.ts1) - new Date(p.ts0))) * 3.6/1.852 * 1000;
const remarks = p.remarks
? "<hr/>"+p.remarks
? "<hr/>"+markdownInline(p.remarks)
: "";
const popup = `Planned sequence <b>${p.sequence}</b><br/>
@@ -146,7 +147,7 @@ const layers = {
: "";
const remarks = p.remarks
? "<hr/>"+p.remarks
? "<hr/>"+markdown(p.remarks)
: "";
const popup = feature.geometry.type == "Point"
@@ -181,7 +182,7 @@ const layers = {
const p = feature.properties;
const remarks = p.remarks
? "<hr/>"+p.remarks
? "<hr/>"+markdown(p.remarks)
: "";
const popup = feature.geometry.type == "Point"
@@ -343,7 +344,7 @@ export default {
},
computed: {
...mapGetters(['loading', 'serverEvent', 'lineName', 'serverEvent']),
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
@@ -358,6 +359,12 @@ export default {
}
}
},
user (newVal, oldVal) {
if (newVal && (!oldVal || newVal.name != oldVal.name)) {
this.initView();
}
},
serverEvent (event) {
if (event.channel == "realtime" && event.payload && event.payload.new) {
@@ -371,7 +378,7 @@ export default {
rtLayer.update(geojson);
}
} else if (event.channel == "event" && event.payload.schema == this.projectSchema) {
console.log("EVENT", event);
//console.log("EVENT", event);
}
}
},
@@ -487,6 +494,7 @@ export default {
const zoom = map.getZoom();
const o = [];
const l = [];
let value;
if (includeLayers) {
for (const overlay of Object.keys(tileMaps)) {
if (map.hasLayer(tileMaps[overlay])) {
@@ -498,14 +506,24 @@ export default {
l.push(layer);
}
}
document.location.hash = `${zoom}/${lat.toFixed(4)}/${lng.toFixed(4)}:${o.join(";")}:${l.join(";")}`;
value = `${zoom}/${lat}/${lng}:${o.join(";")}:${l.join(";")}`;
} else {
document.location.hash = `${zoom}/${lat.toFixed(4)}/${lng.toFixed(4)}`;
value = `${zoom}/${lat}/${lng}`;
}
if (value) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`, value);
}
},
decodeURL () {
const parts = document.location.hash.substring(1).split(":").map(p => decodeURIComponent(p));
const value = localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`);
if (!value) {
return {};
}
const parts = value.split(":");
const activeOverlays = parts.length > 1 && parts[1].split(";");
const activeLayers = parts.length > 2 && parts[2].split(";");
let position = parts && parts[0].split("/").map(i => Number(i));
@@ -515,6 +533,60 @@ export default {
return {position, activeOverlays, activeLayers};
},
initView () {
if (!map) {
return;
}
map.off('overlayadd', this.updateURL);
map.off('overlayremove', this.updateURL);
map.off('layeradd', this.updateURL);
map.off('layerremove', this.updateURL);
const init = this.decodeURL();
if (init.activeOverlays) {
Object.keys(tileMaps).forEach(k => {
const l = tileMaps[k];
if (init.activeOverlays.includes(k)) {
if (!map.hasLayer(l)) {
l.addTo(map);
}
} else {
map.removeLayer(l);
}
});
} else {
tileMaps["No background"].addTo(map);
}
if (init.activeLayers) {
Object.keys(layers).forEach(k => {
const l = layers[k];
if (init.activeLayers.includes(k)) {
if (!map.hasLayer(l)) {
l.addTo(map);
}
} else {
map.removeLayer(l);
}
});
} else {
layers.OpenSeaMap.addTo(map);
layers.Preplots.addTo(map);
}
if (init.position) {
map.setView(init.position.slice(1), init.position[0]);
}
map.on('overlayadd', this.updateURL);
map.on('overlayremove', this.updateURL);
map.on('layeradd', this.updateURL);
map.on('layerremove', this.updateURL);
},
...mapActions(["api"])
@@ -541,8 +613,8 @@ export default {
onEachFeature (feature, layer) {
const p = feature.properties;
const popup = (p.sequence
? `Event @ ${p.tstamp}<br/>Sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/><hr/>${p.remarks}`
: `Event @ ${p.tstamp}<br/><hr/>${p.remarks}`)
? `Event @ ${p.tstamp}<br/>Sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/><hr/>${markdownInline(p.remarks)}`
: `Event @ ${p.tstamp}<br/><hr/>${markdownInline(p.remarks)}`)
+ (p.labels.length? `<br/>[<i>${p.labels.join(", ")}</i>]` : "");
layer.bindTooltip(popup, {sticky: true});
}
@@ -553,25 +625,25 @@ export default {
layers["Events (Other)"] = L.realtime(this.getEvents(i => i.properties.type != "qc"), eventsOptions());
layers["Events (Other)"].on('update', function (e) {
console.log("Events (Other) update event", e);
//console.log("Events (Other) update event", e);
});
layers["Events (QC)"].on('add', function (e) {
console.log("Events (QC) add event", e);
//console.log("Events (QC) add event", e);
e.target._src(data => e.target.update(data), err => console.error)
});
layers["Events (QC)"].on('remove', function (e) {
console.log("Events (QC) remove event", e);
//console.log("Events (QC) remove event", e);
});
layers["Events (Other)"].on('add', function (e) {
console.log("Events (Other) add event", e);
//console.log("Events (Other) add event", e);
e.target._src(data => e.target.update(data), err => console.error)
});
layers["Events (Other)"].on('remove', function (e) {
console.log("Events (Other) remove event", e);
//console.log("Events (Other) remove event", e);
});
@@ -627,7 +699,11 @@ export default {
if (init.position) {
this.refreshLayers();
} else {
this.fitProjectBounds();
setTimeout(() => {
if(!this.decodeURL().position) {
this.fitProjectBounds();
}
}, 1000);
}
// /usr/share/icons/breeze/actions/16/zoom-fit-best.svg

View File

@@ -34,6 +34,7 @@
<v-data-table
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:search="filter"
:loading="loading"
:fixed-header="true"
@@ -180,8 +181,7 @@
@click:append-outer="edit = null"
>
</v-text-field>
<div v-else>
{{item.remarks}}
<div v-else v-html="$options.filters.markdownInline(item.remarks)">
<v-btn v-if="edit === null"
icon
small
@@ -323,6 +323,7 @@ export default {
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
itemsPerPage: 25,
plannerConfig: null,
shiftAll: false, // Shift all sequences checkbox
@@ -336,7 +337,7 @@ export default {
},
computed: {
...mapGetters(['loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
watch: {
@@ -401,6 +402,14 @@ export default {
if (!newVal && oldVal && this.queuedReload) {
this.getPlannedLines();
}
},
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;
}
},

View File

@@ -98,7 +98,7 @@
color="primary"
title="Accept all"
@click.stop="accept(item)">
<v-icon small>mdi-check-all</v-icon>
<v-icon small :color="accepted(item) ? 'green' : ''">mdi-check-all</v-icon>
</v-btn>
<v-btn
:class="{'text--disabled': !hover}"
@@ -117,7 +117,7 @@
color="primary"
title="Accept this value"
@click="accept(item)">
<v-icon small>mdi-check</v-icon>
<v-icon small :color="(item.children && item.children.length == 0)? 'green':''">mdi-check</v-icon>
</v-btn>
</v-hover>
</template>
@@ -260,6 +260,17 @@ export default {
}
return sum;
},
accepted (item) {
if (item.children) {
return item.children.every(child => this.accepted(child));
}
if (item.labels) {
return item.labels.includes("QCAccepted");
}
return false;
},
accept (item) {
if (item.children) {
@@ -478,7 +489,7 @@ export default {
if (res) {
this.items = res.results.map(i => this.transform(i)) || [];
this.updatedOn = res.updatedOn;
this.getQCLabels(); // No await?
await this.getQCLabels();
} else {
this.items = [];
this.updatedOn = null;

View File

@@ -39,12 +39,14 @@
<v-data-table
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
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' : ''"
@click:row="setActiveItem"
@@ -84,6 +86,7 @@
</v-card-subtitle>
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
<v-textarea
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
@@ -91,8 +94,7 @@
>
</v-textarea>
</v-card-text>
<v-card-text v-else>
{{ item.remarks }}
<v-card-text v-else v-html="$options.filters.markdown(item.remarks)">
</v-card-text>
</v-card>
<v-card outlined class="flex-grow-1" v-if="item.remarks_final !== null">
@@ -123,6 +125,7 @@
</v-card-subtitle>
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
<v-textarea
class="markdown"
autofocus
placeholder="Enter your text here"
:disabled="loading"
@@ -130,8 +133,7 @@
>
</v-textarea>
</v-card-text>
<v-card-text>
{{ item.remarks_final }}
<v-card-text v-html="$options.filters.markdown(item.remarks_final)">
</v-card-text>
</v-card>
</v-col>
@@ -397,6 +399,7 @@ export default {
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
itemsPerPage: 25,
// Planner related stuff
preplots: null,
@@ -411,7 +414,7 @@ export default {
},
computed: {
...mapGetters(['loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
watch: {
@@ -460,6 +463,14 @@ export default {
if (!newVal && oldVal && this.queuedReload) {
this.getSequences();
}
},
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;
}
},

View File

@@ -137,6 +137,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 ],
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
get: [ mw.event.get ],
},
':type/': {
':id/': {
// get: [ mw.event.get ],
@@ -183,6 +186,9 @@ app.map({
get: [ mw.gis.navdata.get ]
}
},
'/rss/': {
get: [ mw.rss.get ]
}
//
// '/user': {
// get: [ mw.user.get ],
@@ -206,8 +212,10 @@ app.use(function (err, req, res, next) {
console.log("Error:", err);
res.set("Content-Type", "application/json");
if (err instanceof Error && err.name != "UnauthorizedError") {
console.error(err.stack);
res.set("Content-Type", "text/plain");
res.status(500).send('General internal error');
maybeSendAlert(alert);
} else if (typeof err === 'string') {

View File

@@ -0,0 +1,28 @@
const { event } = require('../../../../lib/db');
const geojson = async function (req, res, next) {
try {
const query = req.query;
query.sequence = req.params.sequence;
const events = await event.list(req.params.project, query);
const response = {
type: "FeatureCollection",
features: events.filter(event => event.geometry).map(event => {
const feature = {
type: "Feature",
geometry: event.geometry,
properties: event
};
delete feature.properties.geometry;
return feature;
})
};
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
}
module.exports = geojson;

View File

@@ -0,0 +1,27 @@
const { event, sequence, configuration } = require('../../../../lib/db');
const { transform } = require('../../../../lib/sse');
const render = require('../../../../lib/render');
const html = async function (req, res, next) {
try {
const query = req.query;
query.sequence = req.params.sequence;
const events = await event.list(req.params.project, query);
const sequences = await sequence.list(req.params.project, query);
const seis = transform(events, sequences, {projectId: req.params.project});
const templates = await configuration.get(req.params.project, "sse/templates");
const template = templates[0].template;
const response = await render(seis, template);
res.status(200).send(response);
next();
} catch (err) {
if (err.message.startsWith("template")) {
next({message: err.message});
} else {
next(err);
}
}
};
module.exports = html;

View File

@@ -0,0 +1,29 @@
const json = require('./json');
const geojson = require('./geojson');
const seis = require('./seis');
const html = require('./html');
const pdf = require('./pdf');
module.exports = async function (req, res, next) {
try {
const handlers = {
"application/json": json,
"application/geo+json": geojson,
"application/vnd.seis+json": seis,
"text/html": html,
"application/pdf": pdf
};
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
if (mimetype) {
res.set("Content-Type", mimetype);
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();
next();
}
} catch (err) {
next(err);
}
}

View File

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

View File

@@ -0,0 +1,42 @@
const fs = require('fs/promises');
const Path = require('path');
const crypto = require('crypto');
const { event, sequence, configuration } = require('../../../../lib/db');
const { transform } = require('../../../../lib/sse');
const render = require('../../../../lib/render');
const { url2pdf } = require('../../../../lib/selenium');
function tmpname (tmpdir="/dev/shm") {
return Path.join(tmpdir, crypto.randomBytes(16).toString('hex')+".tmp");
}
const pdf = async function (req, res, next) {
const fname = tmpname();
try {
const query = req.query;
query.sequence = req.params.sequence;
const events = await event.list(req.params.project, query);
const sequences = await sequence.list(req.params.project, query);
const seis = transform(events, sequences, {projectId: req.params.project});
const templates = await configuration.get(req.params.project, "sse/templates");
const template = templates[0].template;
const html = await render(seis, template);
await fs.writeFile(fname, html);
const pdf = Buffer.from(await url2pdf("file://"+fname), "base64");
res.status(200).send(pdf);
next();
} catch (err) {
if (err.message.startsWith("template")) {
next({message: err.message});
} else {
next(err);
}
} finally {
await fs.unlink(fname);
}
};
module.exports = pdf;

View File

@@ -0,0 +1,18 @@
const { event, sequence } = require('../../../../lib/db');
const { transform } = require('../../../../lib/sse');
const seis = async function (req, res, next) {
try {
const query = req.query;
query.sequence = req.params.sequence;
const events = await event.list(req.params.project, query);
const sequences = await sequence.list(req.params.project, query);
const response = transform(events, sequences, {projectId: req.params.project});
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
};
module.exports = seis;

View File

@@ -1,291 +1,5 @@
const { event, sequence } = require('../../../../lib/db');
function transform (events, sequences, opts = {}) {
const dgl = !!opts.projectId;
const exportQC = opts.exportQC !== false;
const exportGeneral = opts.exportGeneral !== false;
const exportMissing = opts.exportMissing !== false;
const output = {
DglProjectId: opts.projectId,
Sequences: [],
DglCreatedOn: dgl && new Date()
};
// NOTE: Events come in descending chronological
// order from the server.
for (const event of events.reverse()) {
const SequenceNumber = event.sequence;
if (SequenceNumber) {
const sequence = sequences.find(s => s.sequence == SequenceNumber);
if (!sequence) {
throw Error(`Sequence ${SequenceNumber} not found in sequence list`);
}
let SequenceObject = output.Sequences.find(s => s.SequenceNumber == SequenceNumber);
if (!SequenceObject) {
SequenceObject = {
SequenceNumber,
Entries: [],
DglNumPoints: sequence.num_points,
DglNumMissing: sequence.missing_shots,
// NOTE: Distance & azimuth refer to raw data if the sequence
// status is 'raw' and to final data if status is 'final'. In
// the event of it being NTBP it depends on whether final data
// exists or not.
DglLength: sequence.length
};
[sequence.remarks, sequence.remarks_final].filter(i => !!i).forEach(i => {
if (!SequenceObject.DglSequenceComments) {
SequenceObject.DglSequenceComments = []
}
SequenceObject.DglSequenceComments.push(i);
});
output.Sequences.push(SequenceObject);
}
if (event.labels.includes("FSP")) {
const entry = {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Comment": event.remarks.toString(),
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
"IsUndershoot": false,
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FGSP")) {
const entry = {
"EntryType": "Start good",
"EntryTypeId": 3001,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FCSP")) {
const entry = {
"EntryType": "Start charged",
"EntryTypeId": 3002,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LGFFSP")) {
const entry = {
"EntryType": "Last good Full Fold",
"EntryTypeId": 3008,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LCFFSP")) {
const entry = {
"EntryType": "Last charged Full Fold",
"EntryTypeId": 3009,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FDSP")) {
const entry = {
"EntryType": "Midnight",
"EntryTypeId": 3003,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LCSP")) {
const entry = {
"EntryType": "End charged",
"EntryTypeId": 3005,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LGSP")) {
const entry = {
"EntryType": "End good",
"EntryTypeId": 3006,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LSP")) {
const entry = {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"Comment": event.remarks.toString(),
"IsNTBP": sequence.status == "ntbp",
// NOTE: Always pending, see
// https://gitlab.com/wgp/dougal/software/-/issues/12
"LineStatus": "Pending",
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
// Dougal QC data
if (exportQC) {
if (event.labels.includes("QC")) {
if (!event.labels.includes("QCAccepted")) {
const entry = {
"EntryType": "QC",
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
}
}
if (exportGeneral) {
// These are labels that we have already (potentially) exported
const excluded = [
"FSP", "FGSP", "FCSP", "LGFFSP",
"LCFFSP", "LCSP", "LGSP", "LSP",
"LDSP", "FDSP", "QC", "QCAccepted",
"Internal", "Private", "Confidential",
"NoExport"
];
if (!event.labels || !event.labels.some( l => excluded.includes(l) )) {
const entry = {
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
}
}
}
for (const SequenceObject of output.Sequences) {
const sequence = sequences.find(s => s.sequence == SequenceObject.SequenceNumber);
// If no explicit FSP but there is a FGSP, clone it as FSP.
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3000)) {
const fgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3001);
if (fgspIndex != -1) {
const fsp = Object.assign({}, SequenceObject.Entries[fgspIndex], {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot,
"IsUndershoot": false,
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
});
SequenceObject.Entries.splice(fgspIndex, 0, fsp);
} else {
// We have neither an FSP nor an FGSP, let us take the first
// preplot shot from the sequence data as save that as FSP
// (we intentionally omit adding an FGSP as we know nothing
// about the user's intent).
const fsp = {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Comment": sequence.fsp_final
? "(First preplot shot found in P1)"
: "(First shot found in P1)",
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
"IsUndershoot": false,
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
"ShotPointId": sequence.fsp_final || sequence.fsp,
"Time": sequence.ts0_final || sequence.ts0
}
SequenceObject.Entries.unshift(fsp);
}
}
// If no explicit LSP but there is a LGSP, clone it as LSP.
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3007)) {
const lgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3006);
if (lgspIndex != -1) {
const lsp = Object.assign({}, SequenceObject.Entries[lgspIndex], {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"IsNTBP": sequence.status == "ntbp",
"LineStatus": "Pending",
});
SequenceObject.Entries.splice(lgspIndex+1, 0, lsp);
} else {
// See comment above concerning FSP
const lsp = {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"Comment": sequence.lsp_final
? "(Last preplot shot found in P1)"
: "(Last shot found in P1)",
"IsNTBP": sequence.status == "ntbp",
// NOTE: Always pending, see
// https://gitlab.com/wgp/dougal/software/-/issues/12
"LineStatus": "Pending",
"ShotPointId": sequence.lsp_final || sequence.lsp,
"Time": sequence.ts1_final || sequence.ts1
}
SequenceObject.Entries.push(lsp);
}
}
// Set the missing shots object if not inhibited by the user.
if (exportMissing) {
// The user also needs to request missing shots from the sequences
// endpoint; these are not returned by default.
if ("missing_final" in sequence) {
SequenceObject.MissingShots = sequence.missing_final.map(s => s.point).sort();
}
}
}
return output;
}
const { transform } = require('../../../../lib/sse');
const seis = async function (req, res, next) {
try {

View File

@@ -12,5 +12,6 @@ module.exports = {
configuration: require('./configuration'),
info: require('./info'),
meta: require('./meta'),
openapi: require('./openapi')
openapi: require('./openapi'),
rss: require('./rss')
};

View File

@@ -0,0 +1,21 @@
const fetch = require('node-fetch');
module.exports = async function (req, res, next) {
try {
if (req.query.remote) {
// We're being asked to fetch a remote feed
// NOTE: No, we don't limit what feeds the user can fetch
const r = await fetch(req.query.remote);
if (r && r.ok) {
res.set("Content-Type", "application/xml");
res.send(await r.text());
return;
}
}
res.status(400).send();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,4 @@
module.exports = {
get: require('./get')
}

View File

@@ -1,5 +1,22 @@
const { setSurvey } = require('../connection');
const { geometryAsString } = require('../../utils');
function replaceMarkers (item, opts={}) {
const textkey = opts.text || "remarks";
const text = item[textkey];
if (text && typeof text === "string") {
item[textkey] = text
.replace(/@POS(ITION)?@/g, geometryAsString(item, opts) || "(position unknown)")
.replace(/@DMS@/g, geometryAsString(item, {...opts, dms:true}) || "(position unknown)")
}
return item;
}
async function list (projectId, opts = {}) {
const client = await setSurvey(projectId);
@@ -29,7 +46,7 @@ async function list (projectId, opts = {}) {
const res = await client.query(text, filter[1]);
client.release();
return res.rows;
return res.rows.map(i => replaceMarkers(i));
}
module.exports = list;

View File

@@ -45,7 +45,7 @@ async function list (projectId, opts = {}) {
AND UNBOUNDED FOLLOWING
)
)
SELECT s.*, incr, remarks, pl.ntba
SELECT s.*, incr, remarks, pl.ntba, pl.meta
FROM summary s
INNER JOIN preplot_lines pl ON pl.class = 'V' AND s.line = pl.line
ORDER BY ${sortKey} ${sortDir}

View File

@@ -92,7 +92,7 @@ async function getLineName (client, projectId, payload) {
const attempt = planned.filter(r => r.line == payload.line).concat(previous).length;
const p = payload;
const incr = p.lsp > p.fsp;
const sequence = p.sequence;
const sequence = p.sequence || 1;
const line = p.line;
return `${incr?"1":"2"}0${line}${attempt}${sequence.toString().padStart(3, "0")}S00000`;
}

View File

@@ -71,7 +71,12 @@ async function byShot (client) {
async function bySequence (client) {
console.error("bySequence");
const text = `
SELECT sequence _id, 'raw' AS type, *
SELECT sequence _id, 'raw' AS type, *,
EXISTS(SELECT 1
FROM raw_shots
WHERE sequence = ss.sequence
HAVING count(*) FILTER (WHERE meta ? 'smsrc') > 0
) has_smsrc_data
FROM sequences_summary ss
WHERE NOT EXISTS (
SELECT sequence

View File

@@ -0,0 +1,53 @@
const Path = require('path');
const nunjucks = require('nunjucks');
const marked = require('marked');
function njkFind (ary, key, value) {
if (!ary.find) {
return ary[key];
}
if (typeof value == 'undefined') {
return ary.find(i => i.hasOwnAttribute(key));
} else {
return ary.find(i => i[key] == value);
}
}
function njkPadStart (str, len, chr) {
return String(str).padStart(len, chr);
}
function njkTimestamp (arg) {
if (typeof arg.toISOString === "function") {
return arg.toISOString();
}
const ts = new Date(arg);
if (!isNaN(ts)) {
return ts.toISOString();
}
return arg;
}
function njkMarkdown (str) {
return marked(String(str));
}
function njkMarkdownInline (str) {
return marked.parseInline(String(str));
}
async function render (data, template) {
const nenv = nunjucks.configure(Path.dirname(template), {autoescape: false, lstripBlocks: false, trimBlocks: false});
nenv.addFilter('find', njkFind);
nenv.addFilter('padStart', njkPadStart);
nenv.addFilter('timestamp', njkTimestamp);
nenv.addFilter('markdown', njkMarkdown);
nenv.addFilter('markdownInline', njkMarkdownInline);
const view = nenv.render(Path.basename(template), data);
return view;
}
module.exports = render;

Binary file not shown.

View File

@@ -0,0 +1,52 @@
// TODO Append location to PATH
const path = require('path');
const fs = require('fs');
const {Builder, By, Key, until} = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const geckodriverPath = path.resolve(__dirname, "geckodriver");
// We launch a browser instance and then start an activity timer.
// We shut down the browser after a period of inactivity, to
// save memory.
let driver = null;
let timer = null;
function resetTimer () {
clearTimeout(timer);
timer = setTimeout(shutdown, 120000); // Yup, hardcoded to two minutes. For now anyway
}
async function launch () {
resetTimer();
if (!driver) {
console.log("Launching Firefox");
const options = new firefox.Options();
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxService(new firefox.ServiceBuilder(geckodriverPath))
.setFirefoxOptions(options.headless())
.build();
}
}
async function shutdown () {
if (driver) {
console.log("Shutting down Firefox");
// This is an attempt at avoiding a race condition if someone
// makes a call and resets the timer while the shutdown is in
// progress.
const d = driver;
driver = null;
await d.quit();
}
}
async function url2pdf (url) {
await launch();
await driver.get(url);
return await driver.printPage({width: 21.0, height: 29.7});
}
module.exports = { url2pdf };

View File

@@ -0,0 +1,3 @@
module.exports = {
transform: require('./transform')
}

View File

@@ -0,0 +1,289 @@
function transform (events, sequences, opts = {}) {
const dgl = !!opts.projectId;
const exportQC = opts.exportQC !== false;
const exportGeneral = opts.exportGeneral !== false;
const exportMissing = opts.exportMissing !== false;
const output = {
DglProjectId: opts.projectId,
Sequences: [],
DglCreatedOn: dgl && new Date()
};
// NOTE: Events come in descending chronological
// order from the server.
for (const event of events.reverse()) {
const SequenceNumber = event.sequence;
if (SequenceNumber) {
const sequence = sequences.find(s => s.sequence == SequenceNumber);
if (!sequence) {
throw Error(`Sequence ${SequenceNumber} not found in sequence list`);
}
let SequenceObject = output.Sequences.find(s => s.SequenceNumber == SequenceNumber);
if (!SequenceObject) {
SequenceObject = {
SequenceNumber,
Entries: [],
DglNumPoints: sequence.num_points,
DglNumMissing: sequence.missing_shots,
// NOTE: Distance & azimuth refer to raw data if the sequence
// status is 'raw' and to final data if status is 'final'. In
// the event of it being NTBP it depends on whether final data
// exists or not.
DglLength: sequence.length
};
[sequence.remarks, sequence.remarks_final].filter(i => !!i).forEach(i => {
if (!SequenceObject.DglSequenceComments) {
SequenceObject.DglSequenceComments = []
}
SequenceObject.DglSequenceComments.push(i);
});
output.Sequences.push(SequenceObject);
}
if (event.labels.includes("FSP")) {
const entry = {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Comment": event.remarks.toString(),
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
"IsUndershoot": false,
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FGSP")) {
const entry = {
"EntryType": "Start good",
"EntryTypeId": 3001,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FCSP")) {
const entry = {
"EntryType": "Start charged",
"EntryTypeId": 3002,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LGFFSP")) {
const entry = {
"EntryType": "Last good Full Fold",
"EntryTypeId": 3008,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LCFFSP")) {
const entry = {
"EntryType": "Last charged Full Fold",
"EntryTypeId": 3009,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("FDSP")) {
const entry = {
"EntryType": "Midnight",
"EntryTypeId": 3003,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LCSP")) {
const entry = {
"EntryType": "End charged",
"EntryTypeId": 3005,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LGSP")) {
const entry = {
"EntryType": "End good",
"EntryTypeId": 3006,
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
if (event.labels.includes("LSP")) {
const entry = {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"Comment": event.remarks.toString(),
"IsNTBP": sequence.status == "ntbp",
// NOTE: Always pending, see
// https://gitlab.com/wgp/dougal/software/-/issues/12
"LineStatus": "Pending",
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
// Dougal QC data
if (exportQC) {
if (event.labels.includes("QC")) {
if (!event.labels.includes("QCAccepted")) {
const entry = {
"EntryType": "QC",
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
}
}
if (exportGeneral) {
// These are labels that we have already (potentially) exported
const excluded = [
"FSP", "FGSP", "FCSP", "LGFFSP",
"LCFFSP", "LCSP", "LGSP", "LSP",
"LDSP", "FDSP", "QC", "QCAccepted",
"Internal", "Private", "Confidential",
"NoExport"
];
if (!event.labels || !event.labels.some( l => excluded.includes(l) )) {
const entry = {
"Comment": event.remarks.toString(),
"ShotPointId": event.point,
"Time": event.tstamp
}
SequenceObject.Entries.push(entry);
}
}
}
}
for (const SequenceObject of output.Sequences) {
const sequence = sequences.find(s => s.sequence == SequenceObject.SequenceNumber);
// If no explicit FSP but there is a FGSP, clone it as FSP.
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3000)) {
const fgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3001);
if (fgspIndex != -1) {
const fsp = Object.assign({}, SequenceObject.Entries[fgspIndex], {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot,
"IsUndershoot": false,
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
});
SequenceObject.Entries.splice(fgspIndex, 0, fsp);
} else {
// We have neither an FSP nor an FGSP, let us take the first
// preplot shot from the sequence data as save that as FSP
// (we intentionally omit adding an FGSP as we know nothing
// about the user's intent).
const fsp = {
"EntryType": "Start of line recording",
"EntryTypeId": 3000,
"Comment": sequence.fsp_final
? "(First preplot shot found in P1)"
: "(First shot found in P1)",
"Heading": Number(sequence.azimuth.toFixed(1)),
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
"IsUndershoot": false,
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
"LineNumber": sequence.line.toString(),
"LineType": "Prime", // FIXME
"ShotPointId": sequence.fsp_final || sequence.fsp,
"Time": sequence.ts0_final || sequence.ts0
}
SequenceObject.Entries.unshift(fsp);
}
}
// If no explicit LSP but there is a LGSP, clone it as LSP.
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3007)) {
const lgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3006);
if (lgspIndex != -1) {
const lsp = Object.assign({}, SequenceObject.Entries[lgspIndex], {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"IsNTBP": sequence.status == "ntbp",
"LineStatus": "Pending",
});
SequenceObject.Entries.splice(lgspIndex+1, 0, lsp);
} else {
// See comment above concerning FSP
const lsp = {
"EntryType": "End of line recording",
"EntryTypeId": 3007,
"Comment": sequence.lsp_final
? "(Last preplot shot found in P1)"
: "(Last shot found in P1)",
"IsNTBP": sequence.status == "ntbp",
// NOTE: Always pending, see
// https://gitlab.com/wgp/dougal/software/-/issues/12
"LineStatus": "Pending",
"ShotPointId": sequence.lsp_final || sequence.lsp,
"Time": sequence.ts1_final || sequence.ts1
}
SequenceObject.Entries.push(lsp);
}
}
// Set the missing shots object if not inhibited by the user.
if (exportMissing) {
// The user also needs to request missing shots from the sequences
// endpoint; these are not returned by default.
if ("missing_final" in sequence) {
SequenceObject.MissingShots = sequence.missing_final.map(s => s.point).sort();
}
}
}
return output;
}
module.exports = transform;

View File

@@ -0,0 +1,36 @@
function dms (lat, lon) {
const λh = lat < 0 ? "S" : "N";
const φh = lon < 0 ? "W" : "E";
const λn = Math.abs(lat);
const φn = Math.abs(lon);
const λi = Math.trunc(λn);
const φi = Math.trunc(φn);
const λf = λn - λi;
const φf = φn - φi;
const λs = ((λf*3600)%60).toFixed(1);
const φs = ((φf*3600)%60).toFixed(1);
const λm = Math.trunc(λf*60);
const φm = Math.trunc(φf*60);
const λ =
String(λi).padStart(2, "0") + "°" +
String(λm).padStart(2, "0") + "'" +
String(λs).padStart(4, "0") + '" ' +
λh;
const φ =
String(φi).padStart(3, "0") + "°" +
String(φm).padStart(2, "0") + "'" +
String(φs).padStart(4, "0") + '" ' +
φh;
return λ+" "+φ;
}
module.exports = dms;

View File

@@ -0,0 +1,36 @@
const dms = require('./dms');
function geometryAsString (item, opts = {}) {
const key = "key" in opts ? opts.key : "geometry";
const formatDMS = opts.dms;
let str = "";
if (key in item) {
const geometry = item[key];
if (geometry && "coordinates" in geometry) {
if (geometry.type == "Point") {
if (formatDMS) {
str = dms(geometry.coordinates[1], geometry.coordinates[0]);
} else {
str = `${geometry.coordinates[1].toFixed(6)}, ${geometry.coordinates[0].toFixed(6)}`;
}
}
if (str) {
if (opts.url) {
if (typeof opts.url === 'string') {
str = `[${str}](${opts.url.replace("$x", geometry.coordinates[0]).replace("$y", geometry.coordinates[1])})`;
} else {
str = `[${str}](geo:${geometry.coordinates[0]},${geometry.coordinates[1]})`;
}
}
}
}
}
return str;
}
module.exports = geometryAsString;

View File

@@ -0,0 +1,5 @@
module.exports = {
geometryAsString: require('./geometryAsString'),
dms: require('./dms')
};

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,12 @@
"express": "^4.17.1",
"express-jwt": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"marked": "^2.0.3",
"netmask": "^1.0.6",
"node-fetch": "^2.6.1",
"nunjucks": "^3.2.3",
"pg": "^8.3.3",
"selenium-webdriver": "*",
"ws": "^7.3.1",
"yaml": "^2.0.0-0"
},

View File

@@ -301,6 +301,9 @@ components:
ntba:
type: boolean
description: Not to be acquired flag. If `true`, all the points in this line are ignored in production statistics.
meta:
type: object
description: Arbitrary JSON data associated with this shotpoint.
Shotpoint:
@@ -813,6 +816,32 @@ paths:
/project/{project}/sequence/{sequence}:
get:
description: Get a sequence
tags: [ "sequences" ]
parameters:
- $ref: "#/components/parameters/Project"
- $ref: "#/components/parameters/Sequence"
responses:
"200":
description: Sequence data.
content:
application/json:
schema:
$ref: "#/components/schemas/Sequence"
application/vnd.seis+json:
schema:
type: object
application/geo+json:
schema:
$ref: "#/components/schemas/GeoJSONFeature"
text/html:
schema:
type: string
application/pdf:
schema:
type: string
format: binary
patch:
summary: Update sequence information.
description: All of the properties are optional. Users may specify only the one(s) they need, other properties remain unchanged. An empty object body causes the resource not to be modified at all. Final sequence properties only have effect if a final P1 file exists for the relevant sequence.