Merge branch '76-add-configuration-gui' into 'devel'

Resolve "Add configuration GUI"

Closes #294, #295, #296, #298, #76, #297, #129, #313, #312, #305, #264, #307, #303, #300, #301, #302, #290, #291, #292, and #293

See merge request wgp/dougal/software!17
This commit is contained in:
D. Berge
2025-07-09 18:11:50 +00:00
103 changed files with 11493 additions and 228 deletions

View File

@@ -256,6 +256,62 @@ class Datastore:
self.maybe_commit()
def save_preplot_line_info(self, lines, filepath, filedata = None):
"""
Save preplot line information
Arguments:
lines (iterable): should be a collection of lines returned from
one of the line info reading functions (see preplots.py).
filepath (string): the full path to the preplot file from where the lines
have been read. It will be added to the survey's `file` table so that
it can be monitored for changes.
"""
with self.conn.cursor() as cursor:
cursor.execute("BEGIN;")
hash = self.add_file(filepath, cursor)
count=0
for line in lines:
count += 1
print(f"\u001b[2KSaving line {count} / {len(lines)}", end="\r", flush=True)
sail_line = line["sail_line"]
incr = line.get("incr", True)
ntba = line.get("ntba", False)
remarks = line.get("remarks", None)
meta = json.dumps(line.get("meta", {}))
source_lines = line.get("source_line", [])
for source_line in source_lines:
qry = """
INSERT INTO preplot_saillines AS ps
(sailline, line, sailline_class, line_class, incr, ntba, remarks, meta, hash)
VALUES
(%s, %s, 'V', 'S', %s, %s, %s, %s, %s)
ON CONFLICT (sailline, sailline_class, line, line_class, incr) DO UPDATE
SET
incr = EXCLUDED.incr,
ntba = EXCLUDED.ntba,
remarks = COALESCE(EXCLUDED.remarks, ps.remarks),
meta = ps.meta || EXCLUDED.meta,
hash = EXCLUDED.hash;
"""
# NOTE Consider using cursor.executemany() instead. Then again,
# we're only expecting a few hundred lines at most.
cursor.execute(qry, (sail_line, source_line, incr, ntba, remarks, meta, hash))
if filedata is not None:
self.save_file_data(filepath, json.dumps(filedata), cursor)
self.maybe_commit()
def save_raw_p190(self, records, fileinfo, filepath, epsg = 0, filedata = None, ntbp = False):
"""
Save raw P1 data.
@@ -627,12 +683,12 @@ class Datastore:
if include_archived:
qry = """
SELECT meta
SELECT meta, schema
FROM public.projects;
"""
else:
qry = """
SELECT meta
SELECT meta, schema
FROM public.projects
WHERE NOT (meta->'archived')::boolean IS true
"""
@@ -642,7 +698,12 @@ class Datastore:
cursor.execute(qry)
results = cursor.fetchall()
return [r[0] for r in results if r[0]]
surveys = []
for r in results:
if r[0]:
r[0]['schema'] = r[1]
surveys.append(r[0])
return surveys
# TODO Does this need tweaking on account of #246?

163
bin/delimited.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/python3
"""
Delimited record importing functions.
"""
import csv
import builtins
def to_bool (v):
try:
return bool(int(v))
except ValueError:
if type(v) == str:
return v.strip().lower().startswith("t")
return False
transform = {
"int": lambda v: builtins.int(float(v)),
"float": float,
"string": str,
"bool": to_bool
}
def cast_values (row, fields):
def enum_for (key):
field = fields.get(key, {})
def enum (val):
if "enum" in field:
ret_val = field.get("default", val)
enums = field.get("enum", [])
for enum_key in enums:
if enum_key == val:
ret_val = enums[enum_key]
return ret_val
return val
return enum
# Get rid of any unwanted data
if None in row:
del(row[None])
for key in row:
val = row[key]
enum = enum_for(key)
transformer = transform.get(fields.get(key, {}).get("type"), str)
if type(val) == list:
for i, v in enumerate(val):
row[key][i] = transformer(enum(v))
elif type(val) == dict:
continue
else:
row[key] = transformer(enum(val))
return row
def build_fieldnames (spec): #(arr, key, val):
fieldnames = []
if "fields" in spec:
for key in spec["fields"]:
index = spec["fields"][key]["column"]
try:
fieldnames[index] = key
except IndexError:
assert index >= 0
fieldnames.extend(((index + 1) - len(fieldnames)) * [None])
fieldnames[index] = key
return fieldnames
def from_file_delimited (path, spec):
fieldnames = build_fieldnames(spec)
fields = spec.get("fields", [])
delimiter = spec.get("delimiter", ",")
firstRow = spec.get("firstRow", 0)
headerRow = spec.get("headerRow", False)
if headerRow:
firstRow += 1
records = []
with open(path, "r", errors="ignore") as fd:
if spec.get("type") == "x-sl+csv":
fieldnames = None # Pick from header row
firstRow = 0
reader = csv.DictReader(fd, delimiter=delimiter)
else:
reader = csv.DictReader(fd, fieldnames=fieldnames, delimiter=delimiter)
row = 0
for line in reader:
skip = False
if row < firstRow:
skip = True
if not skip:
records.append(cast_values(dict(line), fields))
row += 1
return records
def remap (line, headers):
row = dict()
for i, key in enumerate(headers):
if "." in key[1:-1]:
# This is an object
k, attr = key.split(".")
if not k in row:
row[k] = dict()
row[k][attr] = line[i]
elif key in row:
if type(row[key]) == list:
row[key].append(line[i])
else:
row[key] = [ row[key], line[i] ]
else:
row[key] = line[i]
return row
def from_file_saillines (path, spec):
fields = {
"sail_line": { "type": "int" },
"source_line": { "type": "int" },
"incr": { "type": "bool" },
"ntba": { "type": "bool" }
}
# fields = spec.get("fields", sl_fields)
delimiter = spec.get("delimiter", ",")
firstRow = spec.get("firstRow", 0)
records = []
with open(path, "r", errors="ignore") as fd:
row = 0
reader = csv.reader(fd, delimiter=delimiter)
while row < firstRow:
next(reader)
row += 1
headers = [ h.strip() for h in next(reader) if len(h.strip()) ]
for line in reader:
records.append(cast_values(remap(line, headers), fields))
return records
def from_file_p111 (path, spec):
pass
def from_file (path, spec):
if spec.get("type") == "x-sl+csv":
return from_file_saillines(path, spec)
else:
return from_file_delimited(path, spec)

126
bin/fwr.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/python3
"""
Fixed width record importing functions.
"""
import builtins
def to_bool (v):
try:
return bool(int(v))
except ValueError:
if type(v) == str:
return v.strip().lower().startswith("t")
return False
transform = {
"int": lambda v: builtins.int(float(v)),
"float": float,
"string": str,
"str": str,
"bool": to_bool
}
def parse_line (line, fields, fixed = None):
data = dict()
if fixed:
for value in fixed:
start = value["offset"]
end = start + len(value["text"])
text = line[start:end]
if text != value["text"]:
return f"Expected text `{value['text']}` at position {start} but found `{text}` instead."
for key in fields:
spec = fields[key]
transformer = transform[spec.get("type", "str")]
pos_from = spec["offset"]
pos_to = pos_from + spec["length"]
text = line[pos_from:pos_to]
value = transformer(text)
if "enum" in spec:
if "default" in spec:
value = spec["default"]
for enum_key in spec["enum"]:
if enum_key == text:
enum_value = transformer(spec["enum"][enum_key])
value = enum_value
break
data[key] = value
return data
specfields = {
"sps1": {
"line_name": { "offset": 1, "length": 16, "type": "int" },
"point_number": { "offset": 17, "length": 8, "type": "int" },
"easting": { "offset": 46, "length": 9, "type": "float" },
"northing": { "offset": 55, "length": 10, "type": "float" }
},
"sps21": {
"line_name": { "offset": 1, "length": 7, "type": "int" },
"point_number": { "offset": 11, "length": 7, "type": "int" },
"easting": { "offset": 46, "length": 9, "type": "float" },
"northing": { "offset": 55, "length": 10, "type": "float" }
},
"p190": {
"line_name": { "offset": 1, "length": 12, "type": "int" },
"point_number": { "offset": 19, "length": 6, "type": "int" },
"easting": { "offset": 46, "length": 9, "type": "float" },
"northing": { "offset": 55, "length": 9, "type": "float" }
},
}
def from_file(path, spec):
# If spec.fields is not present, deduce it from spec.type ("sps1", "sps21", "p190", etc.)
if "fields" in spec:
fields = spec["fields"]
elif "type" in spec and spec["type"] in specfields:
fields = specfields[spec["type"]]
else:
# TODO: Should default to looking for spec.format and doing a legacy import on it
return "Neither 'type' nor 'fields' given. I don't know how to import this fixed-width dataset."
firstRow = spec.get("firstRow", 0)
skipStart = [] # Skip lines starting with any of these values
skipMatch = [] # Skip lines matching any of these values
if "type" in spec:
if spec["type"] == "sps1" or spec["type"] == "sps21" or spec["type"] == "p190":
skipStart = "H"
skipMatch = "EOF"
records = []
with open(path, "r", errors="ignore") as fd:
row = 0
line = fd.readline()
while line:
skip = False
if row < firstRow:
skip = True
if not skip:
for v in skipStart:
if line.startswith(v):
skip = True
break
for v in skipMatch:
if line == v:
skip = True
break
if not skip:
records.append(parse_line(line, fields))
row += 1
line = fd.readline()
return records

View File

@@ -15,6 +15,7 @@ import re
import time
import configuration
import p111
import fwr
from datastore import Datastore
def add_pending_remark(db, sequence):
@@ -69,8 +70,12 @@ if __name__ == '__main__':
print("No final P1/11 configuration")
exit(0)
pattern = final_p111["pattern"]
rx = re.compile(pattern["regex"])
lineNameInfo = final_p111.get("lineNameInfo")
pattern = final_p111.get("pattern")
rx = None
if pattern and pattern.get("regex"):
rx = re.compile(pattern["regex"])
if "pending" in survey["final"]:
pendingRx = re.compile(survey["final"]["pending"]["pattern"]["regex"])
@@ -98,15 +103,38 @@ if __name__ == '__main__':
print("Importing")
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not match the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
if rx:
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not match the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = dict(zip(pattern["captures"], match.groups()))
file_info["meta"] = {}
file_info = dict(zip(pattern["captures"], match.groups()))
file_info["meta"] = {}
if lineNameInfo:
basename = os.path.basename(physical_filepath)
fields = lineNameInfo.get("fields", {})
fixed = lineNameInfo.get("fixed")
try:
parsed_line = fwr.parse_line(basename, fields, fixed)
except ValueError as err:
parsed_line = "Line format error: " + str(err)
if type(parsed_line) == str:
print(parsed_line, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = {}
file_info["sequence"] = parsed_line["sequence"]
file_info["line"] = parsed_line["line"]
del(parsed_line["sequence"])
del(parsed_line["line"])
file_info["meta"] = {
"fileInfo": parsed_line
}
if pending:
print("Skipping / removing final file because marked as PENDING", logical_filepath)

View File

@@ -15,6 +15,12 @@ import configuration
import preplots
from datastore import Datastore
def preplots_sorter (preplot):
rank = {
"x-sl+csv": 10
}
return rank.get(preplot.get("type"), 0)
if __name__ == '__main__':
print("Connecting to database")
@@ -28,7 +34,10 @@ if __name__ == '__main__':
for survey in surveys:
print(f'Survey: {survey["id"]} ({survey["schema"]})')
db.set_survey(survey["schema"])
for file in survey["preplots"]:
# We sort the preplots so that ancillary line info always comes last,
# after the actual line + point data has been imported
for file in sorted(survey["preplots"], key=preplots_sorter):
realpath = configuration.translate_path(file["path"])
print(f"Preplot: {file['path']}")
@@ -48,7 +57,10 @@ if __name__ == '__main__':
if type(preplot) is list:
print("Saving to DB")
db.save_preplots(preplot, file["path"], file["class"], survey["epsg"], file)
if file.get("type") == "x-sl+csv":
db.save_preplot_line_info(preplot, file["path"], file)
else:
db.save_preplots(preplot, file["path"], file["class"], survey["epsg"], file)
elif type(preplot) is str:
print(preplot)
else:

View File

@@ -15,6 +15,7 @@ import re
import time
import configuration
import p111
import fwr
from datastore import Datastore
if __name__ == '__main__':
@@ -38,8 +39,11 @@ if __name__ == '__main__':
print("No raw P1/11 configuration")
exit(0)
pattern = raw_p111["pattern"]
rx = re.compile(pattern["regex"])
lineNameInfo = raw_p111.get("lineNameInfo")
pattern = raw_p111.get("pattern")
rx = None
if pattern and pattern.get("regex"):
rx = re.compile(pattern["regex"])
if "ntbp" in survey["raw"]:
ntbpRx = re.compile(survey["raw"]["ntbp"]["pattern"]["regex"])
@@ -68,16 +72,38 @@ if __name__ == '__main__':
print("Importing")
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not matching the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
if rx:
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not matching the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = dict(zip(pattern["captures"], match.groups()))
file_info["meta"] = {}
file_info = dict(zip(pattern["captures"], match.groups()))
file_info["meta"] = {}
if lineNameInfo:
basename = os.path.basename(physical_filepath)
fields = lineNameInfo.get("fields", {})
fixed = lineNameInfo.get("fixed")
try:
parsed_line = fwr.parse_line(basename, fields, fixed)
except ValueError as err:
parsed_line = "Line format error: " + str(err)
if type(parsed_line) == str:
print(parsed_line, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = {}
file_info["sequence"] = parsed_line["sequence"]
file_info["line"] = parsed_line["line"]
del(parsed_line["sequence"])
del(parsed_line["line"])
file_info["meta"] = {
"fileInfo": parsed_line
}
p111_data = p111.from_file(physical_filepath)
print("Saving")

View File

@@ -15,6 +15,7 @@ import re
import time
import configuration
import smsrc
import fwr
from datastore import Datastore
if __name__ == '__main__':
@@ -33,17 +34,21 @@ if __name__ == '__main__':
db.set_survey(survey["schema"])
try:
raw_smsrc = survey["raw"]["smsrc"]
raw_smsrc = survey["raw"]["source"]["smsrc"]["header"]
except KeyError:
print("No SmartSource data configuration")
continue
flags = 0
if "flags" in raw_smsrc:
configuration.rxflags(raw_smsrc["flags"])
# NOTE I've no idea what this is 🤔
# flags = 0
# if "flags" in raw_smsrc:
# configuration.rxflags(raw_smsrc["flags"])
pattern = raw_smsrc["pattern"]
rx = re.compile(pattern["regex"], flags)
lineNameInfo = raw_smsrc.get("lineNameInfo")
pattern = raw_smsrc.get("pattern")
rx = None
if pattern and pattern.get("regex"):
rx = re.compile(pattern["regex"])
for fileprefix in raw_smsrc["paths"]:
realprefix = configuration.translate_path(fileprefix)
@@ -64,14 +69,39 @@ if __name__ == '__main__':
print("Importing")
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not matching the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
if rx:
match = rx.match(os.path.basename(logical_filepath))
if not match:
error_message = f"File path not matching the expected format! ({logical_filepath} ~ {pattern['regex']})"
print(error_message, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = dict(zip(pattern["captures"], match.groups()))
file_info = dict(zip(pattern["captures"], match.groups()))
file_info["meta"] = {}
if lineNameInfo:
basename = os.path.basename(physical_filepath)
fields = lineNameInfo.get("fields", {})
fixed = lineNameInfo.get("fixed")
try:
parsed_line = fwr.parse_line(basename, fields, fixed)
except ValueError as err:
parsed_line = "Line format error: " + str(err)
if type(parsed_line) == str:
print(parsed_line, file=sys.stderr)
print("This file will be ignored!")
continue
file_info = {}
file_info["sequence"] = parsed_line["sequence"]
file_info["line"] = parsed_line["line"]
del(parsed_line["sequence"])
del(parsed_line["line"])
file_info["meta"] = {
"fileInfo": parsed_line
}
smsrc_records = smsrc.from_file(physical_filepath)

View File

@@ -7,7 +7,6 @@ P1/11 parsing functions.
import math
import re
from datetime import datetime, timedelta, timezone
from parse_fwr import parse_fwr
def _int (string):
return int(float(string))

View File

@@ -1,21 +0,0 @@
#!/usr/bin/python3
def parse_fwr (string, widths, start=0):
"""Parse a fixed-width record.
string: the string to parse.
widths: a list of record widths. A negative width denotes a field to be skipped.
start: optional start index.
Returns a list of strings.
"""
results = []
current_index = start
for width in widths:
if width > 0:
results.append(string[current_index : current_index + width])
current_index += width
else:
current_index -= width
return results

View File

@@ -1,15 +1,51 @@
import sps
import fwr
import delimited
"""
Preplot importing functions.
"""
def is_fixed_width (file):
fixed_width_types = [ "sps1", "sps21", "p190", "fixed-width" ]
return type(file) == dict and "type" in file and file["type"] in fixed_width_types
def is_delimited (file):
delimited_types = [ "csv", "p111", "x-sl+csv" ]
return type(file) == dict and "type" in file and file["type"] in delimited_types
def from_file (file, realpath = None):
"""
Return a list of dicts, where each dict has the structure:
{
"line_name": <int>,
"points": [
{
"line_name": <int>,
"point_number": <int>,
"easting": <float>,
"northing": <float>
},
]
}
On error, return a string describing the error condition.
"""
filepath = realpath or file["path"]
if not "type" in file or file["type"] == "sps":
records = sps.from_file(filepath, file["format"] if "format" in file else None )
if is_fixed_width(file):
records = fwr.from_file(filepath, file)
elif is_delimited(file):
records = delimited.from_file(filepath, file)
else:
return "Not an SPS file"
return "Unrecognised file format"
if type(records) == str:
# This is an error message
return records
if file.get("type") == "x-sl+csv":
return records
lines = []
line_names = set([r["line_name"] for r in records])

View File

@@ -1,51 +0,0 @@
#!/usr/bin/python3
"""
SPS importing functions.
And by SPS, we mean more or less any line-delimited, fixed-width record format.
"""
import builtins
from parse_fwr import parse_fwr
def int (v):
return builtins.int(float(v))
def parse_line (string, spec):
"""Parse a line from an SPS file."""
names = spec["names"]
widths = spec["widths"]
normalisers = spec["normalisers"]
record = [ t[0](t[1]) for t in zip(normalisers, parse_fwr(string, widths)) ]
return dict(zip(names, record))
def from_file(path, spec = None):
if spec is None:
spec = {
"names": [ "line_name", "point_number", "easting", "northing" ],
"widths": [ -1, 10, 10, -25, 10, 10 ],
"normalisers": [ int, int, float, float ]
}
else:
normaliser_tokens = [ "int", "float", "str", "bool" ]
spec["normalisers"] = [ eval(t) for t in spec["types"] if t in normaliser_tokens ]
records = []
with open(path) as fd:
cnt = 0
line = fd.readline()
while line:
cnt = cnt+1
if line == "EOF":
break
record = parse_line(line, spec)
if record is not None:
records.append(record)
line = fd.readline()
del spec["normalisers"]
return records

View File

@@ -1,5 +1,5 @@
\connect dougal
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.2"}')
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.5"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.4.2"}' WHERE public.info.key = 'version';
SET value = public.info.value || '{"db_schema": "0.4.5"}' WHERE public.info.key = 'version';

View File

@@ -399,6 +399,62 @@ $$;
ALTER FUNCTION _SURVEY__TEMPLATE_.clear_shot_qc() OWNER TO postgres;
--
-- Name: event_log_uid_seq; Type: SEQUENCE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE SEQUENCE _SURVEY__TEMPLATE_.event_log_uid_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE _SURVEY__TEMPLATE_.event_log_uid_seq OWNER TO postgres;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: event_log_full; Type: TABLE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE TABLE _SURVEY__TEMPLATE_.event_log_full (
uid integer DEFAULT nextval('_SURVEY__TEMPLATE_.event_log_uid_seq'::regclass) NOT NULL,
id integer NOT NULL,
tstamp timestamp with time zone,
sequence integer,
point integer,
remarks text DEFAULT ''::text NOT NULL,
labels text[] DEFAULT ARRAY[]::text[] NOT NULL,
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
validity tstzrange NOT NULL,
CONSTRAINT event_log_full_check CHECK ((((tstamp IS NOT NULL) AND (sequence IS NOT NULL) AND (point IS NOT NULL)) OR ((tstamp IS NOT NULL) AND (sequence IS NULL) AND (point IS NULL)) OR ((tstamp IS NULL) AND (sequence IS NOT NULL) AND (point IS NOT NULL)))),
CONSTRAINT event_log_full_validity_check CHECK ((NOT isempty(validity)))
);
ALTER TABLE _SURVEY__TEMPLATE_.event_log_full OWNER TO postgres;
--
-- Name: event_log_changes(timestamp with time zone); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE FUNCTION _SURVEY__TEMPLATE_.event_log_changes(ts0 timestamp with time zone) RETURNS SETOF _SURVEY__TEMPLATE_.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);
$$;
ALTER FUNCTION _SURVEY__TEMPLATE_.event_log_changes(ts0 timestamp with time zone) OWNER TO postgres;
--
-- Name: event_log_full_insert(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
@@ -881,46 +937,6 @@ $$;
ALTER FUNCTION _SURVEY__TEMPLATE_.ij_error(line double precision, point double precision, geom public.geometry) OWNER TO postgres;
--
-- Name: event_log_uid_seq; Type: SEQUENCE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE SEQUENCE _SURVEY__TEMPLATE_.event_log_uid_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE _SURVEY__TEMPLATE_.event_log_uid_seq OWNER TO postgres;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: event_log_full; Type: TABLE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE TABLE _SURVEY__TEMPLATE_.event_log_full (
uid integer DEFAULT nextval('_SURVEY__TEMPLATE_.event_log_uid_seq'::regclass) NOT NULL,
id integer NOT NULL,
tstamp timestamp with time zone,
sequence integer,
point integer,
remarks text DEFAULT ''::text NOT NULL,
labels text[] DEFAULT ARRAY[]::text[] NOT NULL,
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
validity tstzrange NOT NULL,
CONSTRAINT event_log_full_check CHECK ((((tstamp IS NOT NULL) AND (sequence IS NOT NULL) AND (point IS NOT NULL)) OR ((tstamp IS NOT NULL) AND (sequence IS NULL) AND (point IS NULL)) OR ((tstamp IS NULL) AND (sequence IS NOT NULL) AND (point IS NOT NULL)))),
CONSTRAINT event_log_full_validity_check CHECK ((NOT isempty(validity)))
);
ALTER TABLE _SURVEY__TEMPLATE_.event_log_full OWNER TO postgres;
--
-- Name: event_log; Type: VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
@@ -1519,9 +1535,9 @@ CREATE VIEW _SURVEY__TEMPLATE_.final_lines_summary AS
s.ts1,
(s.ts1 - s.ts0) AS duration,
s.num_points,
( SELECT count(*) AS count
FROM _SURVEY__TEMPLATE_.missing_sequence_final_points
WHERE missing_sequence_final_points.sequence = s.sequence) AS missing_shots,
(( SELECT count(*) AS count
FROM _SURVEY__TEMPLATE_.preplot_points
WHERE ((preplot_points.line = fl.line) AND (((preplot_points.point >= s.fsp) AND (preplot_points.point <= s.lsp)) OR ((preplot_points.point >= s.lsp) AND (preplot_points.point <= s.fsp))))) - s.num_points) AS missing_shots,
s.length,
s.azimuth,
fl.remarks,
@@ -2077,10 +2093,10 @@ CREATE VIEW _SURVEY__TEMPLATE_.preplot_summary AS
ALTER TABLE _SURVEY__TEMPLATE_.preplot_summary OWNER TO postgres;
--
-- Name: project_summary; Type: VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
-- Name: project_summary; Type: MATERIALIZED VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE VIEW _SURVEY__TEMPLATE_.project_summary AS
CREATE MATERIALIZED VIEW _SURVEY__TEMPLATE_.project_summary AS
WITH fls AS (
SELECT avg((final_lines_summary.duration / ((final_lines_summary.num_points - 1))::double precision)) AS shooting_rate,
avg((final_lines_summary.length / date_part('epoch'::text, final_lines_summary.duration))) AS speed,
@@ -2123,7 +2139,8 @@ CREATE VIEW _SURVEY__TEMPLATE_.project_summary AS
fls.speed AS shooting_rate
FROM _SURVEY__TEMPLATE_.preplot_summary ps,
fls,
project;
project
WITH NO DATA;
ALTER TABLE _SURVEY__TEMPLATE_.project_summary OWNER TO postgres;
@@ -2168,9 +2185,9 @@ CREATE VIEW _SURVEY__TEMPLATE_.raw_lines_summary AS
(s.ts1 - s.ts0) AS duration,
s.num_points,
s.num_preplots,
(SELECT count(*) AS count
FROM _SURVEY__TEMPLATE_.missing_sequence_raw_points
WHERE missing_sequence_raw_points.sequence = s.sequence) AS missing_shots,
(( SELECT count(*) AS count
FROM _SURVEY__TEMPLATE_.preplot_points
WHERE ((preplot_points.line = rl.line) AND (((preplot_points.point >= s.fsp) AND (preplot_points.point <= s.lsp)) OR ((preplot_points.point >= s.lsp) AND (preplot_points.point <= s.fsp))))) - s.num_preplots) AS missing_shots,
s.length,
s.azimuth,
rl.remarks,

View File

@@ -0,0 +1,164 @@
-- Sailline ancillary data
--
-- New schema version: 0.5.0
--
-- 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.
--
-- Issue #264 calls for associating sail and acquisition lines as well
-- as indicating expected acquisition direction, and other data which
-- cannot be provided via standard import formats such as SPS or P1/90.
--
-- We support this via an additional table that holds most of the required
-- data. This data can simply be inferred from regular preplots, e.g., line
-- direction can be deduced from preplot point order, and sail / source
-- line offsets can be taken from P1/90 headers or from a configuration
-- parameter. Alternatively, and in preference, the data can be provided
-- explicitly, which is what issue #264 asks for.
--
-- In principle, this makes at least some of the attributes of `preplot_lines`
-- redundant (at least `incr` and `ntba`) but we will leave them there for
-- the time being as technical debt.
--
-- 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 TABLE IF NOT EXISTS preplot_saillines
(
sailline integer NOT NULL,
line integer NOT NULL,
sailline_class character(1) NOT NULL,
line_class character(1) NOT NULL,
incr boolean NOT NULL DEFAULT true,
ntba boolean NOT NULL DEFAULT false,
remarks text NOT NULL DEFAULT '',
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
hash text NULL, -- Theoretically the info in this table could all be inferred.
PRIMARY KEY (sailline, sailline_class, line, line_class, incr),
CONSTRAINT fk_sailline FOREIGN KEY (sailline, sailline_class)
REFERENCES preplot_lines (line, class)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT fk_line FOREIGN KEY (line, line_class)
REFERENCES preplot_lines (line, class)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT fk_hash FOREIGN KEY (hash)
REFERENCES files (hash) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE,
CHECK (sailline_class = 'V' AND sailline_class != line_class)
);
COMMENT ON TABLE preplot_saillines
IS 'We explicitly associate each preplot sailline (aka vessel line) with zero or more source lines. This information can be inferred from preplot files, e.g., via a sailline offset value, or explicitly provided.';
-- Let us copy whatever information we can from existing tables or views
INSERT INTO preplot_saillines
(sailline, line, sailline_class, line_class, incr, ntba, remarks, meta)
SELECT DISTINCT
sailline, psp.line, 'V' sailline_class, psp.class line_class, pl.incr, pl.ntba, pl.remarks, pl.meta
FROM preplot_saillines_points psp
INNER JOIN preplot_lines pl ON psp.sailline = pl.line AND pl.class = 'V'
ORDER BY sailline
ON CONFLICT DO NOTHING;
-- We need to recreate the preplot_saillines_points view
CREATE OR REPLACE VIEW preplot_saillines_points AS
SELECT psl.sailline,
psl.ntba AS sailline_ntba,
psl.line,
pps.point,
pps.class,
pps.ntba,
pps.geometry,
pps.meta
FROM preplot_saillines psl
INNER JOIN preplot_points pps
ON psl.line = pps.line AND psl.line_class = pps.class;
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.5.0' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.4.5' 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.5.0"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.5.0"}' 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

@@ -0,0 +1,119 @@
-- Sailline ancillary data
--
-- New schema version: 0.5.1
--
-- 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.
--
-- The sequences_detail view wrongly associates source lines and shot
-- points when it should be associating saillines and shot points instead.
--
-- This updates fixes that issue (#307).
--
-- 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 VIEW sequences_detail
AS
SELECT rl.sequence,
rl.line AS sailline,
rs.line,
rs.point,
rs.tstamp,
rs.objref AS objrefraw,
fs.objref AS objreffinal,
st_transform(pp.geometry, 4326) AS geometrypreplot,
st_transform(rs.geometry, 4326) AS geometryraw,
st_transform(fs.geometry, 4326) AS geometryfinal,
ij_error(rs.line::double precision, rs.point::double precision, rs.geometry) AS errorraw,
ij_error(rs.line::double precision, rs.point::double precision, fs.geometry) AS errorfinal,
json_build_object('preplot', pp.meta, 'raw', rs.meta, 'final', fs.meta) AS meta
FROM raw_lines rl
INNER JOIN preplot_saillines psl ON rl.line = psl.sailline
INNER JOIN raw_shots rs ON rs.sequence = rl.sequence AND rs.line = psl.line
INNER JOIN preplot_points pp ON psl.line = pp.line AND psl.line_class = pp.class AND rs.point = pp.point
LEFT JOIN final_shots fs ON rl.sequence = fs.sequence AND rs.point = fs.point;
ALTER TABLE sequences_detail
OWNER TO postgres;
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.5.1' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.5.0' 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.5.1"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.5.1"}' 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

@@ -0,0 +1,142 @@
-- Fix preplot_lines_summary view
--
-- New schema version: 0.5.2
--
-- 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.
--
-- Following introduction of `preplot_saillines` (0.5.0), the incr and
-- ntba statuses are stored in a separate table, not in `preplot_lines`
-- (TODO: a future upgrade should remove those columns from `preplot_lines`)
--
-- Now any views referencing `incr` and `ntba` must be updated to point to
-- the new location of those attributes.
--
-- This update fixes #312.
--
-- 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 VIEW preplot_lines_summary
AS
WITH summary AS (
SELECT DISTINCT pp.line, pp.class,
first_value(pp.point) OVER w AS p0,
last_value(pp.point) OVER w AS p1,
count(pp.point) OVER w AS num_points,
st_distance(first_value(pp.geometry) OVER w, last_value(pp.geometry) OVER w) AS length,
st_azimuth(first_value(pp.geometry) OVER w, last_value(pp.geometry) OVER w) * 180::double precision / pi() AS azimuth0,
st_azimuth(last_value(pp.geometry) OVER w, first_value(pp.geometry) OVER w) * 180::double precision / pi() AS azimuth1
FROM preplot_points pp
WHERE pp.class = 'V'::bpchar
WINDOW w AS (PARTITION BY pp.line ORDER BY pp.point ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
)
SELECT psl.line,
CASE
WHEN psl.incr THEN s.p0
ELSE s.p1
END AS fsp,
CASE
WHEN psl.incr THEN s.p1
ELSE s.p0
END AS lsp,
s.num_points,
s.length,
CASE
WHEN psl.incr THEN s.azimuth0
ELSE s.azimuth1
END AS azimuth,
psl.incr,
psl.remarks
FROM summary s
JOIN preplot_saillines psl ON psl.sailline_class = s.class AND s.line = psl.line
ORDER BY psl.line, incr;
ALTER TABLE preplot_lines_summary
OWNER TO postgres;
COMMENT ON VIEW preplot_lines_summary
IS 'Summarises ''V'' (vessel sailline) preplot lines.';
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.5.2' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.5.1' 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.5.2"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.5.2"}' 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

@@ -0,0 +1,132 @@
-- Fix final_lines_summary view
--
-- New schema version: 0.5.3
--
-- 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 fixes a long-standing bug, where if the sail and source lines are
-- the same, the number of missing shots will be miscounted.
--
-- This update fixes #313.
--
-- 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 VIEW final_lines_summary
AS
WITH summary AS (
SELECT DISTINCT fs.sequence,
first_value(fs.point) OVER w AS fsp,
last_value(fs.point) OVER w AS lsp,
first_value(fs.tstamp) OVER w AS ts0,
last_value(fs.tstamp) OVER w AS ts1,
count(fs.point) OVER w AS num_points,
count(pp.point) OVER w AS num_preplots,
st_distance(first_value(fs.geometry) OVER w, last_value(fs.geometry) OVER w) AS length,
st_azimuth(first_value(fs.geometry) OVER w, last_value(fs.geometry) OVER w) * 180::double precision / pi() AS azimuth
FROM final_shots fs
LEFT JOIN preplot_points pp USING (line, point)
WINDOW w AS (PARTITION BY fs.sequence ORDER BY fs.tstamp ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
)
SELECT fl.sequence,
fl.line,
s.fsp,
s.lsp,
s.ts0,
s.ts1,
s.ts1 - s.ts0 AS duration,
s.num_points,
(( SELECT count(*) AS count
FROM preplot_points
WHERE preplot_points.line = fl.line AND (preplot_points.point >= s.fsp AND preplot_points.point <= s.lsp OR preplot_points.point >= s.lsp AND preplot_points.point <= s.fsp))) - s.num_preplots AS missing_shots,
s.length,
s.azimuth,
fl.remarks,
fl.meta
FROM summary s
JOIN final_lines fl USING (sequence);
ALTER TABLE final_lines_summary
OWNER TO postgres;
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.5.3' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.5.2' 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.5.3"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.5.3"}' 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

@@ -10,7 +10,9 @@
"license": "UNLICENSED",
"dependencies": {
"@mdi/font": "^7.2.96",
"buffer": "^6.0.3",
"core-js": "^3.6.5",
"csv-parse": "^5.5.2",
"d3": "^7.0.1",
"jwt-decode": "^3.0.0",
"leaflet": "^1.7.1",
@@ -26,7 +28,8 @@
"vue-debounce": "^2.6.0",
"vue-router": "^3.5.1",
"vuetify": "^2.5.0",
"vuex": "^3.6.2"
"vuex": "^3.6.2",
"yaml": "^2.3.4"
},
"devDependencies": {
"@babel/plugin-proposal-logical-assignment-operators": "^7.14.5",
@@ -3457,7 +3460,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -3508,6 +3510,30 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -3647,10 +3673,9 @@
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -3667,7 +3692,7 @@
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
@@ -3752,9 +3777,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001559",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001559.tgz",
"integrity": "sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==",
"version": "1.0.30001726",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"dev": true,
"funding": [
{
@@ -3769,7 +3794,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/case-sensitive-paths-webpack-plugin": {
"version": "2.4.0",
@@ -4283,6 +4309,15 @@
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -4620,6 +4655,15 @@
"postcss": "^8.2.15"
}
},
"node_modules/cssnano/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/csso": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz",
@@ -4632,6 +4676,11 @@
"node": ">=8.0.0"
}
},
"node_modules/csv-parse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz",
"integrity": "sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA=="
},
"node_modules/d3": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.3.0.tgz",
@@ -6448,7 +6497,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -11341,12 +11389,11 @@
"dev": true
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
"engines": {
"node": ">= 6"
"node": ">= 14"
}
},
"node_modules/yargs": {
@@ -13920,8 +13967,7 @@
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"batch": {
"version": "0.6.1",
@@ -13950,6 +13996,18 @@
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
},
"dependencies": {
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
}
}
},
"bluebird": {
@@ -14060,13 +14118,12 @@
}
},
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"buffer-from": {
@@ -14133,9 +14190,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001559",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001559.tgz",
"integrity": "sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==",
"version": "1.0.30001726",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"dev": true
},
"case-sensitive-paths-webpack-plugin": {
@@ -14518,6 +14575,14 @@
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"dependencies": {
"yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true
}
}
},
"cross-spawn": {
@@ -14710,6 +14775,14 @@
"cssnano-preset-default": "^5.2.14",
"lilconfig": "^2.0.3",
"yaml": "^1.10.2"
},
"dependencies": {
"yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true
}
}
},
"cssnano-preset-default": {
@@ -14765,6 +14838,11 @@
"css-tree": "^1.1.2"
}
},
"csv-parse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz",
"integrity": "sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA=="
},
"d3": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.3.0.tgz",
@@ -16108,8 +16186,7 @@
"ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"ignore": {
"version": "5.2.4",
@@ -19700,10 +19777,9 @@
"dev": true
},
"yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA=="
},
"yargs": {
"version": "16.2.0",

View File

@@ -8,7 +8,9 @@
},
"dependencies": {
"@mdi/font": "^7.2.96",
"buffer": "^6.0.3",
"core-js": "^3.6.5",
"csv-parse": "^5.5.2",
"d3": "^7.0.1",
"jwt-decode": "^3.0.0",
"leaflet": "^1.7.1",
@@ -24,7 +26,8 @@
"vue-debounce": "^2.6.0",
"vue-router": "^3.5.1",
"vuetify": "^2.5.0",
"vuex": "^3.6.2"
"vuex": "^3.6.2",
"yaml": "^2.3.4"
},
"devDependencies": {
"@babel/plugin-proposal-logical-assignment-operators": "^7.14.5",

View File

@@ -0,0 +1,85 @@
<template>
<v-dialog
v-model="dialogOpen"
@input="(e) => $emit('input', e)"
max-width="600"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-if="adminaccess"
title="Create a new project from scratch. Generally, it's preferable to clone an existing project (right-click → Clone)"
small
outlined
color="warning"
v-bind="attrs"
v-on="on"
>
<span>Create new project</span>
<v-icon right small>mdi-file-document-plus-outline</v-icon>
</v-btn>
</template>
<dougal-project-settings-name-id-geodetics
:value="newProjectDetails"
@input="save"
@close="dialogOpen = false"
>
</dougal-project-settings-name-id-geodetics>
</v-dialog>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalProjectSettingsNameIdGeodetics from '@/components/project-settings/name-id-geodetics'
export default {
name: 'DougalAppBarExtensionProjectList',
components: {
DougalProjectSettingsNameIdGeodetics
},
data() {
return {
dialogOpen: false,
newProjectDetails: {
name: null,
id: null,
epsg: null
}
};
},
computed: {
...mapGetters(["adminaccess"])
},
methods: {
async save (data) {
this.dialogOpen = false;
data.archived = true; // Make the project inactive to start with
console.log("POST the new project data");
console.log(data);
const init = {
method: "POST",
body: data
};
const cb = (err, res) => {
if (!err && res) {
console.log(res);
if (res.status == "201") {
// Redirect to new project settings page
const settingsUrl = `/projects/${data.id.toLowerCase()}/configuration`;
this.$router.push(settingsUrl);
}
}
};
await this.api(["/project", init, cb]);
},
...mapActions(["api"])
}
}
</script>

View File

@@ -1,6 +1,15 @@
<template>
<v-tabs :value="tab" show-arrows>
<v-tabs :value="tab" show-arrows v-if="page != 'configuration'">
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
<template v-if="adminaccess">
<v-spacer></v-spacer>
<v-tab :to="tabLink('configuration')" class="orange--text darken-3" title="Edit project settings"><v-icon small left color="orange darken-3">mdi-cog-outline</v-icon> Settings</v-tab>
</template>
</v-tabs>
<v-tabs optional :value="0" show-arrows align-with-title v-else>
<v-tab>Project settings</v-tab>
<v-spacer></v-spacer>
<v-tab :to="tabLink('summary')">Go to project</v-tab>
</v-tabs>
</template>
@@ -35,6 +44,7 @@ export default {
return this.tabs.findIndex(t => t.href == this.page);
},
...mapGetters(["adminaccess"])
},
methods: {

View File

@@ -0,0 +1,82 @@
<template>
<v-row
dense
no-gutters
align="center"
>
<v-col cols="1">
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip outlined label small :color="colour || getHSLColourFor(key)">{{name}}</v-chip>
</v-col>
<v-col cols="4">
<v-text-field
dense
label="Column"
type="number"
min="0"
clearable
:value="value.column"
@input="$emit('input', {...value, column: Number($event)})"
>
<template v-slot:append-outer>
<dougal-field-content-dialog
:readonly="readonly"
:value="value"
@input="$emit('input', $event)"
></dougal-field-content-dialog>
</template>
</v-text-field>
</v-col>
<v-col cols="1">
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
</style>
<script>
import { parse } from 'csv-parse/sync'
import { getHSLColourFor } from '@/lib/hsl'
import DougalFieldContentDialog from '../fields/field-content-dialog'
export default {
name: "DougalDelimitedStringDecoderField",
components: {
//DougalFixedStringDecoderField,
DougalFieldContentDialog
},
props: {
value: Object,
name: String,
colour: String,
readonly: Boolean,
},
data () {
return {
}
},
computed: {
},
watch: {
},
methods: {
getHSLColourFor: getHSLColourFor.bind(this),
},
}
</script>

View File

@@ -0,0 +1,366 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<v-container>
<dougal-delimited-string-decoder-field v-for="(field, key) in fields" :key="key"
:colour="getHSLColourFor(key)"
:readonly="readonly"
:name="key"
:value="fields[key]"
@input="$emit('update:fields', {...fields, [key]: $event})"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this property"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-delimited-string-decoder-field>
<v-row dense no-gutters v-if="editableFieldList && !readonly">
<v-col cols=6 offset=1>
<v-text-field
label="Add new field"
hint="Enter the name of a new field"
:error-messages="fieldNameErrors"
v-model="fieldName"
append-outer-icon="mdi-plus-circle"
@keydown.enter.prevent="addField"
>
<template v-slot:append-outer>
<v-icon
color="primary"
:disabled="fieldName && !!fieldNameErrors"
@click="addField"
>mdi-plus</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="6">
<v-combobox
label="Field delimiter"
hint="How are the fields separated from each other?"
:items="delimiters"
v-model="delimiter_"
></v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="6">
<v-text-field
class="ml-3"
label="Skip lines"
hint="This lets you to skip file headers if present"
type="number"
min="0"
:value.number="numberedLines"
@input="$emit('update:numbered-lines', Number($event))"
></v-text-field>
</v-col>
<v-col cols="6">
<v-checkbox
v-ripple
label="First non-skipped line are field names"
:value="headerRow"
@change="$emit('update:header-row', $event)"
></v-checkbox>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table dense>
<template v-slot:default>
<colgroup v-if="showLineNumbers">
<col class="line_no"/>
</colgroup>
<thead>
<tr>
<th class="line_no">
<v-simple-checkbox
off-icon="mdi-format-list-numbered"
title="Show line numbers"
v-model="showLineNumbers"
>
</v-simple-checkbox>
</th>
<th v-for="(header, idx) in headers" :key="idx"
:style="`color:${header.colour};`"
>
<v-select
dense
clearable
:items="fieldsAvailableFor(idx)"
:value="header.fieldName"
@input="fieldSelected(idx, $event)"
>
</v-select>
</th>
</tr>
<tr>
<th class="line_no">
<small v-if="showLineNumbers && headers.length">Line no.</small>
</th>
<th v-for="(header, idx) in headers" :key="idx"
:style="`color:${header.colour};`"
>
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ridx) in rows" :key="ridx">
<td class="line_no"">
<small v-if="showLineNumbers">
{{ ridx + (typeof numberedLines == "number" ? numberedLines : 0)+1 }}
</small>
</td>
<td v-for="(cell, cidx) in row" :key="cidx"
:style="`background-color:${cell.colour};`"
>
{{ cell.text }}
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
/*.v-data-table table tbody tr td*/
th {
border: 1px solid hsl(0, 0%, 33.3%);
}
td {
border-inline: 1px solid hsl(0, 0%, 33.3%);
}
.line_no {
text-align: right;
width: 4ex;
border: none !important;
}
</style>
<script>
import { parse } from 'csv-parse/sync'
import { getHSLColourFor } from '@/lib/hsl'
import truncateText from '@/lib/truncate-text'
import DougalDelimitedStringDecoderField from './delimited-string-decoder-field'
export default {
name: "DougalDelimitedStringDecoder",
components: {
DougalDelimitedStringDecoderField
},
props: {
text: String,
fields: Object,
delimiter: String,
headerRow: { type: [ Boolean, Number ], default: false},
numberedLines: [ Boolean, Number ],
maxHeight: String,
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The name of a new field to add.
fieldName: "",
showLineNumbers: null,
delimiters: [
{ text: "Comma (,)", value: "," },
{ text: "Tabulator (⇥)", value: "\x09" },
{ text: "Semicolon (;)", value: ";" }
]
}
},
computed: {
/** The index of the last column.
*
* This will be the higher of the number of columns available
* in the sample text or the highest column number defined in
* this.fields.
*
* NOTE: May return NaN
*/
numberOfColumns () {
const lastIndex = Object.values(this.fields)
.reduce( (acc, cur) => Math.max(acc, cur.column), this.cells[0]?.length-1);
return isNaN(lastIndex) ? 0 : (lastIndex + 1);
},
cells () {
return parse(this.text_, {delimiter: this.delimiter, trim: true});
},
headers () {
const headerNames = typeof this.headerRow == "number"
? this.cells[this.headerRow]
: this.headerRow === true
? this.cells[0]
: Array.from(this.cells[0] ?? [], (_, ι) => `Column ${ι}`);
return headerNames?.map((c, ι) => {
const fieldName = Object.keys(this.fields).find(i => this.fields[i].column == ι);
const field = this.fields[fieldName] ?? {}
const colour = this.headerRow === false
? this.getHSLColourFor(ι*10)
: this.getHSLColourFor(c);
return {
text: c,
colour: this.getHSLColourFor(c),
fieldName,
field
} ?? {}
}) ?? [];
},
rows () {
// NOTE It doesn't matter if headerRow is boolean, it works just the same.
return [...this.cells].slice(this.headerRow).map(r =>
r.map( (c, ι) => ({
text: truncateText(c),
colour: this.headers.length
? this.getHSLColourFor(this.headers[ι]?.text, 0.2)
: this.getHSLColourFor(ι*10, 0.2)
})));
},
fieldNameErrors () {
return Object.keys(this.fields).includes(this.fieldName)
? "A field with this name already exists"
: null;
},
delimiter_: {
get () {
return this.delimiters.find(i => i.value == this.delimiter) ?? this.delimiter;
},
set (v) {
this.$emit("update:delimiter", typeof v == "object" ? v.value : v);
}
}
},
watch: {
text () {
if (this.text != this.text_) {
this.reset();
}
},
numberedLines (cur, prev) {
if (cur != prev) {
this.showLineNumbers = typeof cur == "number" || cur;
}
}
},
methods: {
fieldsAvailableFor (idx) {
return Object.keys(this.fields).filter( i =>
this.fields[i].column === idx || this.fields[i].column === null) ?? [];
},
fieldSelected (col, key) {
const fields = {};
for (const k in this.fields) {
const field = {...this.fields[k]};
if (k === key) {
field.column = col
} else {
if (field.column === col) {
field.column = null;
}
}
fields[k] = field;
}
this.$emit("update:fields", fields);
},
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { column: null }
});
this.fieldName = "";
}
},
removeField (key) {
const fields = {...this.fields};
delete fields[key];
this.$emit("update:fields", fields);
},
getHSLColourFor: getHSLColourFor.bind(this),
numberLine (number, line) {
return `<span class="line-number">${number}</span>${line}`;
},
reset () {
this.text_ = this.text.replaceAll("\r", "");
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<v-row dense no-gutters>
<v-col cols="1">
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip outlined label small :color="colour">{{name}}</v-chip>
</v-col>
<v-col cols="2">
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
v-model.number="value.offset"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col cols="2">
<v-text-field
class="ml-3"
dense
label="Length"
type="number"
min="0"
v-model.number="value.length"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col cols="2">
<dougal-field-content-dialog
:readonly="readonly"
:value="value"
@input="$emit('input', $event)"
></dougal-field-content-dialog>
</v-col>
<v-col cols="1">
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input >>> .chunk-empty {
padding-inline: 1px;
}
.input >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
import DougalFieldContentDialog from '../fields/field-content-dialog'
export default {
name: "DougalFixedStringDecoderField",
components: {
DougalFieldContentDialog
},
props: {
value: Object,
name: String,
colour: String,
readonly: Boolean,
},
data () {
return {
name_: "",
}
},
computed: {
},
watch: {
name () {
if (this.name != this.name_) {
this.name_ = this.name;
}
},
},
methods: {
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { offset: 0, length: 0 }
});
this.fieldName = "";
}
},
reset () {
this.text_ = this.text;
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,486 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<div v-if="isMultiline"
class="multiline mb-5"
:style="multilineElementStyle"
v-html="html"
>
</div>
<v-input v-else
class="v-text-field"
:hint="hint"
persistent-hint
v-model="text_"
>
<label
class="v-label"
:class="[ $vuetify.theme.isDark && 'theme--dark', text_ && text_.length && 'v-label--active' ]"
style="left: 0px; right: auto; position: absolute;"
>{{ label }}</label>
<div class="input"
:class="isMultiline ? 'multiline' : ''"
v-html="html"
>
</div>
</v-input>
<v-container>
<!-- Variable fields -->
<v-row no-gutters class="mb-2">
<h4>Variable fields</h4>
</v-row>
<dougal-fixed-string-decoder-field v-for="(field, key) in fields" :key="key"
v-model="fields[key]"
:name="key"
:colour="getHSLColourFor(key)"
:readonly="readonly"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this property"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-decoder-field>
<v-row dense no-gutters v-if="editableFieldList && !readonly">
<v-col cols="3">
<v-text-field
label="Add new field"
hint="Enter the name of a new field"
:error-messages="fieldNameErrors"
v-model="fieldName"
append-outer-icon="mdi-plus-circle"
@keydown.enter.prevent="addField"
>
<template v-slot:append-outer>
<v-icon
color="primary"
:disabled="fieldName && !!fieldNameErrors"
@click="addField"
>mdi-plus</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<!-- Fixed text strings -->
<v-row no-gutters class="mt-2 mb-2">
<h4>Fixed strings</h4>
</v-row>
<dougal-fixed-string-text v-for="(item, idx) in fixed" :key="idx"
v-model="fixed[idx]"
:colour="getHSLColourFor(item.text+item.offset)"
:readonly="readonly"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this property"
>
<v-icon
color="error"
@click="removeFixed(idx)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-text>
<v-row dense no-gutters v-if="editableFieldList && !readonly">
<v-col cols="3">
<v-text-field
label="Add fixed text"
hint="Enter text"
:error-messages="fieldNameErrors"
v-model="fixedName"
@keydown.enter.prevent="addFixed"
>
</v-text-field>
</v-col>
<v-col cols="3">
<v-text-field
class="ml-3"
label="From position"
hint="Enter offset"
type="number"
min="0"
v-model.number="fixedOffset"
:readonly="readonly"
append-outer-icon="mdi-plus-circle"
>
<template v-slot:append-outer>
<v-icon
color="primary"
:disabled="!fixedName"
@click="addFixed"
>mdi-plus</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.multiline {
font-family: mono;
white-space: pre;
overflow-x: auto;
overflow-y: auto;
}
.multiline >>> .line-number {
display: inline-block;
font-size: 75%;
width: 5ex;
margin-inline-end: 1ex;
text-align: right;
border: none;
position: relative;
top: -1px;
}
.input, .multiline >>> .chunk-field {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-fixed {
padding-inline: 1px;
border: 1px dashed;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
.input, .multiline >>> .chunk-mismatch {
padding-inline: 1px;
border: 2px solid red !important;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
import DougalFixedStringDecoderField from './fixed-string-decoder-field'
import DougalFixedStringText from './fixed-string-text'
export default {
name: "DougalFixedStringDecoder",
components: {
DougalFixedStringDecoderField,
DougalFixedStringText
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
text: { type: String, default: "" },
fixed: { type: Array, default: () => [] },
fields: { type: Object, default: () => ({}) },
multiline: Boolean,
numberedLines: [ Boolean, Number ],
maxHeight: String,
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String,
label: String,
hint: String,
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The value of a fixed string that should be always present at a specific position
fixedName: "",
fixedOffset: 0,
//< The name of a new field to add.
fieldName: ""
}
},
computed: {
/** Whether to treat the sample text as multiline.
*/
isMultiline () {
return this.multiline === true || this.text.includes("\n");
},
/* Return the fields as an array sorted by offset
*/
parts () {
// return Object.entries(this.fields).sort( (a, b) => a[1].offset - b[1].offset );
return [
...Object.entries(this.fields),
...this.fixed.map(i => [ i.text + i.offset, {...i, length: i.text?.length} ])
].sort( (a, b) => {
const offset_a = a.offset ?? a[1].offset;
const offset_b = b.offset ?? b[1].offset;
return a - b;
})
},
/* Transform this.parts into {start, end} intervals.
*/
chunks () {
const chunks = [];
const chunk_num = 0;
for (const [name, part] of this.parts) {
const chunk = {};
chunk.start = part.offset;
chunk.end = part.offset + part.length - 1;
//chunk.text = this.text_.slice(chunk.start, chunk.end);
chunk.colour = this.getHSLColourFor(name)
chunk.class = part.text ? "fixed" : "field";
chunk.text = part.text;
chunks.push(chunk);
}
return chunks;
},
multilineElementStyle () {
if (this.maxHeight) {
return `max-height: ${this.maxHeight};`;
}
return "";
},
/** Return a colourised HTML version of this.text.
*/
html () {
if (!this.text_) {
return;
}
if (this.isMultiline) {
if (typeof this.numberedLines == "number" || this.numberedLines) {
const offset = typeof this.numberedLines == "number" ? Math.abs(this.numberedLines) : 0;
return this.text_.split("\n").map( (line, idx) =>
this.numberLine(offset+idx, this.renderTextLine(line))).join("<br/>");
} else {
return this.text_.split("\n").map(this.renderTextLine).join("<br/>");
}
} else {
return this.renderTextLine(this.text_);
}
},
fieldNameErrors () {
return this.parts.find( i => i[0] == this.fieldName )
? "A field with this name already exists"
: null;
}
},
watch: {
text () {
if (this.text != this.text_) {
this.reset();
}
}
},
methods: {
addFixed () {
if (this.fixedName) {
const fixed = [
...this.fixed,
{ text: this.fixedName, offset: this.fixedOffset }
];
fixed.sort( (a, b) => a.offset - b.offset );
this.fixedName = null;
this.fixedOffset = 0;
this.$emit("update:fixed", fixed);
}
},
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { offset: 0, length: 0 }
});
this.fieldName = "";
}
},
// NOTE Not used
updateField (field, key, value) {
const fields = {
...this.fields,
[field]: {
...this.fields[field],
[key]: value
}
};
this.$emit("update:fields", fields);
},
removeField (key) {
const fields = {...this.fields};
delete fields[key];
this.$emit("update:fields", fields);
},
removeFixed (idx) {
const fixed = [...this.fixed];
fixed.splice(idx, 1);
//fixed.sort( (a, b) => a.offset - b.offset );
this.$emit("update:fixed", fixed);
},
/** Return an HSL colour as a function of an input value
* `str`.
*/
xgetHSLColourFor () {
console.log("WILL BE DEFINED ON MOUNT");
},
/** Return a `<span>` opening tag.
*/
style (name, colour) {
return colour
? `<span class="${name}" style="color:${colour};border-color:${colour}">`
: `<span class="${name}">`;
},
/** Return an array of the intervals that intersect `pos`.
* May be empty.
*/
chunksFor (pos) {
return this.chunks.filter( chunk =>
pos >= chunk.start &&
pos <= chunk.end
)
},
/*
* Algorithm:
*
* Go through every character of one line of text and determine in which
* part(s) it falls in, if any. Collect adjacent same parts into <span/>
* elements.
*/
renderTextLine (text) {
const parts = [];
let prevStyle;
for (const pos in text) {
const chunks = this.chunksFor(pos);
const isEmpty = chunks.length == 0;
const isOverlap = chunks.length > 1;
const isMismatch = chunks[0]?.text &&
(text.substring(chunks[0].start, chunks[0].end+1) != chunks[0].text)
const style = isEmpty
? this.style("chunk-empty")
: isMismatch
? this.style("chunk-mismatch", chunks[0].colour)
: isOverlap
? this.style("chunk-overlap")
: this.style("chunk-"+chunks[0].class, chunks[0].colour);
if (style != prevStyle) {
if (prevStyle) {
parts.push("</span>");
}
parts.push(style);
}
parts.push(text[pos]);
prevStyle = style;
}
if (parts.length) {
parts.push("</span>");
}
return parts.join("");
},
numberLine (number, line) {
return `<span class="line-number">${number}</span>${line}`;
},
setText (v) {
//console.log(v);
this.text_ = v;
},
reset () {
this.text_ = this?.text.replaceAll("\r", "");
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<v-row dense no-gutters>
<v-col cols="1">
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip outlined label small :color="colour" style="border: 1px dashed">{{value.text}}</v-chip>
</v-col>
<v-col cols="2">
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
v-model.number="value.offset"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col cols="2">
</v-col>
<v-col cols="2">
</v-col>
<v-col cols="1">
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input >>> .chunk-empty {
padding-inline: 1px;
}
.input >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
export default {
name: "DougalFixedStringText",
components: {
},
props: {
value: Object,
colour: String,
readonly: Boolean,
},
data () {
return {
name_: "",
}
},
computed: {
},
watch: {
name () {
if (this.name != this.name_) {
this.name_ = this.name;
}
},
},
methods: {
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { offset: 0, length: 0 }
});
this.fieldName = "";
}
},
reset () {
this.text_ = this.text;
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,301 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-tabs v-model="viewTab">
<v-tab>Text</v-tab>
<v-tab>Parsed</v-tab>
</v-tabs>
<v-tabs-items v-model="viewTab">
<v-tab-item>
<v-simple-table dense class="text">
<template v-slot:default>
<colgroup v-if="showLineNumbers">
<col class="line_no"/>
</colgroup>
<thead>
<tr>
<th class="line_no">
<v-simple-checkbox
v-ripple
off-icon="mdi-format-list-numbered"
title="Show line numbers"
v-model="showLineNumbers"
>
</v-simple-checkbox>
</th>
<th v-for="(header, idx) in headers" :key="idx"
:style="`color:${header.colour};`"
>
{{ header.text }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ridx) in rows" :key="ridx">
<td class="line_no"">
<small v-if="showLineNumbers">
{{ ridx + (typeof numberedLines == "number" ? numberedLines : 0)+1 }}
</small>
</td>
<td v-for="(cell, cidx) in row" :key="cidx"
:style="`background-color:${cell.colour};`"
>
{{ cell.text }}
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-tab-item>
<v-tab-item>
<!-- Parsed view -->
<v-simple-table dense class="parsed">
<template v-slot:default>
<thead>
<tr>
<th
title="The line along which the vessel will nominally sail"
>Sail line</th>
<th
title="Whether the line will be acquired in the incrementing or decrementing shot points direction"
>Direction</th>
<th
title="Whether the line is planned to be acquired. Some lines may be in the preplot but not intended to be shot in a particular campaign"
>Acquire?</th>
<th
title="The source lines that will be shot from this vessel line. Typically there is one source line per source array."
>Source lines</th>
<th
title="Any general remarks concerning this sail line (supports Markdown)"
>Remarks</th>
</tr>
</thead>
<tbody>
<tr v-for="(line, line_no) in saillines" :key="line_no">
<td>{{ line_no }}</td>
<td v-if="line.incr" title="Incrementing"></td>
<td v-else title="Decrementing"></td>
<td v-if="line.ntba" title="Not to be acquired" class="ko"></td>
<td v-else title="Line acquisition planned" class="ok"></td>
<td v-html="line.source_line.join('<br/>')"></td>
<td v-if="line['meta.colour']"
:style="`background-color:${line['meta.colour']};`"
v-html="$options.filters.markdown(line.remarks)"></td>
<td v-else
v-html="$options.filters.markdown(line.remarks)"></td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-tab-item>
</v-tabs-items>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
/*.v-data-table table tbody tr td*/
.text th {
border: 1px solid hsl(0, 0%, 33.3%);
}
.text td {
border-inline: 1px solid hsl(0, 0%, 33.3%);
}
.parsed td {
vertical-align: top;
}
.line_no {
text-align: right;
width: 4ex;
border: none !important;
}
.ok {
color: green;
}
.ko {
color: red;
}
</style>
<script>
import { parse } from 'csv-parse/sync'
import { getHSLColourFor } from '@/lib/hsl'
import truncateText from '@/lib/truncate-text'
export default {
name: "DougalSaillinesStringDecoder",
components: {
},
props: {
text: String,
//fields: Object,
//delimiter: String,
headerRow: { type: [ Boolean, Number ], default: false},
numberedLines: [ Boolean, Number ],
maxHeight: String,
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String
},
data () {
return {
delimiter: ",",
showLineNumbers: null,
text_: "",
viewTab: null
}
},
computed: {
cells () {
return parse(this.text_, {delimiter: this.delimiter, trim: true});
},
headers () {
return this.cells[0]?.map(cell => ({
text: cell,
colour: this.getHSLColourFor(cell),
backgroundColour: this.getHSLColourFor(cell, 0.2),
})) ?? [];
},
rows () {
return [...this.cells].slice(1).map(r =>
r.map( (c, ι) => ({
text: truncateText(c),
colour: this.headers[ι]?.backgroundColour
})));
},
/*
* A saillines object looks like:
*
* {
* [sail_line]: {
* incr: true, // or false
* ntba: true, // or false
* remarks: "",
* source_line: [ 1000, 1001, …],
* "meta.colour": ""
* },
* …
* }
*/
saillines () {
// Return an array of the column numbers
// corresponding to `key`.
// This file accepts duplicate column numbers,
// notably for `source_line`.
const key_indices = (key) =>
this.headers.reduce( (acc, cur, ι) => {
if (cur.text == key) {
acc.push(ι)
}
return acc;
}, []);
// Properties of the sailline object
const keys = [ "incr", "ntba", "remarks", "source_line", "meta.colour" ];
function to_bool (v, missing=false) {
return (v === undefined || v === null)
? missing // Missing value meaning
: /^t(rue)|^[1-9-]+$/i.test(String(v).trim())
}
// To transform the input text into the required format for each field
const transformer = (key) => {
const transformers = {
incr: (v) => to_bool(v, true),
ntba: (v) => to_bool(v, false),
remarks: (v) => (v === undefined || v === null) ? "" : String,
source_line: Number,
};
return transformers[key] ?? String;
};
// This is the saillines object
const lines = {};
// The column numbers for each property
const columns = keys.map( k => [ k, key_indices(k) ] );
// The column number for the sail_line property, which
// we use as a key.
const sail_line_idx = key_indices("sail_line")[0];
// Transform each line in the input file into a
// sailline object (just for display purposes,
// this is not exactly how the server will do it).
for (const row of this.rows) {
const sail_line = row[sail_line_idx]?.text;
const values = columns.map(i => [
i[0],
i[0] == "source_line"
? i[1].map(idx => transformer(i[0])(row[idx]?.text))
: transformer(i[0])(row[i[1][0]]?.text)
]);
lines[sail_line] = Object.fromEntries(values);
}
return lines;
}
},
watch: {
text () {
if (this.text != this.text_) {
this.reset();
}
},
numberedLines (cur, prev) {
if (cur != prev) {
this.showLineNumbers = typeof cur == "number" || cur;
}
}
},
methods: {
getHSLColourFor: getHSLColourFor.bind(this),
numberLine (number, line) {
return `<span class="line-number">${number}</span>${line}`;
},
reset () {
this.text_ = this.text.replaceAll("\r", "");
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,269 @@
<template>
<v-row dense no-gutters>
<v-col>
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip v-if="value.item && !readonly"
outlined
label
small
:color="colour"
:title="description"
>{{name}}</v-chip>
<v-select v-else-if="items.length && !readonly"
label="Item"
:items=items
v-model="value.item"
dense
title="Select an item to use as a field"
></v-select>
</v-col>
<v-col>
<v-select v-if="type == 'boolean'"
label="Condition"
:items="[true, false]"
v-model="value.when"
dense
title="Use this configuration only when the value of this item matches the selected state. This allows the user to configure different values for true and false conditions."
></v-select>
</v-col>
<v-col>
<v-text-field v-if="type == 'boolean' || type == 'text'"
class="ml-3"
dense
label="Value"
v-model="value.value"
title="This literal text will be inserted at the designated position"
></v-text-field>
<v-menu v-else-if="type == 'number'"
max-width="600"
:close-on-content-click="false"
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-chip
class="ml-3"
small
:light="$vuetify.theme.isDark"
:dark="!$vuetify.theme.isDark"
:color="value.scale_offset != null || value.scale_multiplier != null ? 'primary' : ''"
:title="`Number scaling${ value.scale_offset != null ? ('\nOffset: ' + value.scale_offset) : '' }${ value.scale_multiplier != null ? ('\nMultiplier: ' + value.scale_multiplier) : ''}`"
v-bind="attrs"
v-on="on"
>
<v-icon small>mdi-ruler</v-icon>
</v-chip>
</template>
<v-card rounded outlined>
<v-card-text>
<v-row dense no-gutters>
<v-text-field
type="number"
dense
clearable
label="Offset"
title="Offset the value by this amount (after scaling)"
v-model.number="value.scale_offset"
></v-text-field>
</v-row>
<v-row dense no-gutters>
<v-text-field
type="number"
dense
clearable
label="Scale"
title="Mutiply the value by this amount (before scaling)"
v-model.number="value.scale_multiplier"
></v-text-field>
</v-row>
</v-card-text>
</v-card>
</v-menu>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
v-model.number="value.offset"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="Length"
type="number"
min="0"
v-model.number="value.length"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col>
<v-menu v-if="value.length > 1"
max-width="600"
:close-on-content-click="false"
offset-y
:disabled="!(value.length>1)"
>
<template v-slot:activator="{ on, attrs }">
<v-chip
class="ml-3"
small
:light="$vuetify.theme.isDark"
:dark="!$vuetify.theme.isDark"
title="Text alignment"
v-bind="attrs"
v-on="on"
:disabled="!(value.length>1)"
>
<v-icon small v-if="value.pad_side=='right'">mdi-format-align-left</v-icon>
<v-icon small v-else-if="value.pad_side=='left'">mdi-format-align-right</v-icon>
<v-icon small v-else>mdi-format-align-justify</v-icon>
</v-chip>
</template>
<v-card rounded outlined>
<v-card-text>
<v-row dense no-gutters>
<v-select
label="Alignment"
clearable
:items='[{text:"Left", value:"right"}, {text:"Right", value:"left"}]'
v-model="value.pad_side"
></v-select>
</v-row>
<v-row dense no-gutters v-if="value.pad_side">
<v-text-field
dense
label="Pad character"
title="Fill the width of the field on the opposite side by padding with this character"
v-model="value.pad_string"
></v-text-field>
</v-row>
</v-card-text>
</v-card>
</v-menu>
</v-col>
<v-col>
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input >>> .chunk-empty {
padding-inline: 1px;
}
.input >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
export default {
name: "DougalFixedStringEncoderField",
components: {
},
props: {
value: Object,
properties: Object,
colour: String,
readonly: Boolean,
},
data () {
return {
}
},
watch: {
"value.value": function (value, old) {
if (value != null && String(value).length > this.value.length) {
this.value.length = String(value).length;
}
}
},
computed: {
field: {
get () {
return this.value;
},
set (v) {
console.log("input", v);
this.$emit("input", v);
}
},
item () {
return this.properties?.[this.value?.item] ?? {};
},
items () {
return Object.entries(this.properties).map(i => ({text: i[1].summary ?? i[0], value: i[0]}))
},
name () {
// TODO Use properties[item].summary or similar
return this.item?.summary ?? this.value.item ?? "???";
},
type () {
return this.item?.type ?? typeof this.value?.item ?? "undefined";
},
description () {
return this.item?.description;
}
},
methods: {
reset () {
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,351 @@
<template>
<v-input
class="v-text-field"
:hint="hint"
persistent-hint
:value="text"
>
<label
class="v-label"
:class="[ $vuetify.theme.isDark && 'theme--dark', text && text.length && 'v-label--active' ]"
style="left: 0px; right: auto; position: absolute;"
>{{ label }}</label>
<div class="input" slot="default"
v-html="html"
>
</div>
<template slot="append">
<v-menu
scrollable
offset-y
:close-on-content-click="false"
>
<template v-slot:activator="{on, attrs}">
<v-btn
icon
v-bind="attrs"
v-on="on"
>
<v-icon title="Configure sample values">mdi-list-box-outline</v-icon>
</v-btn>
</template>
<v-card>
<v-card-title>Sample values</v-card-title>
<v-card-subtitle>Enter sample values to test your configuration</v-card-subtitle>
<v-divider></v-divider>
<v-card-text>
<v-container>
<v-row v-for="(prop, key) in properties" :key="key">
<template v-if="prop.type == 'boolean'">
<v-col cols="6" align-self="center">
<v-chip
outlined
label
small
:color="getHSLColourFor(key)"
:title="prop.description"
>{{prop.summary || key}}</v-chip>
</v-col>
<v-col cols="6" align-self="center">
<v-simple-checkbox v-model="values[key]"></v-simple-checkbox>
</v-col>
</template>
<template v-else-if="key != 'text'">
<v-col cols="6" align-self="center">
<v-chip
outlined
label
small
:color="getHSLColourFor(key)"
:title="prop.description"
>{{prop.summary || key}}</v-chip>
</v-col>
<v-col cols="6" align-self="center">
<v-text-field v-if="prop.type == 'number'"
:type="prop.type"
:label="prop.summary || key"
:hint="prop.description"
v-model.number="values[key]"
></v-text-field>
<v-text-field v-else
:type="prop.type"
:label="prop.summary || key"
:hint="prop.description"
v-model="values[key]"
></v-text-field>
</v-col>
</template>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-menu>
</template>
<v-icon slot="prepend">mdi-list</v-icon>
</v-input>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
white-space-collapse: preserve;
}
.multiline {
font-family: mono;
white-space: pre;
overflow-x: auto;
overflow-y: auto;
}
.multiline >>> .line-number {
display: inline-block;
font-size: 75%;
width: 5ex;
margin-inline-end: 1ex;
text-align: right;
border: none;
position: relative;
top: -1px;
}
.input, .multiline >>> .chunk-field {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-fixed {
padding-inline: 1px;
border: 1px dashed;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
.input >>> .chunk-mismatch {
padding-inline: 1px;
border: 2px solid red !important;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
export default {
name: "DougalFixedStringEncoderSample",
components: {
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
properties: { type: Object, default: () => ({}) },
fields: { type: Array, default: () => [] },
values: { type: Object, default: () => ({}) },
readonly: Boolean,
label: String,
hint: String,
},
data () {
return {
}
},
computed: {
chunks () {
const properties = this.properties;
const fields = this.fields;
const values = this.values;
const str = "";
const chunks = [];
for (const field of fields) {
const value = this.fieldValue(properties, field, values);
if (value != null) {
const chunk = {
start: field.offset,
end: field.offset + field.length - 1,
colour: this.getHSLColourFor(field.item),
class: field.item == "text" ? "fixed" : "field",
text: value
}
chunks.push(chunk);
}
}
return chunks;
},
text () {
return this.sample(this.properties, this.fields, this.values);
},
html () {
return this.renderTextLine(this.text);
}
},
watch: {
},
methods: {
fieldValue (properties, field, values) {
let value;
if (field.item == "text") {
value = field.value;
} else if (properties[field.item]?.type == "boolean") {
if (values[field.item] === field.when) {
value = field.value;
}
} else {
value = values[field.item];
}
if (value != null) {
if (properties[field.item]?.type == "number") {
if (field.scale_multiplier != null) {
value *= field.scale_multiplier;
}
if (field.scale_offset != null) {
value += field.scale_offset;
}
if (field.format == "integer") {
value = Math.round(value);
}
}
value = String(value);
if (field.pad_side == "left") {
value = value.padStart(field.length, field.pad_string ?? " ");
} else if (field.pad_side == "right") {
value = value.padEnd(field.length, field.pad_string ?? " ");
}
return value;
}
},
sample (properties, fields, values, str = "") {
const length = fields.reduce( (acc, cur) => (cur.offset + cur.length) > acc ? (cur.offset + cur.length) : acc, str.length )
str = str.padEnd(length);
for (const field of fields) {
//console.log("FIELD", field);
const value = this.fieldValue(properties, field, values);
if (value != null) {
str = str.slice(0, field.offset) + value + str.slice(field.offset + field.length);
}
}
return str;
},
/** Return a `<span>` opening tag.
*/
style (name, colour) {
return colour
? `<span class="${name}" style="color:${colour};border-color:${colour}">`
: `<span class="${name}">`;
},
/** Return an array of the intervals that intersect `pos`.
* May be empty.
*/
chunksFor (pos) {
return this.chunks.filter( chunk =>
pos >= chunk.start &&
pos <= chunk.end
)
},
/*
* Algorithm:
*
* Go through every character of one line of text and determine in which
* part(s) it falls in, if any. Collect adjacent same parts into <span/>
* elements.
*/
renderTextLine (text) {
const parts = [];
let prevStyle;
for (const pos in text) {
const chunks = this.chunksFor(pos);
const isEmpty = chunks.length == 0;
const isOverlap = chunks.length > 1;
const isMismatch = chunks[0]?.text &&
(text.substring(chunks[0].start, chunks[0].end+1) != chunks[0].text);
const style = isEmpty
? this.style("chunk-empty")
: isMismatch
? this.style("chunk-mismatch", chunks[0].colour)
: isOverlap
? this.style("chunk-overlap")
: this.style("chunk-"+chunks[0].class, chunks[0].colour);
if (style != prevStyle) {
if (prevStyle) {
parts.push("</span>");
}
parts.push(style);
}
parts.push(text[pos]);
prevStyle = style;
}
if (parts.length) {
parts.push("</span>");
}
return parts.join("");
},
},
mounted () {
}
}
</script>

View File

@@ -0,0 +1,307 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<!-- Sample text -->
<dougal-fixed-string-encoder-sample
:label="label"
:hint="hint"
:properties="properties"
:fields="fields"
:values.sync="values"
></dougal-fixed-string-encoder-sample>
<!-- Fields -->
<v-container>
<v-row no-gutters class="mb-2">
<h4>Fields</h4>
</v-row>
<dougal-fixed-string-encoder-field v-for="(field, key) in fields" :key="key"
v-model="fields[key]"
:properties="properties"
:colour="getHSLColourFor(field.item)"
:readonly="readonly"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this field"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-encoder-field>
<v-row no-gutters class="mb-2" v-if="editableFieldList && !readonly">
<h4>Add new field</h4>
</v-row>
<dougal-fixed-string-encoder-field v-if="editableFieldList && !readonly"
v-model="newField"
:properties="properties"
:colour="getHSLColourFor(newField.item)"
>
<template v-slot:prepend>
<v-btn v-if="isFieldDirty(newField)"
top
text
small
title="Reset"
>
<v-icon
color="warning"
@click="resetField(newField)"
>mdi-backspace-reverse-outline</v-icon>
</v-btn>
</template>
<template v-slot:append>
<v-btn
class="ml-3"
fab
text
small
title="Add field"
:disabled="isFieldValid(newField) !== true"
>
<v-icon
color="primary"
@click="addField(newField)"
>mdi-plus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-encoder-field>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input, .multiline >>> .chunk-field {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-fixed {
padding-inline: 1px;
border: 1px dashed;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
.input >>> .chunk-mismatch {
padding-inline: 1px;
border: 2px solid red !important;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
import DougalFixedStringEncoderField from './fixed-string-encoder-field'
import DougalFixedStringEncoderSample from './fixed-string-encoder-sample'
export default {
name: "DougalFixedStringEncoder",
components: {
DougalFixedStringEncoderField,
DougalFixedStringEncoderSample
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
properties: { type: Object },
fields: { type: Array },
values: { type: Object },
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String,
label: String,
hint: String,
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The value of a fixed string that should be always present at a specific position
fixedName: "",
fixedOffset: 0,
//< The name of a new field to add.
fieldName: "",
newField: {
item: null,
when: null,
offset: null,
length: null,
value: null,
pad_side: null,
pad_string: null
}
}
},
computed: {
chunks () {
const properties = this.properties;
const fields = this.fields;
const values = this.values;
const str = "";
const chunks = [];
for (const field of fields) {
//console.log("FIELD", structuredClone(field));
//console.log("VALUES DATA", values[field.item]);
let value;
if (field.item == "text") {
value = field.value;
} else if (properties[field.item]?.type == "boolean") {
if (values[field.item] === field.when) {
value = field.value;
}
} else {
value = values[field.item];
}
if (value != null) {
value = String(value);
if (field.pad_side == "left") {
value = value.padStart(field.length, field.pad_string);
} else {
value = value.padEnd(field.length, field.pad_string);
}
const chunk = {
start: field.offset,
end: field.offset + field.length - 1,
colour: this.getHSLColourFor(field.item),
class: field.item == "text" ? "fixed" : "field",
text: value
}
//console.log("CHUNK", chunk);
chunks.push(chunk);
}
}
return chunks;
},
html () {
return this.renderTextLine(this.sample(this.properties, this.fields, this.values));
//return this.sample(this.properties, this.fields, this.values);
}
},
watch: {
},
methods: {
isFieldDirty (field) {
return Object.entries(field).reduce( (acc, cur) => cur[1] === null ? acc : true, false );
},
isFieldValid (field) {
if (!field.item) return "Missing item";
if (typeof field.offset !== "number" || field.offset < 0) return "Missing offset";
if (typeof field.length !== "number" || field.length < 1) return "Missing length";
if (!this.properties[field.item]) return "Unrecognised property";
if (this.properties[field.item].type == "text" && !field.value?.length) return "Missing value";
if (this.properties[field.item].type == "boolean" && !field.value?.length) return "Missing value (boolean)";
if(!!field.pad_side && !field.pad_string) return "Missing pad string";
return true;
},
resetField (field) {
field.item = null;
field.when = null;
field.offset = null;
field.length = null;
field.value = null;
field.pad_side = null;
field.pad_string = null;
return field;
},
addField (field) {
if (this.isFieldValid(field)) {
const fields = structuredClone(this.fields);
fields.push({...field});
this.resetField(field);
console.log("update:fields", fields);
this.$emit("update:fields", fields);
}
},
removeField (key) {
console.log("REMOVE", "update:fields", key, this.fields);
const fields = structuredClone(this.fields);
fields.splice(key, 1);
this.$emit("update:fields", fields);
},
},
mounted () {
}
}
</script>

View File

@@ -0,0 +1,109 @@
<template>
<v-dialog
max-width="600"
:close-on-content-click="false"
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-chip
class="ml-3"
small
:light="$vuetify.theme.isDark"
:dark="!$vuetify.theme.isDark"
:title="getFriendlyTypeName(value.type)"
:color="getHSLColourFor(value.type||'str', .4, .5)"
v-bind="attrs"
v-on="on"
>
<v-icon small>{{ getTypeIcon(value.type||'str') }}</v-icon>
<v-icon small v-if="value.enum"
:title="'Values: '+Object.entries(value.enum).map(i => `${i[0]}=${i[1]}`).join('; ')+'\nDefault: '+value.default"
>mdi-format-list-group</v-icon>
</v-chip>
</template>
<dougal-field-content
:readonly="readonly"
:value="value"
@input="$emit('input', $event)"
></dougal-field-content>
</v-dialog>
</template>
<script>
import DougalFieldContent from './field-content'
export default {
name: "DougalFieldContentDialog",
components: {
DougalFieldContent
},
props: {
value: Object,
readonly: Boolean
},
data () {
return {
};
},
computed: {
},
methods: {
getFriendlyTypeName (type) {
switch (type) {
case "str":
return "Text";
case "int":
return "Integer";
case "float":
return "Float";
case "bool":
return "Boolean";
default:
return type ?? "Text (default)";
}
},
getTypeIcon (type) {
switch (type) {
case "str":
return "mdi-format-text-variant";
case "int":
return "mdi-numeric";
case "float":
return "mdi-decimal";
case "bool":
return "mdi-format-list-checks";
default:
return "mdi-format-text";
}
},
getHSLColourFor (str, saturation = 1, lightness = 0.25, offset = 0) {
function getHash (v) {
return [...v].reduce( (acc, cur) => String(cur).charCodeAt(0) + ((acc << 5) - acc), 0 );
}
const h = (getHash(str) + offset) % 360;
const s = saturation * 100;
const l = this.$vuetify.theme.isDark
? (1-lightness) * 100
: lightness * 100;
return `hsl(${h},${s}%,${l}%)`;
}
}
}
</script>

View File

@@ -0,0 +1,242 @@
<template>
<v-card flat elevation="0">
<v-card-subtitle>Item options</v-card-subtitle>
<v-card-text>
<v-select
label="Value type"
v-model="type"
:items="types"
value="int"
:readonly="readonly"
></v-select>
<v-checkbox
label="Enumerated values"
v-model="enumerated"
:readonly="readonly"
></v-checkbox>
</v-card-text>
<template v-if="enumerated">
<v-card-subtitle>Valid options</v-card-subtitle>
<v-card-text>
<v-list dense>
<v-list-item v-for="(out, key) in value.enum" :key=key
>
<v-list-item-content class="mr-1">
<v-text-field
dense
hide-details="auto"
v-model="key"
:readonly="readonly"
></v-text-field>
</v-list-item-content>
<v-list-item-content class="ml-1">
<v-select v-if="type == 'bool'"
dense
hide-details="auto"
:items="[ true, false ]"
v-model="value.enum[key]"
:readonly="readonly"
></v-select>
<v-text-field v-else
dense
hide-details="auto"
v-model="value.enum[key]"
:readonly="readonly"
></v-text-field>
</v-list-item-content>
<v-list-item-action>
<v-icon
small
color="error"
:disabled="readonly"
@click="removeEnum(key)"
>mdi-minus-circle</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item v-if="!readonly"
>
<v-list-item-content class="mr-1">
<v-text-field
dense
hide-details="auto"
label="New input value"
v-model="newEnumKey"
></v-text-field>
</v-list-item-content>
<v-list-item-content class="ml-1">
<v-select v-if="type == 'bool'"
dense
hide-details="auto"
label="New output value"
:items="[ true, false ]"
v-model="newEnumValue"
></v-select>
<v-text-field v-else
dense
hide-details="auto"
label="New output value"
v-model="newEnumValue"
></v-text-field>
</v-list-item-content>
<v-list-item-action>
<v-icon
small
color="primary"
:disabled="!isNewEnumValid"
@click="addEnum"
>mdi-plus-circle</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-select v-if="type == 'bool'"
dense
hide-details="auto"
label="Default value"
hint="Value to use if none matches"
:items="[ true, false ]"
v-model="defaultValue"
:readonly="readonly"
></v-select>
<v-text-field v-else
label="Default value"
hint="Value to use if none matches"
persistent-hint
v-model="defaultValue"
:readonly="readonly"
></v-text-field>
</v-list-item-content>
<v-list-item-action>
<v-icon
small
color="secondary"
:disabled="readonly"
@click="defaultValue = null"
>mdi-backspace</v-icon>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card-text>
</template>
</v-card>
</template>
<script>
export default {
name: "DougalFieldContent",
props: {
value: Object,
readonly: Boolean
},
data () {
return {
newEnumKey: null,
newEnumValue: null,
types: [
{ text: "Text", value: "str" },
{ text: "Integer", value: "int" },
{ text: "Float", value: "float" },
{ text: "Boolean", value: "bool" },
]
}
},
computed: {
type: {
get () {
return this.value?.type ?? "str";
},
set (v) {
this.$emit("input", {
...this.value,
type: v
})
}
},
enumerated: {
get () {
return typeof this.value?.enum === "object";
},
set (v) {
if (v) {
this.$emit("input", {
enum: {},
...this.value
})
} else {
const obj = {...this.value};
delete obj.enum;
this.$emit("input", obj)
}
}
},
defaultValue: {
get () {
return this.value?.default;
},
set (v) {
this.$emit("input", {
...this.value,
"default": v
});
}
},
isNewEnumValid () {
return !!(this.newEnumKey &&
!Object.keys(this.value.enum).includes(this.newEnumKey) &&
(typeof this.newEnumValue == "boolean" || this.newEnumValue));
}
},
watch: {
},
methods: {
addEnum () {
this.$emit("input", {
...this.value,
enum: {
...this.value.enum,
[this.newEnumKey]: this.newEnumValue
}
});
this.newEnumKey = null;
this.newEnumValue = null;
},
removeEnum (key) {
const obj = {...this.value.enum};
delete obj[key];
this.$emit("input", {
...this.value,
enum: obj
});
}
},
mounted () {
}
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<v-dialog
max-width="600"
v-model="open"
>
<template v-slot:activator="{ on, attrs }">
<v-icon
v-bind="attrs"
v-on="on"
:title="title"
>mdi-folder-network-outline</v-icon>
</template>
<v-card>
<v-card-title>File picker</v-card-title>
<v-divider></v-divider>
<v-card-text>
<dougal-file-browser
v-model="selected"
:mimetypes="mimetypes"
:root="root"
ref="browser"
>
</dougal-file-browser>
</v-card-text>
<v-card-actions>
<v-btn text @click="save" :disabled="!selected">
<v-icon small flat color="primary" class="mr-2">mdi-content-save</v-icon>
Ok
</v-btn>
<v-spacer></v-spacer>
<v-btn text @click="refresh">
<v-icon small flat class="mr-2">mdi-reload</v-icon>
Refresh
</v-btn>
<v-spacer></v-spacer>
<v-btn text @click="close">
<v-icon small flat color="red" class="mr-2">mdi-close</v-icon>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import DougalFileBrowser from './file-browser';
export default {
name: "DougalFileBrowserDialog",
components: { DougalFileBrowser },
props: [ "path", "mimetypes", "root", "title" ],
data () {
return {
open: false,
selected: ""
}
},
methods: {
refresh () {
this.$refs.browser.refresh();
},
close () {
this.open = false;
},
save () {
this.$emit('input', this.selected);
this.close();
}
},
mounted () {
this.selected = this.path;
}
}
</script>

View File

@@ -0,0 +1,150 @@
<template>
<v-layout fill-height style="overflow-y:auto;max-height:400px;">
<v-progress-circular v-if="loading && !items.length"></v-progress-circular>
<v-treeview v-else
activatable
:active.sync="active"
:items="items"
item-key="path"
item-name="basename"
:load-children="readdir"
@update:active="activeChanged"
style="min-width:100%"
>
<template v-slot:label="{item}">
<div style="cursor:pointer;">
{{ item.basename }}
</div>
</template>
</v-treeview>
</v-layout>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
function find(haystack, needle) {
for (const item of haystack) {
if (item.path == needle) {
return item;
} else if (item.children) {
const found = find(item.children, needle);
if (found) {
return found;
}
}
}
}
export default {
name: "DougalFileBrowser",
props: [ "value", "mimetypes", "root" ],
data () {
return {
loading: false,
items: [],
active: [],
selected: null,
path: "",
};
},
computed: {
dirsAreSelectable () {
return !this.mimetypes ||
this.mimetypes == "inode/directory" ||
(Array.isArray(this.mimetypes) && this.mimetypes.includes("inode/directory"));
}
},
watch: {
},
methods: {
activeChanged (active) {
const candidate = find(this.items, active[0]);
if (!this.dirsAreSelectable && this.isDirectory(candidate)) {
this.selected = null;
} else {
this.selected = candidate;
}
this.$emit("input", this.selected?.path);
},
isDirectory (item) {
return item && item["Content-Type"] == "inode/directory";
},
filterMimetypes (item) {
if (!this.mimetypes) {
return true;
} else if (Array.isArray(this.mimetypes)) {
return item["Content-Type"] == "inode/directory" ||
this.mimetypes.includes(item["Content-Type"].split(";")[0]) ||
this.filterGlob(item);
} else {
return item["Content-Type"] == "inode/directory" ||
this.mimetypes == item["Content-Type"].split(";")[0];
}
return false;
},
filterGlob (item) {
const globs = (Array.isArray(this.mimetypes)
? this.mimetypes
: [ this.mimetypes ])
.filter(i => /^\*\..+$/.test(i));
for (const glob of globs) {
const ext = (glob.match(/^\*\.(.+)$/)||[])[1];
if (item.path.toLowerCase().endsWith(ext.toLowerCase())) {
return true;
}
}
return false;
},
async readdir (item) {
this.loading = true;
const url = `/files/${item? item.path : (this.root || this.path || "")}`;
const list = await this.api([url]);
this.loading = false;
const items = list?.map(item => {
if (item["Content-Type"] == "inode/directory") {
item.children = [];
}
item.id = item.path;
item.name = item.basename;
return item;
}).filter(this.filterMimetypes);
if (item) {
item.children = items;
} else {
this.items = items;
}
},
async refresh () {
this.items = []
this.$nextTick(this.readdir);
},
...mapActions(["api"])
},
mounted () {
if (this.value) {
this.path = this.value;
}
this.readdir();
}
}
</script>

View File

@@ -0,0 +1,557 @@
<template>
<v-container>
<v-row>
<v-treeview
dense
activatable
hoverable
:multiple-active="false"
:active.sync="active"
:open.sync="open"
:items="treeview"
style="cursor:pointer;width:100%;"
>
<template v-slot:prepend="{item}">
<template v-if="item.icon">
<v-icon
small
left
:title="item.leaf ? item.type : `${item.type} (${item.children.length} children)`"
>{{item.icon}}</v-icon>
</template>
</template>
<template v-slot:label="{item}">
<template v-if="!('path' in item)">
{{item.name}}
</template>
<template v-else-if="item.leaf">
<v-chip
small
label
outlined
:color="item.isArrayItem ? 'secondary' : 'primary'"
>
{{item.name}}
</v-chip>
<code class="ml-4" v-if="item.type == 'bigint'">{{item.value+"n"}}</code>
<code class="ml-4" v-else-if="item.type == 'boolean'"><b>{{item.value}}</b></code>
<code class="ml-4" v-else>{{item.value}}</code>
<v-icon v-if="item.type == 'string' && (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(item.value) || item.name == 'colour' || item.name == 'color')"
right
:color="item.value"
>mdi-square</v-icon>
</template>
<template v-else>
<v-chip
small
label
outlined
:color="item.isArrayItem ? 'secondary' : 'primary'"
>
{{item.name}}
</v-chip>
</template>
</template>
<template v-slot:append="{item}">
<template>
<v-icon v-if="item.type == 'array'"
small
right
outline
color="primary"
title="Add item"
@click="itemAddDialog(item)"
>mdi-plus</v-icon>
<v-icon v-if="item.type == 'object'"
small
right
outline
color="primary"
title="Add property"
@click="itemAddDialog(item)"
>mdi-plus</v-icon>
<v-icon v-if="item.type == 'boolean'"
small
right
outline
color="primary"
title="Toggle value"
@click="itemToggle(item)"
>{{ item.value ? "mdi-checkbox-blank-outline" : "mdi-checkbox-marked-outline" }}</v-icon>
<v-icon v-if="item.type == 'string' || item.type == 'number'"
small
right
outline
color="primary"
title="Edit value"
@click="itemAddDialog(item, true)"
>mdi-pencil-outline</v-icon>
<v-icon
small
right
outlined
color="red"
title="Delete"
:disabled="item.id == rootId"
@click="itemDelete(item)"
>mdi-minus</v-icon>
</template>
</template>
</v-treeview>
<dougal-json-builder-property-dialog
:open="editor"
v-model="edit"
v-bind="editorProperties"
@save="editorSave"
@close="editorClose"
></dougal-json-builder-property-dialog>
</v-row>
</v-container>
</template>
<script>
import { deepValue, deepSet } from '@/lib/utils';
import DougalJsonBuilderPropertyDialog from './property-dialog';
export default {
name: "DougalJsonBuilder",
components: {
DougalJsonBuilderPropertyDialog
},
props: {
value: Object,
name: String,
sort: String
},
data () {
const rootId = Symbol("rootNode");
return {
rootId,
active: [],
open: [ rootId ],
editor: false,
editorProperties: {
nameShown: true,
nameEditable: true,
typeShown: true,
typeEditable: true,
valueShown: true,
serialisable: true
},
onEditorSave: (evt) => {},
edit: {
name: null,
type: null,
value: null
}
};
},
computed: {
treeview () {
function sorter (key) {
return function λ (a, b) {
return a?.[key] > b?.[key]
? 1
: a?.[key] < b?.[key]
? -1
: 0;
}
}
function getType (value) {
const t = typeof value;
switch (t) {
case "symbol":
case "string":
case "bigint":
case "number":
case "boolean":
case "undefined":
return t;
case "object":
return value === null
? "null"
: Array.isArray(value)
? "array"
: t;
}
}
function getIcon (type) {
switch (type) {
case "symbol":
return "mdi-symbol";
case "string":
return "mdi-format-text";
case "bigint":
return "mdi-numeric";
case "number":
return "mdi-numeric";
case "boolean":
return "mdi-checkbox-intermediate-variant";
case "undefined":
return "mdi-border-none-variant";
case "null":
return "mdi-null";
case "array":
return "mdi-list-box-outline";
case "object":
return "mdi-format-list-bulleted-type";
}
return "mdi-help";
}
const leaf = ([key, value], parent) => {
const id = parent
? parent.id+"."+key
: key;
const name = key;
const type = getType(value);
const icon = getIcon(type);
const isArrayItem = parent?.type == "array";
const obj = {
id,
name,
type,
icon,
isArrayItem,
};
if (parent) {
obj.path = [...parent.path, key];
} else {
obj.path = [ key ];
}
if (type == "object" || type == "array") {
const children = [];
for (const child of Object.entries(value)) {
children.push(leaf(child, obj));
}
if (this.sort) {
children.sort(sorter(this.sort));
}
obj.children = children;
} else {
obj.leaf = true;
obj.value = value;
/*
obj.children = [{
id: id+".value",
name: String(value)
}]
*/
}
return obj;
}
const rootNode = {
id: this.rootId,
name: this.name,
type: getType(this.value),
icon: getIcon(getType(this.value)),
children: []
};
const view = [rootNode];
if (this.value) {
for (const child of Object.entries(this.value)) {
rootNode.children.push(leaf(child));
}
if (this.sort) {
rootNode.children.sort(sorter(this.sort));
}
}
return view;
}
},
watch: {
treeview () {
if (!this.open.includes(this.rootId)) {
this.open.push(this.rootId);
}
}
},
methods: {
openAll (open = true) {
const walk = (obj) => {
if (obj?.children) {
for (const child of obj.children) {
walk(child);
}
if (obj?.id) {
this.open.push(obj.id);
}
}
}
for (const item of this.treeview) {
walk (item);
}
},
itemDelete (item) {
const parents = [...item.path];
const key = parents.pop();
if (key) {
const value = structuredClone(this.value);
const obj = parents.length ? deepValue(value, parents) : value;
if (Array.isArray(obj)) {
obj.splice(key, 1);
} else {
delete obj[key];
}
this.$emit("input", value);
} else {
this.$emit("input", {});
}
},
itemToggle (item, state) {
const parents = [...item.path];
const value = structuredClone(this.value);
if (parents.length) {
deepSet(value, parents, state ?? !item.value)
} else {
value = state ?? !item.value;
}
this.$emit("input", value);
},
itemSet (path, content) {
const parents = [...(path??[])];
const key = parents.pop();
if (key !== undefined) {
const value = structuredClone(this.value);
const obj = parents.length ? deepValue(value, parents) : value;
if (Array.isArray(obj)) {
if (key === null) {
obj.push(content);
} else {
obj[key] = content;
}
} else {
obj[key] = content;
}
this.$emit("input", value);
} else {
this.$emit("input", content);
}
},
itemAdd (path, content) {
let value = structuredClone(this.value);
let path_ = [...(path??[])];
if (path_.length) {
try {
deepSet(value, path_, content);
} catch (err) {
if (err instanceof TypeError) {
this.itemSet(path, content);
return;
}
}
} else {
value = content;
}
this.$emit("input", value);
},
itemAddDialog (item, edit=false) {
if (!this.open.includes(item.id)) {
this.open.push(item.id);
}
if (edit) {
this.editorReset({
name: item.name,
type: item.type,
value: item.value
}, {nameEditable: false});
} else {
this.editorReset({}, {
nameShown: item.type != "array",
nameRequired: item.type != "array"
});
}
this.onEditorSave = (evt) => {
this.editor = false;
let transformer;
switch(this.edit.type) {
case "symbol":
transformer = Symbol;
break;
case "string":
transformer = String;
break;
case "bigint":
transformer = BigInt;
break;
case "number":
transformer = Number;
break;
case "boolean":
transformer = Boolean;
break;
case "undefined":
transformer = () => { return undefined; };
break;
case "object":
transformer = (v) =>
typeof v == "object"
? v
: (typeof v == "string" && v.length)
? JSON.parse(v)
: {};
break;
case "null":
transformer = () => null;
break;
case "array":
// FIXME not great
transformer = (v) =>
Array.isArray(v)
? v
: [];
break;
}
const value = transformer(this.edit.value);
const path = [...(item.path??[])];
if (!edit) {
if (item.type == "array") {
path.push(null);
} else {
path.push(this.edit.name);
}
}
this.itemAdd(path, value);
};
this.editor = true;
},
XXitemEditDialog (item) {
this.editorReset({
name: item.name,
type: item.type,
value: item.value}, {nameEditable: false});
this.onEditorSave = (evt) => {
this.editor = false;
let transformer;
switch(this.edit.type) {
case "symbol":
transformer = Symbol;
break;
case "string":
transformer = String;
break;
case "bigint":
transformer = BigInt;
break;
case "number":
transformer = Number;
break;
case "boolean":
transformer = Boolean;
break;
case "undefined":
transformer = () => { return undefined; };
break;
case "object":
transformer = (v) =>
typeof v == "object"
? v
: (typeof v == "string" && v.length)
? JSON.parse(v)
: {};
break;
case "null":
transformer = () => null;
break;
case "array":
// FIXME not great
transformer = (v) =>
Array.isArray(v)
? v
: [];
break;
}
const key = this.edit.name;
const value = transformer(this.edit.value);
this.itemAdd(item, key, value);
}
this.editor = true;
},
editorReset (values, props) {
this.edit = {
name: values?.name,
type: values?.type,
value: values?.value
};
this.editorProperties = {
nameShown: props?.nameShown ?? true,
nameEditable: props?.nameEditable ?? true,
nameRequired: props?.nameRequired ?? true,
typeShown: props?.typeShown ?? true,
typeEditable: props?.typeEditable ?? true,
valueShown: props?.valueShown ?? true,
serialisable: props?.serialisable ?? true
};
},
editorSave (evt) {
this.onEditorSave?.(evt);
},
editorClose () {
this.editor = false;
}
}
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<v-dialog :value="open" @input="$emit('close')">
<v-card>
<v-card-title>
</v-card-title>
<v-card-text>
<v-text-field v-if="nameShown"
label="Name"
:disabled="!nameEditable"
v-model.sync="value.name"
></v-text-field>
<v-select v-if="typeShown"
label="Type"
:items="types"
:disabled="!typeEditable"
v-model.sync="value.type"
></v-select>
<template v-if="valueShown">
<v-text-field v-if="value.type == 'number' || value.type == 'bigint'"
label="Value"
type="number"
v-model.sync="value.value"
></v-text-field>
<v-textarea v-else-if="value.type == 'string'"
label="Value"
v-model.sync="value.value"
></v-textarea>
<v-radio-group v-else-if="value.type == 'boolean'"
v-model.sync="value.value"
>
<v-radio
label="true"
:value="true"
></v-radio>
<v-radio
label="false"
:value="false"
></v-radio>
</v-radio-group>
</template>
</v-card-text>
<v-card-actions>
<v-btn
:disabled="!canSave"
@click="$emit('save')"
>Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "DougalJsonBuilderPropertyDialog",
props: {
open: Boolean,
value: Object,
nameRequired: {type: Boolean, default: true},
nameEditable: Boolean,
nameShown: {type: Boolean, default: true},
typeEditable: Boolean,
typeShown: {type: Boolean, default: true},
valueShown: {type: Boolean, default: true},
serialisable: {type: Boolean, default: true},
allowedTypes: Array
},
data () {
return {
//key: null,
//type: null,
allTypes: [
"symbol",
"string",
"bigint",
"number",
"boolean",
"undefined",
"object",
"null",
"array"
],
serialisableTypes: [
"string",
"number",
"boolean",
"object",
"null",
"array"
],
};
},
computed: {
types () {
return this.allowedTypes
? this.allowedTypes
: this.serialisable
? this.serialisableTypes
: this.allTypes;
},
canSave () {
return this.value.type && (this.value.name || this.nameRequired === false);
}
}
}
</script>

View File

@@ -0,0 +1,130 @@
<template>
<v-card>
<v-card-title>ASAQC</v-card-title>
<v-card-subtitle>Equinor's cloud API configuration.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="ASAQC ID"
hint="ID number for this survey in ASAQC"
persistent-hint
type="number"
v-model.number="cwo.id"
>
</v-text-field>
<v-text-field
label="IMO"
hint="Project vessel's International Maritime Organisation's identification number"
persistent-hint
v-model.number="cwo.imo"
>
</v-text-field>
<v-text-field
label="MMSI"
hint="Maritime Mobile Service Identities (MMSI) number"
persistent-hint
v-model.number="cwo.mmsi"
>
</v-text-field>
<v-text-field
label="Subscription key"
hint="Key to authenticate to ASAQC, provided by Equinor"
persistent-hint
:type="subscriptionKeyVisible ? 'text' : 'password'"
:append-icon="subscriptionKeyVisible ? 'mdi-eye' : 'mdi-eye-off'"
v-model="cwo.subscriptionKey"
@click:append="subscriptionKeyVisible = !subscriptionKeyVisible"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsASAQC",
props: {
value: Object
},
data () {
return {
subscriptionKeyVisible: false
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.cloud?.asaqc) {
deepSet(this.value, [ "cloud", "asaqc" ], {
id: null,
imo: null,
mmsi: null,
subscriptionKey: null
});
}
return this.value.cloud.asaqc;
} else {
return {};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "cloud", "asaqc" ], v);
}
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,196 @@
<template>
<v-card>
<v-card-title>Binning parameters</v-card-title>
<!-- <v-card-subtitle></v-card-subtitle> -->
<v-card-text>
<v-form>
<v-text-field
label="Azimuth"
hint="Direction of I (inline) axis"
persistent-hint
suffix="°"
type="number"
v-model.number="cwo.theta"
>
</v-text-field>
<v-text-field
label="I increment"
hint="Bin number increment along the inline axis"
persistent-hint
type="number"
min="0"
step="1"
v-model.number="cwo.I_inc"
>
</v-text-field>
<v-text-field
label="J increment"
hint="Bin number increment along the crossline axis"
persistent-hint
type="number"
min="0"
step="1"
v-model.number="cwo.J_inc"
>
</v-text-field>
<v-text-field
label="I width"
hint="Inline bin width (can be negative)"
persistent-hint
type="number"
v-model.number="cwo.I_width"
>
</v-text-field>
<v-text-field
label="J width"
hint="Crossline bin width (can be negative)"
persistent-hint
type="number"
v-model.number="cwo.J_width"
>
</v-text-field>
<fieldset class="pa-3 mt-3">
<legend>Origin</legend>
<v-row>
<v-col>
<v-text-field
label="Easting"
hint="Bin origin easting"
persistent-hint
type="number"
v-model.number="cwo.origin.easting"
>
</v-text-field>
</v-col>
<v-col>
<v-text-field
label="Northing"
hint="Bin origin northing"
persistent-hint
type="number"
v-model.number="cwo.origin.northing"
>
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
label="I"
hint="Bin origin inline"
persistent-hint
type="number"
step="1"
v-model.number="cwo.origin.I"
>
</v-text-field>
</v-col>
<v-col>
<v-text-field
label="J"
hint="Bin origin crossline"
persistent-hint
type="number"
step="1"
v-model.number="cwo.origin.J"
>
</v-text-field>
</v-col>
</v-row>
</fieldset>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsBinning",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.binning) {
deepSet(this.value, [ "binning" ], {
theta: null,
Iinc: 1,
Jinc: 1,
Iwidth: null,
Jwidth: null,
origin: {
easting: null,
northing: null,
I: null,
J: null
}
});
}
return this.value.binning;
} else {
return {};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "binning" ], v);
}
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,312 @@
<template>
<v-card>
<v-card-title v-text="title"></v-card-title>
<v-card-subtitle v-text="subtitle"></v-card-subtitle>
<v-card-text>
<v-tabs v-model="tab">
<v-tab>Paths</v-tab>
<v-tab>Globs</v-tab>
<v-tab v-if="pattern">Pattern</v-tab>
<v-tab v-if="lineNameInfo">Line info</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item>
<v-card flat>
<v-card-subtitle>
A list of directories which are searched for matching files.
</v-card-subtitle>
<v-card-text>
<slot name="paths"></slot>
<v-form>
<v-text-field v-for="(item, index) in paths" :key="index"
v-model="paths[index]"
>
<dougal-file-browser-dialog
slot="append"
v-model="paths[index]"
:root="rootPath"
:mimetypes="[ 'inode/directory' ]"
title="Select a directory"
></dougal-file-browser-dialog>
<v-btn slot="append-outer"
fab
x-small text
dark
color="red"
title="Remove"
@click="() => paths.splice(index, 1)"
>
<v-icon>mdi-minus</v-icon>
</v-btn>
</v-text-field>
<v-btn
class="mx-2"
fab dark
x-small text
color="primary"
title="Add path"
@click="() => paths.push('')"
>
<v-icon dark>mdi-plus</v-icon>
</v-btn>
</v-form>
</v-card-text>
</v-card>
</v-tab-item>
<v-tab-item>
<v-card flat>
<v-card-subtitle>
A list of <a href="https://en.wikipedia.org/wiki/Glob_(programming)" target="_blank">glob patterns</a> expanding to match the files of interest. Note that Linux is case-sensitive.
</v-card-subtitle>
<v-card-text>
<slot name="globs"></slot>
<v-form>
<v-text-field v-for="(item, index) in globs" :key="index"
v-model="globs[index]"
>
<v-btn slot="append-outer"
fab
x-small text
dark
color="red"
title="Remove"
@click="() => globs.splice(index, 1)"
>
<v-icon>mdi-minus</v-icon>
</v-btn>
</v-text-field>
<v-btn
class="mx-2"
fab dark
x-small text
color="primary"
title="Add glob pattern"
@click="() => globs.push('')"
>
<v-icon dark>mdi-plus</v-icon>
</v-btn>
</v-form>
</v-card-text>
</v-card>
</v-tab-item>
<v-tab-item v-if="pattern">
<v-card flat>
<v-card-subtitle>
Regular expression that describes the file format definition. Used to capture information such as line and sequence number, etc.
<b v-if="lineNameInfo">Note: Use the <a @click.stop="tab=3">line info</a> tab preferentially.</b>
</v-card-subtitle>
<v-card-text>
<slot name="pattern"></slot>
<v-form>
<v-text-field
class="mb-5"
label="Regular expression"
v-model="pattern.regex"
persistent-hint
hint="Regular expression"
>
</v-text-field>
<fieldset class="pa-3 mb-5">
<legend>Captures</legend>
<v-text-field v-for="(item, index) in pattern.captures" :key="index"
v-model="pattern.captures[index]"
>
<v-btn slot="append-outer"
fab
x-small text
dark
color="red"
title="Remove"
@click="() => pattern.captures.splice(index, 1)"
>
<v-icon>mdi-minus</v-icon>
</v-btn>
</v-text-field>
<v-btn
class="mx-2"
fab dark
x-small text
color="primary"
title="Add capture"
@click="() => pattern.captures.push('')"
>
<v-icon dark>mdi-plus</v-icon>
</v-btn>
</fieldset>
<v-text-field
class="mb-5"
label="Flags"
v-model="pattern.flags"
persistent-hint
hint="Regular expression modifier flags"
>
</v-text-field>
</v-form>
</v-card-text>
</v-card>
</v-tab-item>
<v-tab-item v-if="lineNameInfo">
<v-card flat>
<v-card-subtitle>
Line information that will be extracted from file names
</v-card-subtitle>
<v-card-text>
<slot name="line-info"></slot>
<v-form>
<v-text-field
label="Example file name"
hint="Enter the name of a representative file to make it easier to visualise your configuration"
persistent-hint
v-model="lineNameInfo.example"
></v-text-field>
<dougal-fixed-string-decoder
:multiline="true"
:text="lineNameInfo.example"
:fixed.sync="lineNameInfo.fixed"
:fields.sync="lineNameInfo.fields"
></dougal-fixed-string-decoder>
</v-form>
</v-card-text>
</v-card>
</v-tab-item>
</v-tabs-items>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
:disabled="!isValid"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepCompare } from '@/lib/utils';
import DougalFileBrowserDialog from '@/components/file-browser/file-browser-dialog';
import DougalFixedStringDecoder from '@/components/decoder/fixed-string-decoder';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalProjectSettingsFileMatchingParameters",
components: {
DougalFileBrowserDialog,
DougalFixedStringDecoder
},
props: {
title: String,
subtitle: String,
isValid: { type: Boolean, default: true },
save: Function,
rootPath: String,
paths: Array,
globs: Array,
lineNameInfo: Object,
pattern: Object
},
data () {
return {
tab: null,
globs_: [],
paths_: [],
lineNameInfo_: {},
pattern_: {
flags: "i",
regex: null,
captures: []
}
}
},
computed: {
},
watch: {
globs () {
if (!deepCompare(this.globs, this.globs_)) {
this.globs_ = this.globs;
}
},
paths () {
if (!deepCompare(this.paths, this.paths_)) {
this.paths_ = this.paths;
}
},
lineNameInfo () {
if (!deepCompare(this.lineNameInfo, this.lineNameInfo_)) {
this.lineNameInfo_ = this.lineNameInfo;
}
},
pattern () {
if (!deepCompare(this.pattern, this.pattern_)) {
this.pattern_ = this.pattern;
}
}
},
methods: {
reset () {
this.globs_ = this.globs;
this.paths_ = this.paths;
this.lineNameInfo_ = this.lineNameInfo;
this.pattern_ = this.pattern;
},
/*
save () {
this.$emit('input', this.data);
},
*/
back () {
this.$emit('close');
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<v-card>
<v-card-title>Files</v-card-title>
<v-card-subtitle>File path configuration for this project.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="Project folder"
hint="Root file path for this project"
persistent-hint
v-model="cwo.rootPath"
>
<dougal-file-browser-dialog
slot="append"
v-model="cwo.rootPath"
mimetypes="inode/directory"
></dougal-file-browser-dialog>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import DougalFileBrowserDialog from '@/components/file-browser/file-browser-dialog';
export default {
name: "DougalProjectSettingsFilePath",
components: { DougalFileBrowserDialog },
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
return this.value;
} else {
return {};
}
},
set (v) {
this.value = v;
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<v-card>
<v-card-title>Geodetics</v-card-title>
<v-card-subtitle>Geodetic parameters.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="EPSG code"
hint="EPSG code of the project's coordinate reference system"
persistent-hint
type="number"
v-model.number="cwo.epsg"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: "DougalProjectSettingsGeodetics",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
return this.value;
} else {
return {};
}
},
set (v) {
this.value = v;
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<v-card>
<v-card-title>Groups</v-card-title>
<v-card-subtitle>For <abbr title="Permanent Reservoir Monitoring">PRM</abbr> and 4D operations, each project can be assigned to one or more groups sharing the same (or substantially the same) preplots.</v-card-subtitle>
<v-card-text>
<v-form>
<v-combobox
v-model="groups"
:items="items"
multiple
:search-input.sync="search"
:hide-no-data="!search"
hide-selected
chips
hint="Project group(s). Type a value to create a new group (case sensitive)"
persistent-hint
>
<template v-slot:no-data>
<v-list-item>
<small>New group: </small>
<v-chip class="ml-3"
:color="`grey lighten-2`"
light
label
small
>
{{ search }}
</v-chip>
</v-list-item>
</template>
<template v-slot:selection="{ attrs, item, parent, selected }">
<v-chip
v-if="item === Object(item)"
v-bind="attrs"
:color="`${item.colour} lighten-3`"
:input-value="selected"
label
small
>
<span class="pr-2">
{{ item.text }}
</span>
<v-icon
small
@click="parent.selectItem(item)"
>
$delete
</v-icon>
</v-chip>
</template>
<template v-slot:item="{ index, item }">
<v-chip
:color="`${item.colour} lighten-3`"
label
small
>{{ item.text }}</v-chip>
</template>
</v-combobox>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { mapGetters } from 'vuex'
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsGroups",
props: [ "value" ],
data () {
return {
search: null
}
},
computed: {
colours () {
return [ "green", "purple", "indigo", "cyan", "teal", "orange" ];
},
nextColour () {
// FIXME Fix colour when adding a new group
return this.colours[(this.items.length + (this.value?.groups?.length ?? 0)) % this.colours.length];
},
items () {
return this.projectGroups.map((item, idx) => {
return {
text: item,
colour: this.colours[idx % this.colours.length]
};
});
},
groups: {
get () {
return this.value?.groups?.map(i => {
return typeof i === "string"
? { text: i, colour: this.nextColour}
: i
}) ?? [];
},
set (v) {
if (this.value) {
this.value.groups = v?.map( i => i?.text ?? i );
}
}
},
...mapGetters(["projectGroups"])
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<dougal-project-settings-file-matching-parameters
title="Final P1/11"
subtitle="Final P1/11 files location and parameters."
v-bind="{rootPath: value.rootPath}"
v-bind.sync="bind"
:is-valid="isValid"
:save="save"
>
<template v-slot:paths v-if="validationErrors.includes('ERR_PATHS')">
<v-alert type="warning">
At least one path entry is required.<br/>
<ul>
<li>If you have final P1/11 files in multiple paths (e.g., each file in its own sequence directory), enter here the parent directory.</li>
<li>If files are across multiple paths without a common ancestor, you must add multiple entries here.</li>
</ul>
</v-alert>
</template>
<template v-slot:globs v-if="validationErrors.includes('ERR_GLOBS')">
<v-alert type="warning">
At least one glob expression is required.
</v-alert>
</template>
<template v-slot:line-info v-if="validationErrors.includes('ERR_LINEINFO')">
<v-alert type="warning">
At least the following fields are required:
<ul>
<li><code>line</code> (integer, the preplot line number)</li>
<li><code>sequence</code> (integer, the acquisition sequence number)</li>
</ul>
</v-alert>
</template>
</dougal-project-settings-file-matching-parameters>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepSet } from '@/lib/utils';
import DougalProjectSettingsFileMatchingParameters from '@/components/project-settings/file-matching-parameters';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalProjectSettingsFinal111",
components: {
DougalProjectSettingsFileMatchingParameters
},
props: {
title: String,
subtitle: String,
value: Object
},
data () {
return {
tab: null,
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.final?.p111) {
deepSet(this.value, [ "final", "p111" ], {
globs: [ "**/*.p111", "**/*.P111" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
},
incr: {
length: 1,
type: "bool"
},
attempt: {
length: 1,
type: "int"
},
file_no: {
length: 3,
type: "int"
}
}
}
});
}
return this.value.final.p111;
} else {
return {
globs: [ "**/*.p111", "**/*.P111" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
},
}
}
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "final", "p111" ], v);
}
}
},
bind () {
return {
globs: this.cwo?.globs,
paths: this.cwo?.paths,
pattern: this.cwo?.pattern,
lineNameInfo: this.cwo?.lineNameInfo ?? {}
};
},
validationErrors () {
const errors = [];
if (!this.cwo?.paths.length || !this.cwo?.paths[0].length) {
// "Missing path: we need at least one directory where to search for matching files"
errors.push("ERR_PATHS");
}
if (!this.cwo?.globs.length) {
// "Missing globs: we need at least one glob to search for matching files"
errors.push("ERR_GLOBS");
}
if (this.cwo?.lineNameInfo) {
const pass = !this.cwo?.lineNameInfo?.fields || [ "line", "sequence" ].every( i =>
["offset", "length"].every( j => j in (this.cwo?.lineNameInfo?.fields?.[i] ?? {}) ));
if (!pass) {
// "Missing field info: We need at least 'line' and 'sequence' fields"
errors.push("ERR_LINEINFO")
}
}
return errors;
},
isValid () {
return this.validationErrors.length == 0;
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<v-card>
<v-card-title>Pending sequence detection</v-card-title>
<v-card-subtitle>Sequences which are pending acceptance (e.g., due to marginal quality) can be marked as such by the naming of their <b>final</b> files or parent directory. Dougal uses regular expression matching against the full path, not just the file name. This pattern applies to both P1/90 and P1/11 files.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="Pattern"
hint="Regular expression text"
persistent-hint
v-model="cwo.pattern.regex"
>
</v-text-field>
<v-text-field
label="Flags"
hint="Regular expression modifier flags"
persistent-hint
v-model="cwo.pattern.flags"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsFinalPending",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.final?.pending.pattern) {
deepSet(this.value, [ "final", "pending", "pattern" ], {
flags: "i",
regex: "PENDING"
});
}
return this.value.final.pending;
} else {
return {
flags: "i",
regex: "PENDING"
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "final", "pending", "pattern" ], v);
}
}
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<v-card>
<v-card-title>NTBP detection</v-card-title>
<v-card-subtitle><abbr title="Not to be processed">NTBP</abbr> sequences are denoted by the naming of its files or any of its parent directories. Dougal uses regular expression matching against the full path, not just the file name. This pattern applies to both P1/90 and P1/11 files.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="Pattern"
hint="Regular expression text"
persistent-hint
v-model="cwo.pattern.regex"
>
</v-text-field>
<v-text-field
label="Flags"
hint="Regular expression modifier flags"
persistent-hint
v-model="cwo.pattern.flags"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsRawNTBP",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.raw?.ntbp.pattern) {
deepSet(this.value, [ "raw", "ntbp", "pattern" ], {
flags: "i",
regex: "NTBP"
});
}
return this.value.raw.ntbp;
} else {
return {
flags: "i",
regex: "NTBP"
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "raw", "ntbp", "pattern" ], v);
}
}
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<dougal-project-settings-file-matching-parameters
title="Raw P1/11"
subtitle="Raw P1/11 files location and parameters."
v-bind="{rootPath: value.rootPath}"
v-bind.sync="bind"
:is-valid="isValid"
:save="save"
>
<template v-slot:paths v-if="validationErrors.includes('ERR_PATHS')">
<v-alert type="warning">
At least one path entry is required.<br/>
<ul>
<li>If you have final P1/11 files in multiple paths (e.g., each file in its own sequence directory), enter here the parent directory.</li>
<li>If files are across multiple paths without a common ancestor, you must add multiple entries here.</li>
</ul>
</v-alert>
</template>
<template v-slot:globs v-if="validationErrors.includes('ERR_GLOBS')">
<v-alert type="warning">
At least one glob expression is required.
</v-alert>
</template>
<template v-slot:line-info v-if="validationErrors.includes('ERR_LINEINFO')">
<v-alert type="warning">
At least the following fields are required:
<ul>
<li><code>line</code> (integer, the preplot line number)</li>
<li><code>sequence</code> (integer, the acquisition sequence number)</li>
</ul>
</v-alert>
</template>
</dougal-project-settings-file-matching-parameters>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepSet } from '@/lib/utils';
import DougalProjectSettingsFileMatchingParameters from '@/components/project-settings/file-matching-parameters';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalProjectSettingsRaw111",
components: {
DougalProjectSettingsFileMatchingParameters
},
props: {
title: String,
subtitle: String,
value: Object
},
data () {
return {
tab: null,
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.raw?.p111) {
deepSet(this.value, [ "raw", "p111" ], {
globs: [ "**/*.p111", "**/*.P111" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
},
incr: {
length: 1,
type: "bool"
},
attempt: {
length: 1,
type: "int"
},
file_no: {
length: 3,
type: "int"
}
}
}
});
}
return this.value.raw.p111;
} else {
return {
globs: [ "**/*.p111", "**/*.P111" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
},
}
}
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "raw", "p111" ], v);
}
}
},
bind () {
return {
globs: this.cwo?.globs,
paths: this.cwo?.paths,
pattern: this.cwo?.pattern,
lineNameInfo: this.cwo?.lineNameInfo
};
},
validationErrors () {
const errors = [];
if (!this.cwo?.paths.length || !this.cwo?.paths[0].length) {
// "Missing path: we need at least one directory where to search for matching files"
errors.push("ERR_PATHS");
}
if (!this.cwo?.globs.length) {
// "Missing globs: we need at least one glob to search for matching files"
errors.push("ERR_GLOBS");
}
if (this.cwo?.lineNameInfo) {
const pass = !this.cwo?.lineNameInfo?.fields || [ "line", "sequence" ].every( i =>
["offset", "length"].every( j => j in (this.cwo?.lineNameInfo?.fields?.[i] ?? {}) ));
if (!pass) {
// "Missing field info: We need at least 'line' and 'sequence' fields"
errors.push("ERR_LINEINFO")
}
}
return errors;
},
isValid () {
return this.validationErrors.length == 0;
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<dougal-project-settings-file-matching-parameters
title="Smartsource header data"
subtitle="Smartsource header data files location and parameters."
v-bind="{rootPath: value.rootPath}"
v-bind.sync="bind"
:is-valid="isValid"
:save="save"
>
</dougal-project-settings-file-matching-parameters>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepSet } from '@/lib/utils';
import DougalProjectSettingsFileMatchingParameters from '@/components/project-settings/file-matching-parameters';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalProjectSettingsSmartsourceHeader",
components: {
DougalProjectSettingsFileMatchingParameters
},
props: {
title: String,
subtitle: String,
value: Object
},
data () {
return {
tab: null,
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.raw?.source?.smsrc?.header) {
deepSet(this.value, [ "raw", "source", "smsrc", "header" ], {
globs: [ "**/*.hdr", "**/*.HDR" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
}
}
}
});
}
return this.value.raw.source.smsrc.header;
} else {
return {
globs: [ "**/*.hdr", "**/*.HDR" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
}
}
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "raw", "source", "smsrc", "header" ], v);
}
}
},
bind () {
return {
globs: this.cwo?.globs,
paths: this.cwo?.paths,
pattern: this.cwo?.pattern,
lineNameInfo: this.cwo?.lineNameInfo
};
},
isValid () {
return !!(this.cwo?.paths.length && this.cwo?.globs.length && (
this.cwo?.pattern?.regex &&
["direction", "line", "sequence"].every( i => this.cwo?.pattern?.captures?.includes(i) )) || (
this.cwo?.lineNameInfo &&
this.cwo?.lineNameInfo?.fields &&
[ "line", "sequence", "incr" ].every( i =>
["offset", "length"].every( j => j in this.cwo?.lineNameInfo.fields[i] ))));
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<dougal-project-settings-file-matching-parameters
title="Smartsource hydrophone data"
subtitle="Smartsource SEG-Y hydrophone data files location and parameters."
v-bind="{rootPath: value.rootPath}"
v-bind.sync="bind"
:is-valid="isValid"
:save="save"
>
</dougal-project-settings-file-matching-parameters>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepSet } from '@/lib/utils';
import setIfDifferent from '@/lib/watcher-mixin';
import DougalProjectSettingsFileMatchingParameters from '@/components/project-settings/file-matching-parameters';
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalProjectSettingsSmartsourceSegy",
components: {
DougalProjectSettingsFileMatchingParameters
},
props: {
title: String,
subtitle: String,
value: Object
},
data () {
return {
tab: null,
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.raw?.source?.smsrc?.segy) {
deepSet(this.value, [ "raw", "source", "smsrc", "segy" ], {
globs: [ "**/*.hdr", "**/*.HDR" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
},
sequence: {
length: 3,
type: "int"
}
}
}
});
}
return this.value.raw.source.smsrc.segy;
} else {
return {
globs: [ "**/*.hdr", "**/*.HDR" ],
paths: [],
lineNameInfo: {
example: "",
fields: {
}
}
};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "raw", "source", "smsrc", "segy" ], v);
}
}
},
bind () {
return {
globs: this.cwo?.globs,
paths: this.cwo?.paths,
pattern: this.cwo?.pattern,
lineNameInfo: this.cwo?.lineNameInfo
};
},
isValid () {
return !!(this.cwo?.paths.length && this.cwo?.globs.length && (
this.cwo?.pattern?.regex &&
["direction", "line", "sequence"].every( i => this.cwo?.pattern?.captures?.includes(i) )) || (
this.cwo?.lineNameInfo &&
this.cwo?.lineNameInfo?.fields &&
[ "line", "sequence", "incr" ].every( i =>
["offset", "length"].every( j => j in this.cwo?.lineNameInfo.fields[i] ))));
}
},
watch: {
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<v-card>
<v-card-title>Name, ID &amp; geodetics</v-card-title>
<v-card-subtitle>The survey's name, short ID and EPSG code. This is the minimum required to create a new survey. <b>These values cannot be changed once the project has been created!</b></v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="ID"
hint="Short survey ID"
persistent-hint
v-model="id"
>
</v-text-field>
<v-text-field
label="Name"
hint="Survey name"
persistent-hint
v-model="name"
>
</v-text-field>
<v-text-field
label="EPSG code"
hint="EPSG code of the project's coordinate reference system"
persistent-hint
type="number"
v-model.number="epsg"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: "DougalProjectSettingsNameIdGeodetics",
props: [ "value" ],
data () {
return {
id: "",
name: "",
epsg: null
}
},
watch: {
value (newValue) {
this.reset();
}
},
methods: {
reset () {
for (const key of Object.keys(this.$data)) {
this[key] = this.value[key];
}
},
save () {
this.$emit('input', {...this.$data});
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<v-card>
<v-card-title>Files</v-card-title>
<v-card-subtitle>File path configuration for this project.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="ID"
hint="Short survey ID"
persistent-hint
v-model="id"
>
</v-text-field>
<v-text-field
label="Name"
hint="Survey name"
persistent-hint
v-model="name"
>
</v-text-field>
<v-text-field
label="Project folder"
hint="Root file path for this project"
persistent-hint
v-model="path"
>
<dougal-file-browser-dialog
slot="append"
v-model="path"
mimetypes="inode/directory"
></dougal-file-browser-dialog>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import DougalFileBrowserDialog from '@/components/file-browser/file-browser-dialog';
export default {
name: "DougalProjectSettingsNameIdRootpath",
components: { DougalFileBrowserDialog },
props: [ "value" ],
data () {
return {
id: "",
name: "",
path: ""
}
},
watch: {
value (newValue) {
this.reset();
}
},
methods: {
reset () {
for (const key of Object.keys(this.$data)) {
this[key] = this.value[key];
}
},
save () {
this.$emit('input', {...this.$data});
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<v-card>
<v-card-title>Name &amp; ID</v-card-title>
<v-card-subtitle>The survey's name and short ID. The latter must match the value used in ASAQC, if applicable.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="ID"
hint="Short survey ID"
persistent-hint
v-model="cwo.id"
>
</v-text-field>
<v-text-field
label="Name"
hint="Survey name"
persistent-hint
v-model="cwo.name"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: "DougalProjectSettingsNameId",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
return this.value;
} else {
return {};
}
},
set (v) {
this.value = v;
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<v-card>
<v-card-title>Not implemented</v-card-title>
<v-card-text>
The code for this configuration section has not yet been implemented.
</v-card-text>
<v-card-actions>
<v-btn
disabled
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
disabled
color="warning"
@click="reset"
>Reset</v-btn>
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: "DougalProjectSettingsNotImplemented",
props: [ "value" ],
data () {
return {
}
},
watch: {
value (newValue) {
this.reset();
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<v-card>
<v-card-title>Online line name</v-card-title>
<v-card-subtitle>Line name decoding configuration for real-time data</v-card-subtitle>
<v-card-text>
<v-form>
<dougal-fixed-string-encoder
title="Line name format"
subtitle="Format of line names as configured in the navigation system"
label="Example line name"
hint="Visualise your line name configuration with example values"
:multiline="true"
:properties="properties"
:fields.sync="fields_"
:values.sync="values_"
></dougal-fixed-string-encoder>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import { deepSet } from '@/lib/utils';
import DougalFixedStringEncoder from '@/components/encoder/fixed-string-encoder';
export default {
name: "DougalProjectSettingsOnlineLineNameFormat",
components: {
DougalFixedStringEncoder
},
props: {
fields: Array,
values: Object,
properties: Object,
value: Object
},
data () {
return {
}
},
watch: {
},
computed: {
fields_: {
get () {
return this.fields;
},
set (v) {
this.$emit("update", structuredClone({values: this.values, fields: v}));
}
},
values_: {
get () {
return this.values;
},
set (v) {
this.$emit("update", structuredClone({values: v, fields: this.fields}));
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<v-card>
<v-card-title>Planner settings</v-card-title>
<v-card-subtitle>Default values when creating new sequences in the planner. These values can then be changed for each individual entry.</v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="Default line change duration"
suffix="min"
type="number"
hint="Expected line change time in minutes"
persistent-hint
v-model.number="cwo.defaultLineChangeDuration"
>
</v-text-field>
<v-text-field
label="Default acquisition speed"
suffix="kt"
type="number"
hint="Expected acquisition speed in knots"
persistent-hint
v-model.number="cwo.defaultAcquisitionSpeed"
>
</v-text-field>
<v-text-field
label="Overlap before"
type="number"
hint="Default number of shots to overlap before the FGSP, for reshoots"
persistent-hint
v-model.number="cwo.overlapBefore"
>
</v-text-field>
<v-text-field
label="Overlap after"
type="number"
hint="Default number of shots to overlap after the LGSP, for reshoots"
persistent-hint
v-model.number="cwo.overlapAfter"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsPlanner",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.planner) {
deepSet(this.value, [ "planner" ], {
defaultLineChangeDuration: null,
defaultAcquisitionSpeed: null,
overlapBefore: null,
overlapAfter: null
});
}
return this.value.planner;
} else {
return {};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "planner" ], v);
}
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,520 @@
<template>
<v-card flat>
<v-card-text>
<v-form>
<v-text-field
label="Path"
v-model="value.path"
>
<dougal-file-browser-dialog
slot="append"
v-model="value.path"
:root="rootPath"
></dougal-file-browser-dialog>
</v-text-field>
<v-select
label="Preplot class"
:items="fileClasses"
v-model="fileClass"
></v-select>
<v-select
label="File format"
:items="preplotFileTypes"
v-model="fileType"
></v-select>
<v-text-field v-if="value.class == 'S'"
class="mb-3"
label="Sailline offset"
prefix="±"
type="number"
hint="The value to add/substract to source lines to get to the corresponding sailline"
v-model.number="value.saillineOffset"
>
</v-text-field>
<v-expansion-panels v-if="isFixedWidthFormat"
:value="head.length ? 0 : null"
>
<v-expansion-panel>
<v-expansion-panel-header>Column settings</v-expansion-panel-header>
<v-expansion-panel-content>
<div class="mb-3" style="max-width:fit-content;">
<v-select v-if="head"
label="Sample text size"
hint="Choose how much of the file to display"
:items="sampleSizeItems"
v-model="sampleSize"
></v-select>
</div>
<v-text-field
label="Skip lines"
hint="This lets you to skip file headers if present"
type="number"
min="0"
v-model.number="firstRow"
></v-text-field>
<dougal-fixed-string-decoder
:text="rows"
:fields="fields"
:multiline="true"
:numbered-lines="firstRow"
max-height="300px"
:editable-field-list="false"
></dougal-fixed-string-decoder>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-expansion-panels v-else-if="isDelimitedFormat"
:value="head.length ? 0 : null"
>
<v-expansion-panel>
<v-expansion-panel-header>Column settings</v-expansion-panel-header>
<v-expansion-panel-content>
<v-container>
<v-row>
<v-col cols="6">
<div class="mb-3" style="max-width:fit-content;">
<v-select v-if="head"
label="Sample text size"
hint="Choose how much of the file to display"
:items="sampleSizeItems"
v-model="sampleSize"
></v-select>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<dougal-delimited-string-decoder
title="Fields"
:text="rows"
:fields.sync="fields"
:header-row.sync="headerRow"
:numbered-lines.sync="firstRow"
:editable-field-list="false"
:delimiter.sync="delimiter"
></dougal-delimited-string-decoder>
</v-col>
</v-row>
</v-container>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-expansion-panels v-else-if="fileClass == 'saillines'"
:value="head.length ? 0 : null"
>
<v-expansion-panel>
<v-expansion-panel-header>Column settings</v-expansion-panel-header>
<v-expansion-panel-content>
<v-container>
<v-row>
<v-col cols="6">
<div class="mb-3" style="max-width:fit-content;">
<v-select v-if="head"
label="Sample text size"
hint="Choose how much of the file to display"
:items="sampleSizeItems"
v-model="sampleSize"
></v-select>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<dougal-saillines-string-decoder
subtitle="Sailline data"
:text="head"
></dougal-saillines-string-decoder>
</v-col>
</v-row>
</v-container>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import DougalFileBrowserDialog from '@/components/file-browser/file-browser-dialog'
import DougalFixedStringDecoder from '@/components/decoder/fixed-string-decoder'
import DougalDelimitedStringDecoder from '@/components/decoder/delimited-string-decoder';
import DougalSaillinesStringDecoder from '@/components/decoder/saillines-string-decoder';
import { mapActions, mapGetters } from 'vuex'
export default {
name: "DougalProjectSettingsPreplotsPreplot",
components: {
DougalFileBrowserDialog,
DougalFixedStringDecoder,
DougalDelimitedStringDecoder,
DougalSaillinesStringDecoder
},
props: {
value: Object,
rootPath: String
},
data () {
return {
fileClasses: [
{ text: "Source points", value: "S" },
{ text: "Vessel points", value: "V" },
{ text: "Sail lines", value: "saillines" }
],
preplotFileTypeList: {
"S": [
{ header: "Fixed width" },
{ text: "SPS v1", value: "sps1", fixedWidth: true },
{ text: "SPS v2.1", value: "sps2.1", fixedWidth: true },
{ text: "P1/90", value: "p190", fixedWidth: true },
{ text: "Other fixed width", value: "fixed-width", fixedWidth: true },
{ header: "Delimited values" },
{ text: "P1/11", value: "p111", delimited: true },
{ text: "CSV", value: "csv", delimited: true },
],
"V": [
{ header: "Fixed width" },
{ text: "SPS v1", value: "sps1", fixedWidth: true },
{ text: "SPS v2.1", value: "sps2.1", fixedWidth: true },
{ text: "P1/90", value: "p190", fixedWidth: true },
{ text: "Other fixed width", value: "fixed-width", fixedWidth: true },
{ header: "Delimited values" },
{ text: "P1/11", value: "p111", delimited: true },
{ text: "CSV", value: "csv", delimited: true },
],
"saillines": [
{ text: "Sail lines CSV", value: "x-sl+csv" }
]
},
head: "",
sampleSize: 8192, //1024;
sampleSizeItems: [
{ text: "512 bytes", value: 512 },
{ text: "1 kiB", value: 1024 },
{ text: "2 kiB", value: 1024*2 },
{ text: "4 kiB", value: 1024*4 },
{ text: "8 kiB", value: 1024*8 },
{ text: "16 kiB", value: 1024*16 },
{ text: "32 kiB", value: 1024*32 },
],
};
},
computed: {
preplotFileTypes () {
return this.preplotFileTypeList[this.fileClass] ?? [];
},
isFixedWidthFormat () {
return this.preplotFileTypes.find(i => i.value == this.fileType)?.fixedWidth;
},
isDelimitedFormat () {
return this.preplotFileTypes.find(i => i.value == this.fileType)?.delimited;
},
rows () {
if (this.head) {
if (this.firstRow) {
return this.head.split("\n").slice(this.firstRow).join("\n");
}
return this.head;
}
return "";
},
fields: {
get () {
return (this.value?.fields && Object.keys(this.value.fields).length)
? this.value.fields
: {
line_name: {
type: "int"
},
point_number: {
type: "int"
},
easting: {
type: "float"
},
northing: {
type: "float"
}
};
},
set (v) {
// console.log("set fields", v);
this.$emit("input", {
...this.value,
fields: {...v}
});
}
},
fileClass: {
get () {
return this.value?.class;
},
set (v) {
this.$emit("input", {
...this.value,
class: v
});
}
},
fileType: {
get () {
return this.value?.type;
},
set (v) {
this.$emit("input", {
...this.value,
type: v
});
}
},
firstRow: {
get () {
return this.value?.firstRow ?? 0;
},
set (v) {
this.$emit("input", {
...this.value,
firstRow: v
});
}
},
headerRow: {
get () {
return this.value?.headerRow ?? false;
},
set (v) {
this.$emit("input", {
...this.value,
headerRow: v
});
}
},
delimiter: {
get () {
return this.value?.delimiter;
},
set (v) {
this.$emit("input", {
...this.value,
delimiter: v
});
}
}
},
watch: {
async "value.path" (cur, prev) {
if (cur != prev) {
this.head = await this.getHead();
}
},
async sampleSize (cur, prev) {
if (cur && cur != prev) {
this.head = await this.getHead();
}
},
fileClass (cur, prev) {
if (cur != prev && cur == "saillines") {
this.fileType = "x-sl+csv"
}
},
fileType (cur, prev) {
switch (cur) {
case prev:
return;
case "sps1":
this.fields = {
line_name: {
offset: 1,
length: 16,
type: "int"
},
point_number: {
offset: 17,
length: 8,
type: "int"
},
easting: {
offset: 46,
length: 9,
type: "float"
},
northing: {
offset: 55,
length: 10,
type: "float"
}
};
break;
case "sps2.1":
this.fields = {
line_name: {
offset: 1,
length: 7,
type: "int" // SPS v2.1 has this as float but Dougal doesn't support that
},
point_number: {
offset: 11,
length: 7,
type: "int" // Ditto
},
easting: {
offset: 46,
length: 9,
type: "float"
},
northing: {
offset: 55,
length: 10,
type: "float"
}
};
break;
case "p190":
this.fields = {
line_name: {
offset: 1,
length: 12,
type: "int"
},
point_number: {
offset: 19,
length: 6,
type: "int"
},
easting: {
offset: 46,
length: 9,
type: "float"
},
northing: {
offset: 55,
length: 9,
type: "float"
}
};
break;
case "fixed-width":
this.fields = {
line_name: {
offset: 1,
length: 4,
type: "int"
},
point_number: {
offset: 11,
length: 4,
type: "int"
},
easting: {
offset: 44,
length: 8,
type: "float"
},
northing: {
offset: 53,
length: 9,
type: "float"
}
};
case "csv":
this.fields = {
line_name: {
column: 0,
type: "int"
},
point_number: {
column: 1,
type: "int"
},
easting: {
column: 2,
type: "float"
},
northing: {
column: 3,
type: "float"
}
};
break
case "x-sl+csv":
this.fields = null;
break;
}
}
},
methods: {
async getHead () {
console.log("getHead", this.value?.path);
if (this.value?.path) {
const url = `/files/${this.value.path}`;
const init = {
text: true,
headers: {
"Range": `bytes=0-${this.sampleSize}`
}
};
const head = await this.api([url, init]);
return head?.substring(0, head.lastIndexOf("\n")) || "";
}
return "";
},
...mapActions(["api"])
},
created () {
this.$nextTick(async () => {
this.head = await this.getHead();
});
}
}
</script>

View File

@@ -0,0 +1,214 @@
<template>
<v-card>
<v-card-title>Preplots</v-card-title>
<v-card-subtitle>Preplot files location and format.</v-card-subtitle>
<v-card-text>
<v-expansion-panels v-model="panel">
<v-expansion-panel v-for="(preplot, idx) in cwo" :key="idx">
<v-expansion-panel-header>
{{ titleFor(preplot) }}
</v-expansion-panel-header>
<v-expansion-panel-content>
<dougal-project-settings-preplots-preplot
v-model="cwo[idx]"
:root-path="value.rootPath"
></dougal-project-settings-preplots-preplot>
<v-btn
outlined
color="red"
title="Delete this preplot definition"
@click.stop="deletePreplot(preplot)"
>
<v-icon left>mdi-delete-outline</v-icon>
Delete
</v-btn>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-btn
class="mt-5"
color="primary"
:disabled="!lastPreplotIsValid"
@click="newPreplot"
>
<v-icon left>mdi-file-document-plus-outline</v-icon>
New preplot
</v-btn>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
:disabled="!isValid"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
import DougalProjectSettingsPreplotsPreplot from '@/components/project-settings/preplots-preplot';
export default {
name: "DougalProjectSettingsPreplots",
components: {
DougalProjectSettingsPreplotsPreplot,
},
props: {
value: Object
},
data () {
return {
panel: null
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.preplots) {
deepSet(this.value, [ "preplots" ], []);
}
return this.value.preplots;
} else {
return [];
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "preplots" ], v);
}
}
},
lastPreplotIsValid () {
return this.cwo.length == 0 || this.validPreplot(this.cwo[this.cwo.length-1]);
},
isValid () {
return this.cwo.every(preplot => this.validPreplot(preplot));
}
},
methods: {
titleFor (preplot) {
let str = "";
if (preplot?.path) {
str += preplot.path;
} else {
const idx = this.cwo.findIndex(i => i == preplot);
if (idx != -1) {
str += `Preplot ${idx}`;
} else {
str += "Preplot <no path>";
}
}
if (preplot?.class || preplot?.type) {
str += " (" + [preplot.class, preplot.type].join("; ") + ")";
}
return str;
},
validPreplot (preplot) {
const predefined_formats = [ "sps1", "sps2.1", "p190", "p111", "x-sl+csv" ]
const common = (preplot?.path &&
preplot?.type &&
preplot?.class &&
preplot?.fields);
if (common) {
if (predefined_formats.includes(preplot.class)) {
// Predefined formats do not require a field definition
return true;
} else {
return !!(preplot?.fields?.line_name &&
preplot?.fields?.point_number &&
preplot?.fields?.easting &&
preplot?.fields?.northing);
}
}
return false;
},
deletePreplot (preplot) {
const idx = this.cwo.find(i => i == preplot);
if (idx != -1) {
this.cwo.splice(idx, 1);
}
},
newPreplot () {
if (this.lastPreplotIsValid) {
const preplot = {
path: "",
type: "",
format: "",
fields: {},
};
this.cwo.push(preplot);
this.panel = this.cwo.length - 1;
}
},
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<v-card>
<v-card-title>Production settings</v-card-title>
<v-card-subtitle></v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
label="Nominal line change duration"
hint="Duration of the nominal elapsed time circling between lines, in minutes. If this time is exceeded between one sequence and the next, an entry will be made in the events log"
persistent-hint
suffix="min"
type="number"
v-model.number="cwo.nominalLineChangeDuration"
>
</v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<!--
<v-btn
color="primary"
@click="save"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="reset"
>Reset</v-btn>
-->
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { deepSet } from '@/lib/utils';
export default {
name: "DougalProjectSettingsProduction",
props: {
value: Object
},
data () {
return {
}
},
computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
get () {
if (this.value) {
if (!this.value?.production) {
deepSet(this.value, [ "production" ], {
nominalLineChangeDuration: null
});
}
return this.value.production;
} else {
return {};
}
},
set (v) {
if (this.value) {
deepSet(this.value, [ "production" ], v);
}
}
}
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,117 @@
<template>
<v-card>
<v-card-title v-text="title"></v-card-title>
<v-card-subtitle v-text="subtitle"></v-card-subtitle>
<v-card-text>
<v-form>
<v-text-field
class="mb-5"
label="Regular expression"
v-model="regex"
persistent-hint
hint="Regular expression"
>
</v-text-field>
<fieldset class="pa-3 mb-5">
<legend>Captures</legend>
<v-text-field v-for="(item, index) in captures" :key="index"
v-model="captures[index]"
>
<v-btn slot="append-outer"
fab
x-small text
dark
color="red"
title="Remove"
@click="() => captures.splice(index, 1)"
>
<v-icon>mdi-minus</v-icon>
</v-btn>
</v-text-field>
<v-btn
class="mx-2"
fab dark
x-small text
color="primary"
title="Add capture"
@click="() => captures.push('')"
>
<v-icon dark>mdi-plus</v-icon>
</v-btn>
</fieldset>
<v-text-field
class="mb-5"
label="Flags"
v-model="flags"
persistent-hint
hint="Regular expression modifier flags"
>
</v-text-field>
</v-form>
</v-card-text>
</v-card>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
}
</style>
<script>
export default {
name: "DougalProjectSettingsRegexPatternCaptures",
props: [ "value", "title", "subtitle" ],
data () {
return {
regex: "",
flags: "",
captures: []
}
},
watch: {
value (newValue) {
this.reset();
},
regex () {
this.save();
},
flags () {
this.save();
},
captures () {
this.save();
}
},
methods: {
reset () {
this.regex = this.value?.regex;
this.flags = this.value?.flags;
this.captures = this.value?.captures ?? [];
},
save () {
this.$emit('input', {regex: this.regex, flags: this.flags, captures: this.captures});
},
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1 @@
../../../../server/lib/utils/deepMerge.js

View File

@@ -0,0 +1,47 @@
/** Return an HSL colour as a function of an input value
* `str`.
*
* Consider using as getHSL.bind(this) in Vue components
* in order to get access to the Vuetify theme configuration.
*/
function getHSL (str, saturation = 1, lightness = 0.25, offset = 0) {
function getHash (v) {
if (typeof (v??false)[Symbol.iterator] != "function") {
// Not an iterable, make it one
v = String(v);
}
return Math.abs([...v, ..." "].reduce( (acc, cur) => String(cur).charCodeAt(0) + ((acc << 5) - acc), 0 ));
}
const h = (getHash(str) + offset) % 360;
const s = saturation * 100;
const l = this?.$vuetify?.theme?.isDark
? (1-lightness) * 100
: lightness * 100;
return {h, s, l};
}
/** Return a CSS hsl() or hsla() colour
* representation as a function of an input value.
*
* Consider using as getHSLColourFor.bind(this) See
* note for getHSL() above.
*/
function getHSLColourFor (str, opacity = 1, saturation, lightness, offset) {
const _getHSL = getHSL.bind(this);
const {h, s, l} = _getHSL(str, saturation, lightness, offset);
if (opacity == 1) {
return `hsl(${h},${s}%,${l}%)`;
} else {
return `hsla(${h},${s}%,${l}%, ${opacity})`;
}
}
export {
getHSL,
getHSLColourFor
}

View File

@@ -0,0 +1,10 @@
function truncateText (text, length=20) {
if (text?.length <= length) {
return text;
} else {
return text.slice(0, length/2)+"…"+text.slice(-(length/2));
}
}
export default truncateText;

View File

@@ -93,25 +93,25 @@ function geometryAsString (item, opts = {}) {
}
/** Extract preferences by prefix.
*
*
* This function returns a lambda which, given
* a key or a prefix, extracts the relevant
* preferences from the designated preferences
* store.
*
*
* For instance, assume preferences = {
* "a.b.c.d": 1,
* "a.b.e.f": 2,
* "g.h": 3
* }
*
*
* And λ = preferencesλ(preferences). Then:
*
*
* λ("a.b") → { "a.b.c.d": 1, "a.b.e.f": 2 }
* λ("a.b.e.f") → { "a.b.e.f": 2 }
* λ("g.x", {"g.x.": 99}) → { "g.x.": 99 }
* λ("a.c", {"g.x.": 99}) → { "g.x.": 99 }
*
*
* Note from the last two examples that a default value
* may be provided and will be returned if a key does
* not exist or is not searched for.
@@ -133,9 +133,109 @@ function preferencesλ (preferences) {
}
/** Compare two possibly complex values for
* loose equality, going as deep as required in the
* case of complex objects.
*/
function deepCompare (a, b) {
if (typeof a == "object" && typeof b == "object") {
return !Object.entries(a).some( ([k, v]) => !deepCompare(v, b[k])) &&
!Object.entries(b).some( ([k, v]) => !deepCompare(v, a[k]));
} else {
return a == b;
}
}
/** Compare two possibly complex values for
* strict equality.
*/
function deepEqual (a, b) {
if (typeof a === "object" && typeof b === "object") {
return !Object.entries(a).some( ([k, v]) => !deepEqual(v, b[k])) &&
!Object.entries(b).some( ([k, v]) => !deepEqual(v, a[k]));
} else {
return a === b;
}
}
/** Traverses an object and sets a nested value.
*
* Example:
*
* const obj = {a: {b: {c: "X"} } }
* deepSet(obj, ["a", "b", "c"], "d")
* → {a: {b: {c: "d"} } }
*
* This would be the equivalent of:
*
* obj?.a?.b?.c = "d";
*
* Except that the above is not a legal expression.
*
* If a non-leaf property does not exist, this function
* creates it as an empty object ({}) and keeps traversing.
*
* The last member of `path` may be `null`, in which case,
* if the object pointed to by the next to last member is
* an array, an insert operation will take place.
*
*/
function deepSet (obj, path, value) {
const key = path.shift();
if (!path.length) {
if (key === null && Array.isArray(obj)) {
obj.push(value);
} else {
obj[key] = value;
}
} else {
if (!Object.hasOwn(obj, key)) {
obj[key] = {};
}
deepSet(obj[key], path, value);
}
}
/** Returns a nested property.
*
* Example:
*
* const obj = {a: {b: {c: "d"} } }
* deepSet(obj, ["a", "b", "c"])
* → "d"
*
* If `path` is known in advance, this is effectively
* the same as:
*
* obj?.a?.b?.c
*
* This might be useful when `path` is dynamic.
*/
function deepValue (obj, path) {
if (obj !== undefined) {
const key = path.shift();
if (!path.length) {
if (key === undefined) {
return obj;
} else {
return obj[key];
}
} else {
return deepValue(obj[key], path);
}
}
}
// Just to have all the deep*()s in one place
import deepMerge from './deepMerge'
export {
withParentProps,
geometryAsString,
preferencesλ
preferencesλ,
deepMerge,
deepCompare,
deepEqual,
deepSet,
deepValue
}

View File

@@ -0,0 +1,14 @@
import { deepCompare } from './utils';
function setIfDifferent(propsLocals) {
return Object.fromEntries(Object.entries(propsLocals).map( ([prop, local]) => [
local,
() => {
if (!deepCompare(this[prop], this[local])){
this[local] = structuredClone(this[prop]);
}
}
]));
}
export default setIfDifferent;

View File

@@ -16,7 +16,9 @@ import Log from '../views/Log.vue'
import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue'
import ProjectSettings from '../views/ProjectSettings.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list'
Vue.use(VueRouter)
@@ -80,7 +82,10 @@ Vue.use(VueRouter)
meta: {
breadcrumbs: [
{ text: "Projects", href: "/projects", disabled: true }
]
],
appBarExtension: {
// component: DougalAppBarExtensionProjectList
}
}
},
{
@@ -168,6 +173,11 @@ Vue.use(VueRouter)
path: "map",
name: "map",
component: Map
},
{
path: "configuration",
name: "configuration",
component: ProjectSettings
}
]
}

View File

@@ -488,6 +488,9 @@ export default {
rows () {
const rows = {};
this.items
.filter(i => {
return !this.$route.params.sequence || (this.$route.params.sequence == i.sequence)
})
.filter(i => {
for (const label of this.filterableLabels) {
if (!this.shownLabels.includes(label) && i.labels.includes(label)) {

View File

@@ -6,6 +6,7 @@
:items="displayItems"
:options.sync="options"
:loading="loading"
@contextmenu:row="contextMenu"
>
<template v-slot:item.pid="{item, value}">
@@ -22,6 +23,13 @@
</template>
</template>
<template v-slot:item.groups="{item, value}">
<v-chip v-for="group in value"
label
small
>{{ group }}</v-chip>
</template>
<template v-slot:item.shots="{item}">
{{ item.total ? (item.prime + item.other) : "" }}
</template>
@@ -54,6 +62,38 @@
</v-data-table>
<v-menu v-if="adminaccess"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<v-list dense v-if="contextMenuItem">
<v-list-item :href="`${contextMenuItem.pid}/configuration`">
<v-list-item-icon><v-icon>mdi-file-document-edit-outline</v-icon></v-list-item-icon>
<v-list-item-title class="warning--text">Edit project settings</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="cloneDialogOpen = true">
<v-list-item-icon><v-icon>mdi-sheep</v-icon></v-list-item-icon>
<v-list-item-title class="warning--text">Clone project</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-dialog
v-model="cloneDialogOpen"
max-width="600"
>
<dougal-project-settings-name-id-rootpath
v-model="cloneProjectDetails"
@input="cloneProject"
@close="cloneDialogOpen = false"
>
</dougal-project-settings-name-id-rootpath>
</v-dialog>
</v-container>
</template>
@@ -65,10 +105,15 @@ td p:last-of-type {
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalProjectSettingsNameIdRootpath from '@/components/project-settings/name-id-rootpath'
export default {
name: "ProjectList",
components: {
DougalProjectSettingsNameIdRootpath
},
data () {
return {
headers: [
@@ -80,6 +125,10 @@ export default {
value: "name",
text: "Name"
},
{
value: "groups",
text: "Groups"
},
{
value: "lines",
text: "Lines"
@@ -104,13 +153,26 @@ export default {
items: [],
options: {},
// Whether or not to show archived projects
showArchived: true,
// Cloned project stuff (admin only)
cloneDialogOpen: false,
cloneProjectDetails: {
name: null,
id: null,
path: null
},
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null
}
},
computed: {
displayItems () {
return this.showArchived
? this.items
@@ -131,8 +193,9 @@ export default {
},
methods: {
async list () {
this.items = await this.api(["/project"]) || [];
this.items = [...this.projects];
},
async summary (item) {
@@ -157,7 +220,145 @@ export default {
}
},
...mapActions(["api"])
contextMenu (e, {item}) {
e.preventDefault();
this.contextMenuShow = false;
this.contextMenuX = e.clientX;
this.contextMenuY = e.clientY;
this.contextMenuItem = item;
this.$nextTick( () => this.contextMenuShow = true );
},
async cloneProject () {
/* Plan of action:
* 1. Pop up dialogue asking for new project name, ID and root path
* 2. Get source project configuration
* 3. Blank out non-clonable parameters (ASAQC, …)
* 4. Rewrite paths prefixed with source rootPath with dest rootPath
* 5. Replace name, ID and rootPath
* 6. Set archived=true
* 7. POST new project
* 8. Redirect to new project settings page
*/
// 1. Pop up dialogue asking for new project name, ID and root path
// (already done, that's why we're here)
const pid = this.contextMenuItem?.pid;
if (!pid) return;
const tpl = this.cloneProjectDetails; // Shorter
if (!tpl.id || !tpl.name || !tpl.path) {
this.showSnack(["Missing project details. Cannot proceed", "warning"]);
return;
}
this.cloneDialogOpen = false;
/** Drills down an object and applies function fn(obj, key)
* on each recursive property [...keys].
*/
function drill (obj, keys, fn) {
if (obj) {
if (Array.isArray(keys)) {
if (keys.length) {
const key = keys.shift();
if (keys.length == 0 && key != "*") { // After shift()
if (typeof fn == "function") {
fn(obj, key);
}
}
if (key == "*") {
// Iterate through this object's keys
if (keys.length) {
for (const k in obj) {
drill(obj[k], [...keys], fn);
}
} else {
for (const k in obj) {
drill(obj, [k], fn);
}
}
} else {
drill(obj[key], keys, fn);
}
}
}
}
}
// 2. Get source project configuration
const src = await this.api([`/project/${pid}/configuration`]);
const blankList = [ "id", "name", "schema", "asaqc.id", "archived" ];
const prjPaths = [
"preplots.*.path",
"raw.p111.paths.*",
"raw.p190.paths.*",
"raw.smsrc.paths.*",
"final.p111.paths.*",
"final.p190.paths.*",
"qc.definitions",
"qc.parameters",
"imports.map.layers.*.*.path",
"rootPath" // Needs to go last because of lazy replacer() implementation below
];
// Technically don't need this
const deleter = (obj, key) => {
delete obj[key];
}
const replacer = (obj, key) => {
if (src.rootPath && tpl.path) {
if (obj[key].startsWith(src.rootPath)) {
obj[key] = obj[key].replace(src.rootPath, tpl.path);
}
}
}
// 3. Blank out non-clonable parameters (ASAQC, …)
blankList.forEach( i => drill(src, i.split("."), deleter) );
// 4. Rewrite paths prefixed with source rootPath with dest rootPath
prjPaths.forEach( i => drill(src, i.split("."), replacer) );
// 5. Replace name, ID and rootPath
// Could use deepMerge, but meh!
src.name = tpl.name;
src.id = tpl.id;
// 6. Set archived=true
src.archived = true;
// 7. POST new project
const init = {
method: "POST",
body: src
};
const cb = (err, res) => {
if (!err && res) {
if (res.status == "201") {
// 8. Redirect to new project settings page
const settingsUrl = `/projects/${tpl.id.toLowerCase()}/configuration`;
this.$router.push(settingsUrl);
}
}
};
await this.api(["/project", init, cb]);
},
...mapActions(["api", "showSnack"])
},
mounted () {

View File

@@ -0,0 +1,750 @@
<template>
<v-container fluid>
<v-overlay :value="loading && !configuration" absolute>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-overlay :value="!configuration && !loading" absolute opacity="0.8">
<v-row justify="center">
<v-alert
type="error"
>
The configuration could not be loaded.
</v-alert>
</v-row>
<v-row justify="center">
<v-btn color="primary" @click="getConfiguration">Retry</v-btn>
</v-row>
</v-overlay>
<v-window v-model="viewMode">
<v-window-item>
<v-row>
<v-col cols="4" max-height="100%">
<v-toolbar
dense
flat
@contextmenu="showContextMenu"
>
<v-toolbar-title>
Survey configuration
</v-toolbar-title>
<v-spacer/>
<template v-if="dirty">
<v-icon left color="primary" @click="saveToFile" title="Save changes to file">mdi-content-save-outline</v-icon>
<v-icon color="primary" @click="upload" title="Upload changes to server">mdi-cloud-upload</v-icon>
</template>
</v-toolbar>
<v-switch
dense
label="Active"
title="Inactive surveys do not take new data, but changes to the logs can still be made."
:disabled="!configuration"
v-model="surveyState"
></v-switch>
<v-treeview
dense
activatable
hoverable
:multiple-active="false"
:active.sync="active"
:open.sync="open"
:items="treeview"
style="cursor:pointer;"
>
</v-treeview>
</v-col>
<v-col cols="8" v-if="activeComponent">
<component
:is="activeComponent"
v-bind="activeValues"
v-model.sync="configuration"
@update="activeUpdateHandler"
@close="deselect"
></component>
</v-col>
<v-col cols="8" v-else>
<v-card>
<v-card-text>
<p>Select a configuration section to change its settings. When clicking <v-btn small color="primary">Save</v-btn> the changes are immediately saved to the server and start to take effect.</p>
<v-divider class="my-5"></v-divider>
<v-alert border="left" type="warning">
Be careful when changing configuration settings! It is rather easy to break things.
</v-alert>
<v-alert border="left" type="info">
On the first save, the survey will be switched to <em>inactive</em> if not already so. This means that no new data will be read. Remember to switch back to <strong>active</strong> when satisfied with your changes.
</v-alert>
<v-divider class="my-5"></v-divider>
<p>It is recommended that you download a backup of your configuration before making any changes.</p>
<v-divider class="my-5"></v-divider>
</v-card-text>
<v-card-actions>
<v-btn
outlined
color="primary"
title="Fetch the configuration from the server anew. Any unsaved changes you might have will be lost."
@click="getConfiguration"
>
<v-icon small left>mdi-cloud-refresh</v-icon>
Refresh
</v-btn>
<v-spacer></v-spacer>
<v-btn
outlined
class="ml-2"
color="primary"
:disabled="!configuration"
@click="saveToFile"
title="Save the current configuration to a file, including any changes you might have made but not yet sent to the server."
>
<v-icon small left>mdi-content-save-outline</v-icon>
Save
</v-btn>
<v-dialog
max-width="400px"
v-model.sync="fileLoadDialog"
>
<template v-slot:activator="{ on, attrs }">
<v-btn
outlined
class="ml-2"
color="primary lighten-2"
title="Load the configuration from a file. It will overwrite any changes that you might have made so far but it won't upload the configuration to the server. You can review the configuration and make changes before using the upload button."
v-bind="attrs"
v-on="on"
>
<v-icon small left>mdi-folder-open-outline</v-icon>
Load
</v-btn>
</template>
<v-card flat>
<v-card-text class="pt-5">
<v-file-input
v-model="files"
class="mt-4"
show-size
accept="application/json,application/yaml,.json,.yaml"
label="Select configuration file"
append-outer-icon="mdi-folder-open-outline"
:error-messages="fileInputErrors"
@click:append-outer="loadFromFile"
></v-file-input>
</v-card-text>
</v-card>
</v-dialog>
<v-spacer></v-spacer>
<v-btn
class="ml-2"
color="warning"
:disabled="!configuration"
title="Save the configuration on the server. This will replace the existing configuration. The project will be set to INACTIVE. Change its state back to active once you're satisfied with your changes."
@click="upload"
>
<v-icon small left>mdi-cloud-upload</v-icon>
Upload
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-window-item>
<v-window-item>
<v-row>
<v-col cols="12">
<v-toolbar
dense
flat
@contextmenu="showContextMenu"
>
<v-toolbar-title>
Advanced survey configuration
</v-toolbar-title>
<v-spacer/>
<v-btn small outlined @click="viewMode=0">Go to normal configuration</v-btn>
</v-toolbar>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<dougal-json-builder
name="Dougal configuration"
v-model="configuration"
></dougal-json-builder>
</v-col>
</v-row>
</v-window-item>
</v-window>
<v-menu
v-model="contextMenu"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<v-list dense>
<v-list-item>
<v-btn
small
outlined
color="red"
title="Not a good idea"
@click="viewMode=1"
>Advanced configuration</v-btn>
</v-list-item>
</v-list>
</v-menu>
</v-container>
</template>
<script>
import YAML from 'yaml';
import { mapActions, mapGetters } from 'vuex';
import { deepSet } from '@/lib/utils';
import DougalJsonBuilder from '@/components/json-builder/json-builder';
import DougalProjectSettingsNameId from '@/components/project-settings/name-id';
import DougalProjectSettingsGroups from '@/components/project-settings/groups';
import DougalProjectSettingsGeodetics from '@/components/project-settings/geodetics';
import DougalProjectSettingsBinning from '@/components/project-settings/binning';
import DougalProjectSettingsFilePath from '@/components/project-settings/file-path';
import DougalProjectSettingsPreplots from '@/components/project-settings/preplots';
import DougalProjectSettingsRawP111 from '@/components/project-settings/input-raw-p111';
import DougalProjectSettingsFinalP111 from '@/components/project-settings/input-final-p111';
import DougalProjectSettingsRawNTBP from '@/components/project-settings/input-raw-ntbp';
import DougalProjectSettingsFinalPending from '@/components/project-settings/input-final-pending';
import DougalProjectSettingsSmartsourceHeader from '@/components/project-settings/input-smartsource-header';
import DougalProjectSettingsSmartsourceSegy from '@/components/project-settings/input-smartsource-segy';
import DougalProjectSettingsPlanner from '@/components/project-settings/planner';
import DougalProjectSettingsOnlineLineNameFormat from '@/components/project-settings/online-line-name-format';
import DougalProjectSettingsASAQC from '@/components/project-settings/asaqc';
import DougalProjectSettingsProduction from '@/components/project-settings/production';
// Temporary placeholder component
import DougalProjectSettingsNotImplemented from '@/components/project-settings/not-implemented';
const components = {
name_id: DougalProjectSettingsNameId,
groups: DougalProjectSettingsGroups,
geodetics: DougalProjectSettingsGeodetics,
binning: DougalProjectSettingsBinning,
input_files: DougalProjectSettingsFilePath,
preplots: DougalProjectSettingsPreplots,
//raw_data: DougalProjectSettingsNotImplemented,
raw_data_p111: DougalProjectSettingsRawP111,
raw_data_smsrc_header: DougalProjectSettingsSmartsourceHeader,
raw_data_smsrc_segy: DougalProjectSettingsSmartsourceSegy,
raw_data_ntbp: DougalProjectSettingsRawNTBP,
//final_data: DougalProjectSettingsNotImplemented,
final_data_p111: DougalProjectSettingsFinalP111,
final_data_pending: DougalProjectSettingsFinalPending,
line_name_format: DougalProjectSettingsOnlineLineNameFormat,
planner_settings: DougalProjectSettingsPlanner,
logging: DougalProjectSettingsNotImplemented,
logging_preset_comments: DougalProjectSettingsNotImplemented,
logging_labels: DougalProjectSettingsNotImplemented,
asaqc: DougalProjectSettingsASAQC,
production: DougalProjectSettingsProduction
}
export default {
name: "DougalProjectSettings",
components: {
DougalJsonBuilder
},
data () {
return {
configuration: null,
settings: null,
active: [],
open: [],
files: [],
treeview: [
/*
{
id: 0,
name: "Archive",
values: (cfg) => cfg.archived,
save: (data, cfg) => {
cfg.archived = data.archived;
return cfg;
}
},
*/
{
id: "name_id",
name: "Name and ID",
values: (obj) => ({
id: obj?.id,
name: obj?.name
})
},
{
id: "groups",
name: "Groups",
values: (obj) => ({
groups: obj?.groups
})
},
{
id: "geodetics",
name: "Geodetics",
values: (obj) => ({
epsg: obj?.epsg
})
},
{
id: "binning",
name: "Binning",
values: (obj) => ({
...obj.binning
})
},
{
id: "input_files",
name: "Input files",
values: obj => ({ rootPath: obj.rootPath}),
children: [
{
id: "preplots",
name: "Preplots",
values: (obj) => ({
preplots: structuredClone(obj.preplots),
rootPath: obj.rootPath
})
},
{
id: "raw_data",
name: "Raw data",
children: [
{
id: "raw_data_p111",
name: "P1/11",
values: (obj) => ({
rootPath: obj.rootPath,
globs: obj.raw.p111.globs,
paths: obj.raw.p111.paths,
pattern: obj.raw?.p111?.pattern,
lineNameInfo: obj.raw?.p111?.lineNameInfo
})
},
{
id: "raw_data_smsrc",
name: "Smartsource",
children: [
{
id: "raw_data_smsrc_header",
name: "Headers",
values: (obj) => ({
rootPath: obj.rootPath,
globs: obj?.raw?.source?.smsrc?.header?.globs,
paths: obj?.raw?.source?.smsrc?.header?.paths,
pattern: obj?.raw?.source?.smsrc?.header?.pattern,
lineNameInfo: obj?.raw?.source?.smsrc?.header?.lineNameInfo
})
},
{
id: "raw_data_smsrc_segy",
name: "Hydrophone data",
values: (obj) => ({
rootPath: obj.rootPath,
globs: obj?.raw?.source?.smsrc?.segy?.globs,
paths: obj?.raw?.source?.smsrc?.segy?.paths,
pattern: obj?.raw?.source?.smsrc?.segy?.pattern,
lineNameInfo: obj?.raw?.source?.smsrc?.segy?.lineNameInfo
})
}
]
},
{
id: "raw_data_ntbp",
name: "NTBP detection",
values: (obj) => ({
regex: obj.raw.ntbp?.pattern?.regex,
flags: obj.raw.ntbp?.pattern?.flags
})
}
]
},
{
id: "final_data",
name: "Final data",
children: [
{
id: "final_data_p111",
name: "P1/11",
values: (obj) => ({
rootPath: obj.rootPath,
globs: obj.final.p111.globs,
paths: obj.final.p111.paths,
pattern: obj.final.p111.pattern,
lineNameInfo: obj.final?.p111?.lineNameInfo
})
},
{
id: "final_data_pending",
name: "Pending line detection",
values: (obj) => ({
regex: obj.final.pending?.pattern?.regex,
flags: obj.final.pending?.pattern?.flags
})
}
]
},
]
},
{
id: "line_name_format",
name: "Line name format",
values: (obj) => ({
fields: this.makeSection(obj, "online.line.lineNameBuilder.fields", []),
values: this.makeSection(obj, "online.line.lineNameBuilder.values",
Object.fromEntries(
Object.keys(this.settings.lineNameBuilder.properties ?? {}).map( k => [k, undefined] ))),
properties: this.settings.lineNameBuilder.properties ?? {}
}),
update: (obj) => {
const configuration = structuredClone(this.configuration);
deepSet(configuration, ["online", "line", "lineNameBuilder"], obj);
this.configuration = configuration;
}
},
{
id: "planner_settings",
name: "Planner settings",
values: (obj) => ({planner: obj?.planner})
},
{
id: "production",
name: "Production settings",
values: (obj) => ({production: obj?.production})
},
{
id: "cloud_apis",
name: "Cloud APIs",
children: [
{
id: "asaqc",
name: "ASAQC",
values: (obj) => ({value: obj?.cloud?.asaqc}),
}
]
}
],
dirty: false,
fileLoadDialog: false,
viewMode: 0,
dialog: false,
contextMenu: false,
contextMenuX: null,
contextMenuY: null
};
},
watch: {
configuration: {
handler (cur, prev) {
if (cur && prev) {
this.dirty = true;
} else {
this.dirty = false;
}
},
deep: true
},
active (cur, prev) {
if (cur == prev) {
return;
}
if (!this.activeComponent && this.activeItem?.children?.length) {
// Automatically expand this item
if (!this.open.includes(cur)) {
this.open.push(cur);
}
this.$nextTick( () => {
const idx = this.active.findIndex(i => i == cur);
if (idx != -1) {
this.active.push(this.activeItem.children[0].id);
this.active.splice(cur, 1);
}
});
}
}
},
computed: {
activeComponent () {
return components[this.active[0]];
},
activeItem () {
function walk (tree) {
const list = [];
for (const leaf of tree) {
if (leaf.children) {
list.push(...walk(leaf.children));
}
list.push(leaf);
}
return list;
}
return walk(this.treeview).find(i => i.id === this.active[0]);
},
activeValues () {
return this.activeItem?.values &&
this.activeItem.values(this.configuration);
},
activeUpdateHandler () {
return this.activeItem?.update ?? ((obj) => {
console.warn("Unhandled update event on", this.activeItem?.id, obj);
})
},
surveyState: {
get () {
return !this.configuration?.archived;
},
async set (value) {
if (this.configuration) {
await this.patch({archived: !value});
// this.configuration.archived = !value;
}
}
},
fileInputErrors () {
const messages = [];
const validTypes = [
"application/json",
"application/yaml",
"application/x-yaml"
];
if (this.files instanceof File) {
if (!validTypes.includes(this.files.type)) {
messages.push(`Invalid file type: ${this.files.type}`);
messages.push("Please select a JSON or YAML file");
} else if (this.files.size < 32) { // 32 is an arbitrary small value
messages.push("File too small to be a valid Dougal configuration");
}
} else if (this.files && this.files.length) {
messages.push("Invalid file path");
}
return messages;
},
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
},
methods: {
makeSection (obj, path, defaultVaue) {
function reduced (obj = {}, path = []) {
return path.reduce( (acc, cur) =>
{
if (!(cur in acc)) {
acc[cur] = {} ;
};
return acc[cur]
}, obj);
}
if (!obj) {
obj = {};
}
if (typeof path == "string") {
path = path.split(".");
}
let value = reduced(obj, path);
const key = path.pop();
if (!Object.keys(value ?? {}).length && defaultVaue !== undefined) {
reduced(obj, path)[key] = defaultVaue;
}
return reduced(obj, path)[key];
},
async getConfiguration () {
this.configuration = null;
const url = `/project/${this.$route.params.project}/configuration`;
const init = {
headers: {
"If-None-Match": "" // Ensure we get a fresh response
}
};
this.configuration = await this.api([url, init]);
this.dirty = false;
},
async getSettings () {
this.settings = null;
let url = `/project/${this.$route.params.project}/linename/properties`;
const init = {
headers: {
"If-None-Match": "" // Ensure we get a fresh response
}
};
this.settings = {
lineNameBuilder: {
properties: await this.api([url, init])
}
};
},
makeTree (obj, id=0) {
const isObject = typeof obj === "object" && !Array.isArray(obj) && obj !== null;
return isObject
? Object.keys(obj).map(k => {
const children = this.makeTree(obj[k], id); //.filter(i => i !== null);
return {
id: id++,
name: k,
children
}
})
: null;
},
deselect () {
this.active.pop();
},
merge ([path, value]) {
deepSet(this.configuration, path, value);
},
// Use to change the project's archival status
async patch (data) {
const url = `/project/${this.$route.params.project}/configuration`;
const init = {
method: "PATCH",
body: data
};
const callback = async (err, res) => {
if (!err && res.ok) {
this.showSnack(["Configuration saved", "success"]);
}
};
const refreshedConfiguration = await this.api([url, init, callback]);
if (refreshedConfiguration) {
this.configuration = refreshedConfiguration;
}
},
async upload () {
const url = `/project/${this.$route.params.project}/configuration`;
const init = {
method: "PUT",
headers: {
//"If-Match": "" // Ensure we're not overwriting someone else's changes
"Content-Type": "application/json"
},
body: {...this.configuration, archived: true}
};
const res = await this.api([url, init]);
if (res && res.id == this.configuration.id) {
// In case the server decided to apply any changes
this.showSnack(["Configuration uploaded to server", "success"]);
this.$nextTick( () => {
this.configuration = res;
this.$nextTick( () => {
this.dirty = false;
});
});
}
},
async loadFromFile () {
if (!this.fileInputErrors.length) {
if (this.files.type == "application/json") {
this.configuration = JSON.parse(await this.files.text());
this.showSnack(["Configuration loaded from file", "primary"]);
} else if (this.files.type == "application/yaml" || this.files.type == "application/x-yaml") {
this.configuration = YAML.parse(await this.files.text());
this.showSnack(["Configuration loaded from file", "primary"]);
} else {
console.error("Unknown file format (shouldn't happen)", this.files.type);
}
this.fileLoadDialog = false;
}
},
async saveToFile () {
const payload = YAML.stringify(this.configuration);
const blob = new Blob([payload], {type: "application/yaml"});
const url = URL.createObjectURL(blob);
const filename = `${this.$route.params.project}-configuration.yaml`;
const element = document.createElement('a');
element.download = filename;
element.href = url;
element.click();
URL.revokeObjectURL(url);
},
closeDialog () {
},
showContextMenu (e) {
e.preventDefault();
this.contextMenu = false
this.contextMenuX = e.clientX
this.contextMenuY = e.clientY
this.$nextTick(() => {
this.contextMenu = true
})
},
...mapActions(["api", "showSnack"])
},
async mounted () {
this.getSettings();
this.getConfiguration();
},
}
</script>

View File

@@ -712,7 +712,11 @@ export default {
line: this.contextMenuItem.line,
fsp: sp0,
lsp: sp1,
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`,
meta: {
is_reshoot: true,
original_sequence: this.contextMenuItem.sequence
}
}
console.log("Plan", payload);
const url = `/project/${this.$route.params.project}/plan`;

View File

@@ -1,3 +1,5 @@
const webpack = require('webpack');
module.exports = {
"transpileDependencies": [
"vuetify",
@@ -41,6 +43,13 @@ module.exports = {
path: require.resolve("path-browserify")
}
},
plugins: [
// Work around for Buffer is undefined:
// https://github.com/webpack/changelog-v5/issues/10
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
})
],
module: {
rules: [
{

View File

@@ -114,6 +114,7 @@ app.map({
'/project/:project/configuration': {
get: [ mw.project.configuration.get ], // Get project configuration
patch: [ mw.auth.access.admin, mw.project.configuration.patch ], // Modify project configuration
put: [ mw.auth.access.admin, mw.project.configuration.put ], // Overwrite configuration
},
/*
@@ -184,6 +185,17 @@ app.map({
delete: [ mw.auth.access.write, mw.plan.delete ]
},
/*
* Line name endpoints
*/
'/project/:project/linename': {
post: [ mw.linename.post ], // Get a linename
},
'/project/:project/linename/properties': {
get: [ mw.linename.properties.get ], // Get linename properties
},
/*
* Event log endpoints
*/
@@ -311,6 +323,9 @@ app.map({
}
}
},
'/diagnostics/': {
get: [ mw.auth.access.write, mw.etag.noSave, mw.admin.diagnostics.get ]
},
'/rss/': {
get: [ mw.rss.get ]
}
@@ -361,7 +376,9 @@ app.use(function (err, req, res, next) {
});
app.get("*", (req, res, next) => {
res.status(404).send({status: 404, message: "This endpoint does not exist"});
if (!res.headersSent) {
res.status(404).send({status: 404, message: "This endpoint does not exist"});
}
});
app.disable('x-powered-by');

View File

@@ -0,0 +1,17 @@
const diagnostics = require('../../../../lib/diagnostics');
module.exports = async function (req, res, next) {
try {
const d = await diagnostics();
if (req.user?.role != "admin" && req.user?.role != "user") {
}
res.status(200).json(d);
} catch (err) {
next(err);
return;
}
next();
};

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ module.exports = {
files: require('./files'),
plan: require('./plan'),
line: require('./line'),
linename: require('./linename'),
project: require('./project'),
sequence: require('./sequence'),
user: require('./user'),
@@ -18,5 +19,6 @@ module.exports = {
openapi: require('./openapi'),
rss: require('./rss'),
etag: require('./etag'),
version: require('./version')
version: require('./version'),
admin: require('./admin')
};

View File

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

View File

@@ -0,0 +1,21 @@
const { linename } = require('../../../lib/db/linename');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
const line = await linename.post(req.params.project, payload);
if (line) {
res.status(200).type("text/plain").send(line);
} else {
res.status(404).send();
}
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,21 @@
const { linename } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
const properties = await linename.properties.get(req.params.project, payload);
if (properties) {
res.status(200).send(properties);
} else {
res.status(404).send();
}
next();
} catch (err) {
next(err);
}
};

View File

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

View File

@@ -2,7 +2,7 @@
module.exports = {
get: require('./get'),
// post: require('./post'),
// put: require('./put'),
put: require('./put'),
patch: require('./patch'),
// delete: require('./delete'),
};

View File

@@ -0,0 +1,16 @@
const { project } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
// TODO
// Implement If-Match header requirements
res.send(await project.configuration.put(req.params.project, req.body));
next();
} catch (err) {
next(err);
}
};

View File

@@ -2,6 +2,7 @@
module.exports = {
project: require('./project'),
line: require('./line'),
linename: require('./linename'),
sequence: require('./sequence'),
event: require('./event'),
plan: require('./plan'),

View File

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

View File

@@ -0,0 +1,39 @@
const { setSurvey, transaction } = require('../connection');
const lib = require('../plan/lib');
async function post (projectId, payload, opts = {}) {
const client = await setSurvey(projectId);
try {
if (!payload.sequence) {
payload.sequence = await lib.getSequence(client);
}
// if (!payload.ts0 || !payload.ts1) {
// const ts = await lib.getTimestamps(client, projectId, payload);
// if (!payload.ts0) {
// payload.ts0 = ts.ts0;
// }
// if (!payload.ts1) {
// payload.ts1 = ts.ts1;
// }
// }
const name = await lib.getLineName(client, projectId, payload);
return name;
} catch (err) {
if (err.code && Math.trunc(err.code/1000) == 23) {
// Class 23 — Integrity Constraint Violation
console.error(err);
throw { status: 400, message: "Malformed request" };
} else {
throw err;
}
} finally {
client.release();
}
return;
}
module.exports = post;

View File

@@ -0,0 +1,15 @@
const lib = require('../../plan/lib');
async function get (projectId, payload, opts = {}) {
try {
return await lib.getLineNameProperties();
} catch (err) {
throw err;
}
}
module.exports = get;

View File

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

View File

@@ -1,6 +1,12 @@
const YAML = require('yaml');
const fs = require('fs').promises;
const path = require('path');
const alert = require("../../../alerts");
const configuration = require('../../configuration');
let lineNameProperties;
async function getDistance (client, payload) {
const text = `
SELECT ST_Distance(pp0.geometry, pp1.geometry) distance
@@ -88,8 +94,6 @@ async function getPlanned (client) {
async function getLineName (client, projectId, payload) {
// FIXME TODO Get line name script from configuration
// Ref.: https://gitlab.com/wgp/dougal/software/-/issues/129
// This is to monitor #165
// https://gitlab.com/wgp/dougal/software/-/issues/incident/165
@@ -97,6 +101,36 @@ async function getLineName (client, projectId, payload) {
alert({function: "getLineName", client, projectId, payload});
}
const lineNameBuilder = await configuration.get(projectId, "online/line/lineNameBuilder");
const fields = lineNameBuilder?.fields;
if (fields) {
const properties = await getLineNameProperties();
const values = await getLineNameValues(client, projectId, payload, lineNameBuilder?.values);
return buildLineName(properties, fields, values, payload?.name);
} else {
// TODO send a user notification via WS to let them know
// they haven't configured the line name parameters
}
// return undefined
}
/** Get line properties that go into making a line name.
*
* The properties are defined in a separate YAML file for
* convenience.
*/
async function getLineNameProperties () {
if (!lineNameProperties) {
const buffer = await fs.readFile(path.join(__dirname, 'linename-properties.yaml'));
lineNameProperties = YAML.parse(buffer.toString());
}
return lineNameProperties;
}
async function getLineNameValues (client, projectId, payload, otherValues = {}) {
const planned = await getPlanned(client);
const previous = await getSequencesForLine(client, payload.line);
const attempt = planned.filter(r => r.line == payload.line).concat(previous).length;
@@ -104,9 +138,79 @@ async function getLineName (client, projectId, payload) {
const incr = p.lsp > p.fsp;
const sequence = p.sequence || 1;
const line = p.line;
return `${incr?"1":"2"}0${line}${attempt}${sequence.toString().padStart(3, "0")}S00000`;
return {
...structuredClone(otherValues),
line_number: payload.line,
sequence_number: payload.sequence || 1,
original_sequence: payload.meta?.original_sequence,
pass_number: attempt,
is_prime: attempt == 0,
is_reshoot: payload.meta?.is_reshoot ?? (!payload.meta?.is_infill && attempt > 0),
is_infill: payload.meta?.is_infill ?? false,
direction: null, // TODO
is_incrementing: incr
};
}
/** Compute the string representation of a line name field
*/
function fieldValue (properties, field, values) {
let value;
if (field.item == "text") {
value = field.value;
} else if (properties[field.item]?.type == "boolean") {
if (values[field.item] === field.when) {
value = field.value;
}
} else {
value = values[field.item];
}
if (value != null) {
if (properties[field.item]?.type == "number") {
if (field.scale_multiplier != null) {
value *= field.scale_multiplier;
}
if (field.scale_offset != null) {
value += field.scale_offset;
}
if (field.format == "integer") {
value = Math.round(value);
}
}
value = String(value);
if (field.pad_side == "left") {
value = value.padStart(field.length, field.pad_string ?? " ");
} else if (field.pad_side == "right") {
value = value.padEnd(field.length, field.pad_string ?? " ");
}
return value;
}
}
/** Build a line name out of its component properties, fields and values.
*
* NOTE: This is the same function as available client-side on
* `fixed-string-encoder.vue`. Consider merging them.
*/
function buildLineName (properties, fields, values, str = "") {
const length = fields.reduce( (acc, cur) => (cur.offset + cur.length) > acc ? (cur.offset + cur.length) : acc, str.length )
str = str.padEnd(length);
for (const field of fields) {
const value = fieldValue(properties, field, values);
if (value != null) {
str = str.slice(0, field.offset) + value + str.slice(field.offset + field.length);
}
}
return str;
}
module.exports = {
getDistance,
@@ -114,5 +218,8 @@ module.exports = {
getTimestamps,
getSequencesForLine,
getPlanned,
getLineName
getLineNameProperties,
getLineNameValues,
getLineName,
buildLineName
};

View File

@@ -0,0 +1,51 @@
#
# These are the properties that can be used to build
# line names.
#
line_number:
summary: Line number
description: The sailline number that is to be acquired
type: number
format: integer
sequence_number:
summary: Sequence
description: The sequence number that will be assigned to this line
type: number
format: integer
original_sequence:
summary: Original sequence
description: The original sequence number of the line that is being reshot
type: number
format: integer
pass_number:
summary: Pass number
description: The number of times this line, or section of line, has been shot
type: number
format: integer
is_prime:
summary: Prime line
description: Whether this is the first time this line is being acquired
type: boolean
is_reshoot:
summary: Reshoot
description: Whether this is a reshoot (mutually exclusive with `is_prime` and `is_infill`)
type: boolean
is_infill:
summary: Infill line
description: Whether this is an infill line (mutually exclusive with `is_prime` and `is_reshoot`)
type: boolean
direction:
summary: Line azimuth
direction: The line azimuth in the Incrementing shotpoints direction
type: number
format: float
is_incrementing:
summary: Incrementing
description: Whether the line is being shot low to high point numbers or vice versa
type: boolean
text:
summary: Fixed text
description: Arbitrary user-entered text (line prefix, suffix, etc.)
type: text

View File

@@ -2,7 +2,7 @@
module.exports = {
get: require('./get'),
// post: require('./post'),
// put: require('./put'),
put: require('./put'),
patch: require('./patch'),
// delete: require('./delete'),
};

View File

@@ -0,0 +1,58 @@
const { setSurvey } = require('../../connection');
const { deepMerge, removeNulls } = require('../../../utils');
const { modify } = require('../create');
async function put (projectId, payload, opts = {}) {
let client;
try {
client = await setSurvey(); // Use public schema
const text = `
SELECT meta
FROM projects
WHERE pid = $1;
`;
const res = await client.query(text, [projectId]);
const source = res.rows[0].meta;
if (!source) {
throw { status: 404, message: "Not found" };
}
console.log("PAYLOAD ID", payload.id, typeof payload);
if (("id" in payload) && (projectId.toLowerCase() != payload.id.toLowerCase())) {
throw {
status: 422,
message: "Project ID cannot be changed in this Dougal version"
}
}
if (("name" in payload) && source.name && (source.name != payload.name)) {
throw {
status: 422,
message: "Project name cannot be changed in this Dougal version"
}
}
// We do not allow users to change the schema
delete payload.schema;
const dest = removeNulls(payload);
await modify(projectId, dest);
return dest;
} catch (err) {
if (err.code == "42P01") {
throw { status: 404, message: "Not found" };
} else {
throw err;
}
} finally {
client.release();
}
}
module.exports = put;

View File

@@ -0,0 +1,102 @@
const os = require('os');
const { statfs } = require('fs').promises;
const { pool } = require('./db/connection');
const cfg = require('./config');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
/** Return filesystem statistics
*/
async function df (fs="/") {
const s = await statfs(fs);
if (s) {
const total = (s.bsize * s.blocks); // bytes
const free = (s.bfree * s.bsize);
const available = (s.bavail * s.bsize);
const used = total - free;
const usedPercent = used/total * 100
return {
total,
free,
available,
used,
usedPercent
}
}
}
/** Return the size of the Dougal database
*/
async function dbSize () {
const client = await pool.connect();
let res;
try {
res = (await client.query("SELECT pg_database_size(current_database()) size;"))?.rows[0];
} catch (err) {
ERROR(err);
} finally {
client.release();
}
return res;
}
async function dbSchemaSizes () {
const text = `
SELECT pid,
(sum(table_size)::bigint) size,
((sum(table_size) / pg_database_size(current_database())) * 100) percent
FROM (
SELECT pg_catalog.pg_namespace.nspname as schema_name,
pg_relation_size(pg_catalog.pg_class.oid) as table_size
FROM pg_catalog.pg_class
JOIN pg_catalog.pg_namespace ON relnamespace = pg_catalog.pg_namespace.oid
) t
JOIN public.projects p ON schema_name = p.schema
GROUP BY pid
ORDER BY pid
`;
const client = await pool.connect();
let res;
try {
res = (await client.query(text))?.rows;
} catch (err) {
ERROR(err);
} finally {
client.release();
}
return res;
}
async function diagnostics () {
const paths = cfg._("global.imports.paths") ?? {};
const data = {};
for (path in paths) {
data[path] = await df(paths[path]);
}
const res = {
hostname: os.hostname(),
memory: {
total: os.totalmem(),
free: os.freemem()
},
uptime: os.uptime(),
loadavg: os.loadavg(),
networkInterfaces: os.networkInterfaces(),
cpus: os.cpus(),
storage: {
root: await df("/"),
data
},
database: {
...(await dbSize()),
projects: Object.fromEntries((await dbSchemaSizes()).map(row => [ row.pid, {size: row.size, percent: row.percent} ]))
}
};
return res;
}
module.exports = diagnostics;

View File

@@ -0,0 +1,13 @@
function FormatTimestamp (str) {
const d = new Date(str);
if (isNaN(d)) {
return str;
} else {
// Get rid of milliseconds
return d.toISOString().substring(0,19)+"Z";
}
}
module.exports = FormatTimestamp;

View File

@@ -0,0 +1,168 @@
/** Compare two possibly complex values for
* loose equality, going as deep as required in the
* case of complex objects.
*/
function deepCompare (a, b) {
if (typeof a == "object" && typeof b == "object") {
return !Object.entries(a).some( ([k, v]) => !deepCompare(v, b[k])) &&
!Object.entries(b).some( ([k, v]) => !deepCompare(v, a[k]));
} else {
return a == b;
}
}
/** Compare two possibly complex values for
* strict equality.
*/
function deepEqual (a, b) {
if (typeof a === "object" && typeof b === "object") {
return !Object.entries(a).some( ([k, v]) => !deepEqual(v, b[k])) &&
!Object.entries(b).some( ([k, v]) => !deepEqual(v, a[k]));
} else {
return a === b;
}
}
/** Traverses an object and sets a nested value.
*
* Example:
*
* const obj = {a: {b: {c: "X"} } }
* deepSet(obj, ["a", "b", "c"], "d")
* → {a: {b: {c: "d"} } }
*
* This would be the equivalent of:
*
* obj?.a?.b?.c = "d";
*
* Except that the above is not a legal expression.
*
* If a non-leaf property does not exist, this function
* creates it as an empty object ({}) and keeps traversing.
*
* The last member of `path` may be `null`, in which case,
* if the object pointed to by the next to last member is
* an array, an insert operation will take place.
*
*/
function deepSet (obj, path, value) {
const key = path.shift();
if (!path.length) {
if (key === null && Array.isArray(obj)) {
obj.push(value);
} else {
obj[key] = value;
}
} else {
if (!Object.hasOwn(obj, key)) {
obj[key] = {};
}
deepSet(obj[key], path, value);
}
}
/** Returns a nested property.
*
* Example:
*
* const obj = {a: {b: {c: "d"} } }
* deepSet(obj, ["a", "b", "c"])
* → "d"
*
* If `path` is known in advance, this is effectively
* the same as:
*
* obj?.a?.b?.c
*
* This might be useful when `path` is dynamic.
*/
function deepValue (obj, path) {
if (obj !== undefined) {
const key = path.shift();
if (!path.length) {
if (key === undefined) {
return obj;
} else {
return obj[key];
}
} else {
return deepValue(obj[key], path);
}
}
}
// Copied from:
// https://gomakethings.com/how-to-deep-merge-arrays-and-objects-with-javascript/
/*!
* Deep merge two or more objects or arrays.
* (c) 2023 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {*} ...objs The arrays or objects to merge
* @returns {*} The merged arrays or objects
*/
function deepMerge (...objs) {
/**
* Get the object type
* @param {*} obj The object
* @return {String} The object type
*/
function getType (obj) {
return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase();
}
/**
* Deep merge two objects
* @return {Object}
*/
function mergeObj (clone, obj) {
for (let [key, value] of Object.entries(obj)) {
let type = getType(value);
if (clone[key] !== undefined && getType(clone[key]) === type && ['array', 'object'].includes(type)) {
clone[key] = deepMerge(clone[key], value);
} else {
clone[key] = structuredClone(value);
}
}
}
// Create a clone of the first item in the objs array
let clone = structuredClone(objs.shift());
// Loop through each item
for (let obj of objs) {
// Get the object type
let type = getType(obj);
// If the current item isn't the same type as the clone, replace it
if (getType(clone) !== type) {
clone = structuredClone(obj);
continue;
}
// Otherwise, merge
if (type === 'array') {
// Replace old array with new
clone = [...structuredClone(obj)];
} else if (type === 'object') {
mergeObj(clone, obj);
} else {
clone = obj;
}
}
return clone;
}
module.exports = {
deepCompare,
deepEqual,
deepSet,
deepValue,
deepMerge
};

View File

@@ -0,0 +1,47 @@
/** Return an HSL colour as a function of an input value
* `str`.
*
* Consider using as getHSL.bind(this) in Vue components
* in order to get access to the Vuetify theme configuration.
*/
function getHSL (str, saturation = 1, lightness = 0.25, offset = 0) {
function getHash (v) {
if (typeof (v??false)[Symbol.iterator] != "function") {
// Not an iterable, make it one
v = String(v);
}
return Math.abs([...v, ..." "].reduce( (acc, cur) => String(cur).charCodeAt(0) + ((acc << 5) - acc), 0 ));
}
const h = (getHash(str) + offset) % 360;
const s = saturation * 100;
const l = this?.$vuetify?.theme?.isDark
? (1-lightness) * 100
: lightness * 100;
return {h, s, l};
}
/** Return a CSS hsl() or hsla() colour
* representation as a function of an input value.
*
* Consider using as getHSLColourFor.bind(this) See
* note for getHSL() above.
*/
function getHSLColourFor (str, opacity = 1, saturation, lightness, offset) {
const _getHSL = getHSL.bind(this);
const {h, s, l} = _getHSL(str, saturation, lightness, offset);
if (opacity == 1) {
return `hsl(${h},${s}%,${l}%)`;
} else {
return `hsla(${h},${s}%,${l}%, ${opacity})`;
}
}
module.exports = {
getHSL,
getHSLColourFor
}

View File

@@ -1,13 +1,24 @@
module.exports = {
geometryAsString: require('./geometryAsString'),
...require('./deep'),
dms: require('./dms'),
replaceMarkers: require('./replaceMarkers'),
...require('./flatEntries'),
flattenQCDefinitions: require('./flattenQCDefinitions'),
deepMerge: require('./deepMerge'),
FormatTimestamp: require('./FormatTimestamp'),
geometryAsString: require('./geometryAsString'),
...require('./hsl'),
...require('./logicalPath'), // FIXME Breaking change (used to be logicalPath.…)
logicalPath: require('./logicalPath'), // NOTE For compatibility, see above
...require('./markdown'),
preferencesλ: require('./preferencesλ'),
...require('./ranges'), // FIXME Breaking change (used to be ranges.…)
ranges: require('./ranges'), // NOTE For compatibility, see above.
removeNulls: require('./removeNulls'),
logicalPath: require('./logicalPath'),
ranges: require('./ranges'),
replaceMarkers: require('./replaceMarkers'),
setContentDisposition: require('./setContentDisposition'),
throttle: require('./throttle'),
truncateText: require('./truncateText'),
unique: require('./unique'),
setContentDisposition: require('./setContentDisposition')
unpack: require('./unpack'),
withParentProps: require('./withParentProps')
};

View File

@@ -22,7 +22,7 @@ function translatePath (file) {
if (typeof importPaths === "string") {
// Substitute the root for the real physical path
// NOTE: `root` deals with import_paths not being absolute
const prefix = Path.resolve(Path.join(root, importPaths));
const prefix = Path.resolve(root, importPaths);
const suffix = Path.resolve(file).replace(/^\/+/, "");
const physicalPath = Path.resolve(Path.join(prefix, suffix));
return validate(physicalPath, prefix);

View File

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

View File

@@ -0,0 +1,44 @@
/** Extract preferences by prefix.
*
* This function returns a lambda which, given
* a key or a prefix, extracts the relevant
* preferences from the designated preferences
* store.
*
* For instance, assume preferences = {
* "a.b.c.d": 1,
* "a.b.e.f": 2,
* "g.h": 3
* }
*
* And λ = preferencesλ(preferences). Then:
*
* λ("a.b") → { "a.b.c.d": 1, "a.b.e.f": 2 }
* λ("a.b.e.f") → { "a.b.e.f": 2 }
* λ("g.x", {"g.x.": 99}) → { "g.x.": 99 }
* λ("a.c", {"g.x.": 99}) → { "g.x.": 99 }
*
* Note from the last two examples that a default value
* may be provided and will be returned if a key does
* not exist or is not searched for.
*/
function preferencesλ (preferences) {
return function (key, defaults={}) {
const keys = Object.keys(preferences).filter(str => str.startsWith(key+".") || str == key);
const settings = {...defaults};
for (const str of keys) {
const k = str == key ? str : str.substring(key.length+1);
const v = preferences[str];
settings[k] = v;
}
return settings;
}
}
module.exports = preferencesλ;

View File

@@ -0,0 +1,33 @@
/**
* Throttle a function call.
*
* It delays `callback` by `delay` ms and ignores any
* repeated calls from `caller` within at most `maxWait`
* milliseconds.
*
* Used to react to server events in cases where we get
* a separate notification for each row of a bulk update.
*/
function throttle (callback, caller, delay = 100, maxWait = 500) {
const schedule = async () => {
caller.triggeredAt = Date.now();
caller.timer = setTimeout(async () => {
await callback();
caller.timer = null;
}, delay);
}
if (!caller.timer) {
schedule();
} else {
const elapsed = Date.now() - caller.triggeredAt;
if (elapsed > maxWait) {
cancelTimeout(caller.timer);
schedule();
}
}
}
module.exports = throttle;

View File

@@ -0,0 +1,10 @@
function truncateText (text, length=20) {
if (text?.length <= length) {
return text;
} else {
return text.slice(0, length/2)+"…"+text.slice(-(length/2));
}
}
module.exports = truncateText;

View File

@@ -0,0 +1,35 @@
/** Unpacks attributes from array items.
*
* At it simplest, given an array of objects,
* the call unpack(rows, "x") returns an array
* of the "x" attribute of every item in rows.
*
* `key` may also be:
*
* - a function with the signature
* (Object) => any
* the result of applying the function to
* the object will be used as the unpacked
* value.
*
* - an array of strings, functions or other
* arrays. In this case, it does a recursive
* fold operation. NOTE: it mutates `key`.
*
*/
function unpack(rows, key) {
if (typeof key === "function") {
return rows && rows.map( row => key(row) );
} else if (Array.isArray(key)) {
const car = key.shift();
if (key.length) {
return unpack(unpack(rows, car), key);
} else {
return unpack(rows, car);
}
} else {
return rows && rows.map( row => row?.[key] );
}
};
module.exports = unpack;

Some files were not shown because too many files have changed in this diff Show More