@geode/opengeodeweb-front 10.39.0 → 10.40.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.build/server/utils/cleanup.js +13 -34
- package/.build/server/utils/path.js +1 -1
- package/.build/server/utils/ws_client.js +32 -13
- package/.build/server/utils/wslink_chunking.d.js +1 -0
- package/.build/shared/utils/call_raw.js +15 -3
- package/app/components/DragAndDrop.vue +84 -5
- package/app/components/FileSelector.vue +20 -4
- package/app/components/FileUploader.vue +8 -0
- package/app/components/MissingFilesSelector.vue +76 -4
- package/app/components/Screenshot.vue +1 -1
- package/app/components/TimeSeriesTargetSelector.vue +70 -0
- package/app/composables/clipping_planes_widget_entries.ts +6 -4
- package/app/stores/camera_manager.ts +4 -9
- package/app/stores/data.ts +6 -12
- package/app/stores/data_helpers/collections.ts +3 -6
- package/app/stores/data_helpers/mesh.ts +4 -5
- package/app/stores/viewer.ts +4 -3
- package/app/utils/import_workflow.ts +26 -1
- package/app/utils/live_observable.ts +14 -0
- package/app/utils/upload_path.ts +75 -0
- package/internal/utils/upload_file.ts +2 -1
- package/package.json +6 -5
- package/server/utils/cleanup.ts +16 -42
- package/server/utils/ws_client.ts +57 -29
- package/server/utils/wslink_chunking.d.ts +12 -0
- package/shared/utils/call_raw.ts +25 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { createServerWsRpcClient } from "./ws_client.js";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { consola } from "consola";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import { setTimeout } from "node:timers/promises";
|
|
5
|
-
import { WebSocket } from "ws";
|
|
6
6
|
import pTimeout from "p-timeout";
|
|
7
7
|
import { rimraf } from "rimraf";
|
|
8
8
|
//#region server/utils/cleanup.ts
|
|
@@ -51,40 +51,19 @@ async function killWebsocketMicroservice(microservice) {
|
|
|
51
51
|
const successMessage = `Disconnected from ${microservice.name} WebSocket server`;
|
|
52
52
|
async function do_kill() {
|
|
53
53
|
if (microservice.url === void 0) return;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
socket.on("open", () => {
|
|
58
|
-
consola.info("Connected to WebSocket server");
|
|
59
|
-
socket.send(JSON.stringify({
|
|
60
|
-
id: "system:hello",
|
|
61
|
-
method: "wslink.hello",
|
|
62
|
-
args: [{ secret: "wslink-secret" }]
|
|
63
|
-
}));
|
|
64
|
-
});
|
|
65
|
-
socket.on("message", (data) => {
|
|
66
|
-
const message = data.toString();
|
|
67
|
-
consola.debug("Received from server:", message);
|
|
68
|
-
if (message.includes("hello")) {
|
|
69
|
-
socket.send(JSON.stringify({
|
|
70
|
-
id: "application.exit",
|
|
71
|
-
method: "application.exit"
|
|
72
|
-
}));
|
|
73
|
-
consola.info(successMessage);
|
|
74
|
-
socket.close();
|
|
75
|
-
resolve();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
socket.on("close", () => {
|
|
79
|
-
consola.info(successMessage);
|
|
80
|
-
resolve();
|
|
81
|
-
});
|
|
82
|
-
socket.on("error", (error) => {
|
|
83
|
-
consola.error("WebSocket error:", error);
|
|
84
|
-
socket.close();
|
|
85
|
-
resolve();
|
|
86
|
-
});
|
|
54
|
+
const client = createServerWsRpcClient(microservice.url);
|
|
55
|
+
const closed = new Promise((resolve) => {
|
|
56
|
+
client.onConnectionClose(resolve);
|
|
87
57
|
});
|
|
58
|
+
try {
|
|
59
|
+
await client.notify("application.exit");
|
|
60
|
+
await closed;
|
|
61
|
+
consola.info(successMessage);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
consola.error("WebSocket error:", error);
|
|
64
|
+
} finally {
|
|
65
|
+
client.close();
|
|
66
|
+
}
|
|
88
67
|
}
|
|
89
68
|
await pTimeout(do_kill(), {
|
|
90
69
|
milliseconds: 5e3,
|
|
@@ -4,8 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
import { consola } from "consola";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import { setTimeout } from "node:timers/promises";
|
|
7
|
-
import os from "node:os";
|
|
8
7
|
import { v4 } from "uuid";
|
|
8
|
+
import os from "node:os";
|
|
9
9
|
//#region server/utils/path.ts
|
|
10
10
|
function executableName(execName) {
|
|
11
11
|
if (process.platform === "win32") return `${execName}.exe`;
|
|
@@ -1,39 +1,47 @@
|
|
|
1
|
+
import { Decoder, encode } from "@msgpack/msgpack";
|
|
2
|
+
import { UnChunker, generateChunks } from "wslink/src/WebsocketConnection/chunking.js";
|
|
1
3
|
import { WebSocket } from "ws";
|
|
2
4
|
import { v4 } from "uuid";
|
|
3
5
|
//#region server/utils/ws_client.ts
|
|
4
6
|
const HELLO_ID = "system:hello";
|
|
5
7
|
const HELLO_SECRET = "wslink-secret";
|
|
8
|
+
const DEFAULT_MAX_MSG_SIZE = 524288;
|
|
6
9
|
function isWsRpcMessage(value) {
|
|
7
10
|
return typeof value === "object" && value !== null;
|
|
8
11
|
}
|
|
9
|
-
function
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
return Buffer.from(raw).toString();
|
|
12
|
+
function rawDataToBlob(raw) {
|
|
13
|
+
if (raw instanceof ArrayBuffer) return new Blob([raw]);
|
|
14
|
+
return new Blob([Uint8Array.from(Array.isArray(raw) ? Buffer.concat(raw) : raw)]);
|
|
13
15
|
}
|
|
14
16
|
function createServerWsRpcClient(baseUrl) {
|
|
15
17
|
const socket = new WebSocket(baseUrl);
|
|
16
18
|
const pending = /* @__PURE__ */ new Map();
|
|
19
|
+
const unChunker = new UnChunker();
|
|
20
|
+
let maxMsgSize = DEFAULT_MAX_MSG_SIZE;
|
|
17
21
|
let onCloseCallback = void 0;
|
|
18
22
|
let onErrorCallback = void 0;
|
|
23
|
+
function send(message) {
|
|
24
|
+
for (const chunk of generateChunks(encode(message), maxMsgSize)) socket.send(chunk);
|
|
25
|
+
}
|
|
19
26
|
const ready = new Promise((resolve, reject) => {
|
|
20
27
|
socket.on("open", () => {
|
|
21
|
-
|
|
28
|
+
send({
|
|
22
29
|
id: HELLO_ID,
|
|
23
30
|
method: "wslink.hello",
|
|
24
31
|
args: [{ secret: HELLO_SECRET }]
|
|
25
|
-
})
|
|
32
|
+
});
|
|
26
33
|
});
|
|
27
|
-
|
|
34
|
+
async function handleMessage(raw) {
|
|
28
35
|
let message = void 0;
|
|
29
36
|
try {
|
|
30
|
-
const
|
|
31
|
-
if (!isWsRpcMessage(
|
|
32
|
-
message =
|
|
37
|
+
const decoded = await unChunker.processChunk(rawDataToBlob(raw), () => new Decoder());
|
|
38
|
+
if (!isWsRpcMessage(decoded)) return;
|
|
39
|
+
message = decoded;
|
|
33
40
|
} catch {
|
|
34
41
|
return;
|
|
35
42
|
}
|
|
36
43
|
if (message.id === HELLO_ID) {
|
|
44
|
+
maxMsgSize = message.result?.maxMsgSize ?? maxMsgSize;
|
|
37
45
|
resolve();
|
|
38
46
|
return;
|
|
39
47
|
}
|
|
@@ -43,12 +51,16 @@ function createServerWsRpcClient(baseUrl) {
|
|
|
43
51
|
pending.delete(message.id);
|
|
44
52
|
if (message.error) entry.reject(new Error(message.error.message ?? "wslink RPC error"));
|
|
45
53
|
else entry.resolve(message.result);
|
|
54
|
+
}
|
|
55
|
+
socket.on("message", (raw) => {
|
|
56
|
+
handleMessage(raw);
|
|
46
57
|
});
|
|
47
58
|
socket.on("error", (error) => {
|
|
48
59
|
onErrorCallback?.(error);
|
|
49
60
|
reject(error);
|
|
50
61
|
});
|
|
51
62
|
socket.on("close", () => {
|
|
63
|
+
reject(/* @__PURE__ */ new Error("WebSocket closed"));
|
|
52
64
|
onCloseCallback?.();
|
|
53
65
|
for (const { reject: rejectPending } of pending.values()) rejectPending(/* @__PURE__ */ new Error("WebSocket closed"));
|
|
54
66
|
pending.clear();
|
|
@@ -62,13 +74,19 @@ function createServerWsRpcClient(baseUrl) {
|
|
|
62
74
|
resolve,
|
|
63
75
|
reject
|
|
64
76
|
});
|
|
65
|
-
|
|
66
|
-
wslink: "1.0",
|
|
77
|
+
send({
|
|
67
78
|
id,
|
|
68
79
|
method: rpc,
|
|
69
80
|
args: [params],
|
|
70
81
|
kwargs: { stream: true }
|
|
71
|
-
})
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async function notify(method) {
|
|
86
|
+
await ready;
|
|
87
|
+
send({
|
|
88
|
+
id: method,
|
|
89
|
+
method
|
|
72
90
|
});
|
|
73
91
|
}
|
|
74
92
|
function close() {
|
|
@@ -93,6 +111,7 @@ function createServerWsRpcClient(baseUrl) {
|
|
|
93
111
|
close,
|
|
94
112
|
getConnection,
|
|
95
113
|
isOpen,
|
|
114
|
+
notify,
|
|
96
115
|
onConnectionClose,
|
|
97
116
|
onConnectionError,
|
|
98
117
|
ready
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import pTimeout from "p-timeout";
|
|
2
|
-
import "lodash";
|
|
2
|
+
import _ from "lodash";
|
|
3
3
|
//#region shared/utils/call_raw.ts
|
|
4
|
+
function isPlainRecord(value) {
|
|
5
|
+
return _.isPlainObject(value);
|
|
6
|
+
}
|
|
7
|
+
function omitUndefinedFields(record) {
|
|
8
|
+
function omitUndefined(value) {
|
|
9
|
+
if (Array.isArray(value)) return value.map((item) => omitUndefined(item));
|
|
10
|
+
if (isPlainRecord(value)) return omitUndefinedFields(value);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0).map(([key, value]) => [key, omitUndefined(value)]));
|
|
14
|
+
}
|
|
4
15
|
async function callClient({ rpc, params = {}, client }) {
|
|
5
|
-
|
|
6
|
-
return await client.call(rpc,
|
|
16
|
+
const definedParams = omitUndefinedFields(params);
|
|
17
|
+
if (globalThis.window !== void 0) return await client.getConnection().getSession().call(rpc, [definedParams]);
|
|
18
|
+
return await client.call(rpc, definedParams);
|
|
7
19
|
}
|
|
8
20
|
async function callRaw({ rpc, params = {}, client, timeout }, { request_error_function, response_function, response_error_function } = {}) {
|
|
9
21
|
async function performCall() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { onMounted, onUnmounted, ref } from "vue";
|
|
3
2
|
import DragAndDropInline from "./DragAndDropInternal/DragAndDropInline.vue";
|
|
4
3
|
import DragAndDropOverlay from "./DragAndDropInternal/DragAndDropOverlay.vue";
|
|
4
|
+
import type { UploadFile } from "@ogw_front/utils/upload_path";
|
|
5
5
|
|
|
6
6
|
interface DragAndDropTexts {
|
|
7
7
|
idle: string;
|
|
@@ -18,6 +18,7 @@ interface Props {
|
|
|
18
18
|
inline?: boolean;
|
|
19
19
|
showOverlay?: boolean;
|
|
20
20
|
texts?: DragAndDropTexts;
|
|
21
|
+
directory?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const {
|
|
@@ -33,10 +34,20 @@ const {
|
|
|
33
34
|
drop: "Drop files here",
|
|
34
35
|
loading: "Loading...",
|
|
35
36
|
},
|
|
37
|
+
directory = false,
|
|
38
|
+
// oxlint-disable-next-line vue/max-props
|
|
36
39
|
} = defineProps<Props>();
|
|
37
40
|
|
|
41
|
+
const displayed_texts = computed<DragAndDropTexts>(() =>
|
|
42
|
+
directory
|
|
43
|
+
? { ...texts, idle: "Click or drag and drop a folder", drop: "Drop the folder here" }
|
|
44
|
+
: texts,
|
|
45
|
+
);
|
|
46
|
+
|
|
38
47
|
const emit = defineEmits<{
|
|
39
|
-
"files-selected": [files:
|
|
48
|
+
"files-selected": [files: UploadFile[]];
|
|
49
|
+
"folders-ignored": [];
|
|
50
|
+
"folder-read-error": [message: string];
|
|
40
51
|
}>();
|
|
41
52
|
|
|
42
53
|
const isDragging = ref(false);
|
|
@@ -115,10 +126,70 @@ function onDragOver(event: DragEvent): void {
|
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
async function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
|
130
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
131
|
+
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
|
132
|
+
reader.readEntries(resolve, reject);
|
|
133
|
+
});
|
|
134
|
+
if (batch.length === 0) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
return [...batch, ...(await readAllEntries(reader))];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function filesFromEntry(entry: FileSystemEntry, path: string): Promise<UploadFile[]> {
|
|
141
|
+
if (entry.isFile) {
|
|
142
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
143
|
+
const file = await new Promise<File>((resolve, reject) => {
|
|
144
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- isFile guarantees a FileSystemFileEntry
|
|
145
|
+
(entry as FileSystemFileEntry).file(resolve, reject);
|
|
146
|
+
});
|
|
147
|
+
return [Object.assign(file, { relativePath: path })];
|
|
148
|
+
}
|
|
149
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- not a file, so a FileSystemDirectoryEntry
|
|
150
|
+
const children = await readAllEntries((entry as FileSystemDirectoryEntry).createReader());
|
|
151
|
+
const nested = await Promise.all(
|
|
152
|
+
children.map((child) => filesFromEntry(child, `${path}/${child.name}`)),
|
|
153
|
+
);
|
|
154
|
+
return nested.flat();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function emitDroppedEntries(entries: FileSystemEntry[]): Promise<void> {
|
|
158
|
+
try {
|
|
159
|
+
const groups = await Promise.all(
|
|
160
|
+
entries.map(async (entry) => {
|
|
161
|
+
const files = await filesFromEntry(entry, entry.name);
|
|
162
|
+
// Files inside a dropped folder are all needed: never filtered out.
|
|
163
|
+
return entry.isDirectory ? files : files.filter((file) => isFileAccepted(file, accept));
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const files = groups.flat();
|
|
167
|
+
if (files.length > 0) {
|
|
168
|
+
emit("files-selected", files);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
emit("folder-read-error", message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
118
176
|
function onDrop(event: DragEvent): void {
|
|
119
177
|
event.preventDefault();
|
|
120
178
|
dragCounter.value = 0;
|
|
121
179
|
isDragging.value = false;
|
|
180
|
+
// Entries must be read synchronously: the DataTransfer is emptied once the event returns.
|
|
181
|
+
const entries = [...(event.dataTransfer?.items ?? [])]
|
|
182
|
+
.map((item) => item.webkitGetAsEntry?.())
|
|
183
|
+
.filter((entry): entry is FileSystemEntry => entry !== null && entry !== undefined);
|
|
184
|
+
if (entries.some((entry) => entry.isDirectory)) {
|
|
185
|
+
if (directory) {
|
|
186
|
+
void emitDroppedEntries(entries);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
emit("folders-ignored");
|
|
190
|
+
void emitDroppedEntries(entries.filter((entry) => entry.isFile));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
122
193
|
const files = [...(event.dataTransfer?.files ?? [])].filter((file) =>
|
|
123
194
|
isFileAccepted(file, accept),
|
|
124
195
|
);
|
|
@@ -181,7 +252,7 @@ defineExpose({ triggerFileDialog });
|
|
|
181
252
|
v-if="inline"
|
|
182
253
|
:is-dragging
|
|
183
254
|
:loading
|
|
184
|
-
:texts
|
|
255
|
+
:texts="displayed_texts"
|
|
185
256
|
:accept
|
|
186
257
|
:show-extensions
|
|
187
258
|
@click="triggerFileDialog"
|
|
@@ -193,13 +264,21 @@ defineExpose({ triggerFileDialog });
|
|
|
193
264
|
:show-overlay
|
|
194
265
|
:fullscreen
|
|
195
266
|
:loading
|
|
196
|
-
:texts
|
|
267
|
+
:texts="displayed_texts"
|
|
197
268
|
:multiple
|
|
198
269
|
:accept
|
|
199
270
|
:show-extensions
|
|
200
271
|
/>
|
|
201
272
|
|
|
202
|
-
<input
|
|
273
|
+
<input
|
|
274
|
+
ref="fileInput"
|
|
275
|
+
type="file"
|
|
276
|
+
class="d-none"
|
|
277
|
+
:multiple
|
|
278
|
+
:accept
|
|
279
|
+
:webkitdirectory="directory"
|
|
280
|
+
@change="handleFileSelect"
|
|
281
|
+
/>
|
|
203
282
|
</template>
|
|
204
283
|
|
|
205
284
|
<style>
|
|
@@ -8,7 +8,7 @@ import { useBackStore } from "@ogw_front/stores/back";
|
|
|
8
8
|
const schema = schemas.opengeodeweb_back.allowed_files;
|
|
9
9
|
|
|
10
10
|
interface Emits {
|
|
11
|
-
update_values: [values: { files: unknown[]; autoUpload: boolean }];
|
|
11
|
+
update_values: [values: { files: unknown[]; autoUpload: boolean; time_series?: string[] }];
|
|
12
12
|
increment_step: [];
|
|
13
13
|
decrement_step: [];
|
|
14
14
|
}
|
|
@@ -20,13 +20,21 @@ interface Props {
|
|
|
20
20
|
files?: File[];
|
|
21
21
|
autoUpload?: boolean;
|
|
22
22
|
showOverlay?: boolean;
|
|
23
|
+
timeSeries?: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const {
|
|
26
|
+
const {
|
|
27
|
+
multiple,
|
|
28
|
+
files = [],
|
|
29
|
+
autoUpload = true,
|
|
30
|
+
showOverlay = true,
|
|
31
|
+
timeSeries = false,
|
|
32
|
+
} = defineProps<Props>();
|
|
26
33
|
|
|
27
34
|
const internal_files = ref<File[]>(files);
|
|
28
35
|
const internal_auto_upload = ref<boolean>(autoUpload);
|
|
29
36
|
const accept = ref<string>("");
|
|
37
|
+
const time_series = ref<string[]>([]);
|
|
30
38
|
const loading = ref<boolean>(false);
|
|
31
39
|
|
|
32
40
|
watch(
|
|
@@ -47,7 +55,12 @@ const toggle_loading = useToggle(loading);
|
|
|
47
55
|
|
|
48
56
|
function files_uploaded_event(value: unknown[]): void {
|
|
49
57
|
if (value.length > 0) {
|
|
50
|
-
emit(
|
|
58
|
+
emit(
|
|
59
|
+
"update_values",
|
|
60
|
+
timeSeries
|
|
61
|
+
? { files: value, autoUpload: false, time_series: time_series.value }
|
|
62
|
+
: { files: value, autoUpload: false },
|
|
63
|
+
);
|
|
51
64
|
emit("increment_step");
|
|
52
65
|
}
|
|
53
66
|
}
|
|
@@ -56,7 +69,10 @@ async function get_allowed_files(): Promise<void> {
|
|
|
56
69
|
toggle_loading();
|
|
57
70
|
const backStore = useBackStore();
|
|
58
71
|
const response = await backStore.request({ schema });
|
|
59
|
-
|
|
72
|
+
time_series.value = timeSeries ? response.time_series_extensions : [];
|
|
73
|
+
accept.value = [...response.data_extensions, ...time_series.value]
|
|
74
|
+
.map((extension) => `.${extension}`)
|
|
75
|
+
.join(",");
|
|
60
76
|
toggle_loading();
|
|
61
77
|
}
|
|
62
78
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { foldersIgnoredWarning } from "@ogw_front/utils/upload_path";
|
|
2
3
|
import { useBackStore } from "@ogw_front/stores/back";
|
|
4
|
+
import { useFeedbackStore } from "@ogw_front/stores/feedback";
|
|
3
5
|
|
|
4
6
|
import CsvPreviewer from "@ogw_front/components/csv-preview/CsvPreviewer.vue";
|
|
5
7
|
import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
|
|
@@ -72,6 +74,10 @@ async function onCsvConfirm(result: unknown): Promise<void> {
|
|
|
72
74
|
csv_dialog.value = false;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function warnFoldersIgnored(): void {
|
|
78
|
+
useFeedbackStore().add_warning(foldersIgnoredWarning);
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
function processSelectedFiles(selected_files: UploadFile[]): void {
|
|
76
82
|
if (multiple) {
|
|
77
83
|
internal_files.value = [...internal_files.value, ...selected_files];
|
|
@@ -154,6 +160,7 @@ watch(
|
|
|
154
160
|
:accept
|
|
155
161
|
:inline="false"
|
|
156
162
|
@files-selected="processSelectedFiles"
|
|
163
|
+
@folders-ignored="warnFoldersIgnored"
|
|
157
164
|
/>
|
|
158
165
|
</template>
|
|
159
166
|
<DragAndDrop
|
|
@@ -166,6 +173,7 @@ watch(
|
|
|
166
173
|
:inline="!internal_files.length"
|
|
167
174
|
:show-overlay="showOverlay"
|
|
168
175
|
@files-selected="processSelectedFiles"
|
|
176
|
+
@folders-ignored="warnFoldersIgnored"
|
|
169
177
|
/>
|
|
170
178
|
|
|
171
179
|
<v-card-text v-if="internal_files.length" class="mt-6 pa-0">
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js";
|
|
3
3
|
|
|
4
|
+
import { matchExpectedFiles, uploadPath } from "@ogw_front/utils/upload_path";
|
|
5
|
+
import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
|
|
4
6
|
import FetchingData from "@ogw_front/components/FetchingData.vue";
|
|
5
7
|
import FileUploader from "@ogw_front/components/FileUploader.vue";
|
|
6
8
|
import { useBackStore } from "@ogw_front/stores/back";
|
|
9
|
+
import { useFeedbackStore } from "@ogw_front/stores/feedback";
|
|
7
10
|
|
|
8
11
|
// Files carry extra app-specific bookkeeping fields once picked up here.
|
|
9
|
-
type UploadFile = File & { isConfigured?: boolean };
|
|
12
|
+
type UploadFile = File & { isConfigured?: boolean; relativePath?: string };
|
|
10
13
|
interface FilePlan {
|
|
11
14
|
has_missing_files: boolean;
|
|
12
15
|
mandatory_files: string[];
|
|
13
16
|
additional_files: string[];
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const schema = schemas.opengeodeweb_back.
|
|
19
|
+
const schema = schemas.opengeodeweb_back.data_missing_files;
|
|
20
|
+
const time_series_schema = schemas.opengeodeweb_back.time_series_missing_files;
|
|
17
21
|
|
|
18
22
|
interface Emits {
|
|
19
23
|
update_values: [value: { additional_files: UploadFile[] }];
|
|
@@ -28,9 +32,16 @@ interface Props {
|
|
|
28
32
|
geodeObjectType: string;
|
|
29
33
|
filenames: string[];
|
|
30
34
|
files?: UploadFile[];
|
|
35
|
+
timeSeries?: boolean;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
const {
|
|
38
|
+
const {
|
|
39
|
+
multiple,
|
|
40
|
+
geodeObjectType,
|
|
41
|
+
filenames,
|
|
42
|
+
files = [],
|
|
43
|
+
timeSeries = false,
|
|
44
|
+
} = defineProps<Props>();
|
|
34
45
|
|
|
35
46
|
const accept = ref<string>("");
|
|
36
47
|
const loading = ref<boolean>(false);
|
|
@@ -38,6 +49,7 @@ const has_missing_files = ref<boolean>(false);
|
|
|
38
49
|
const mandatory_files = ref<string[]>([]);
|
|
39
50
|
const additional_files = ref<string[]>([]);
|
|
40
51
|
const toggle_loading = useToggle(loading);
|
|
52
|
+
const uploading = ref<boolean>(false);
|
|
41
53
|
|
|
42
54
|
function files_uploaded_event(value: UploadFile[]): void {
|
|
43
55
|
emit("update_values", { additional_files: value });
|
|
@@ -63,6 +75,9 @@ async function missing_files(): Promise<void> {
|
|
|
63
75
|
additional_files: [],
|
|
64
76
|
});
|
|
65
77
|
}
|
|
78
|
+
if (timeSeries) {
|
|
79
|
+
return backStore.request({ schema: time_series_schema, params: { filename } });
|
|
80
|
+
}
|
|
66
81
|
const params = { geode_object_type: geodeObjectType, filename };
|
|
67
82
|
return backStore.request({ schema, params });
|
|
68
83
|
});
|
|
@@ -88,12 +103,61 @@ async function missing_files(): Promise<void> {
|
|
|
88
103
|
toggle_loading();
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
// Time series: uploads the missing files found in the selected folder, then the files they
|
|
107
|
+
// Reference in turn, until nothing is missing. The folder layout does not matter.
|
|
108
|
+
async function upload_missing_from(
|
|
109
|
+
folder_files: UploadFile[],
|
|
110
|
+
uploaded: UploadFile[],
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const expected = [...mandatory_files.value, ...additional_files.value];
|
|
113
|
+
const match = matchExpectedFiles(
|
|
114
|
+
folder_files.map((file) => uploadPath(file)),
|
|
115
|
+
expected,
|
|
116
|
+
);
|
|
117
|
+
if (match.status === "ambiguous") {
|
|
118
|
+
useFeedbackStore().add_warning(
|
|
119
|
+
`Several matching files for ${match.path}, select a more specific folder`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const found = expected.flatMap((expected_path, index) => {
|
|
124
|
+
const file = folder_files[match.indices[index] ?? -1];
|
|
125
|
+
return file && !uploaded.includes(file)
|
|
126
|
+
? [Object.assign(file, { relativePath: expected_path })]
|
|
127
|
+
: [];
|
|
128
|
+
});
|
|
129
|
+
if (found.length === 0) {
|
|
130
|
+
useFeedbackStore().add_warning(`The selected folder does not contain: ${expected.join(", ")}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const backStore = useBackStore();
|
|
134
|
+
await Promise.all(found.map((file) => backStore.upload(file)));
|
|
135
|
+
emit("update_values", { additional_files: [...uploaded, ...found] });
|
|
136
|
+
await missing_files();
|
|
137
|
+
if (has_missing_files.value) {
|
|
138
|
+
await upload_missing_from(folder_files, [...uploaded, ...found]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function upload_folder(folder_files: UploadFile[]): Promise<void> {
|
|
143
|
+
uploading.value = true;
|
|
144
|
+
try {
|
|
145
|
+
await upload_missing_from(folder_files, []);
|
|
146
|
+
} finally {
|
|
147
|
+
uploading.value = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function warn_folder_read_error(message: string): void {
|
|
152
|
+
useFeedbackStore().add_warning(`Could not read the dropped folder: ${message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
91
155
|
// oxlint-disable-next-line no-top-level-await
|
|
92
156
|
await missing_files();
|
|
93
157
|
</script>
|
|
94
158
|
|
|
95
159
|
<template>
|
|
96
|
-
<FetchingData v-if="loading" />
|
|
160
|
+
<FetchingData v-if="loading || uploading" />
|
|
97
161
|
<v-container v-else-if="has_missing_files">
|
|
98
162
|
<v-row v-if="mandatory_files.length" align="center">
|
|
99
163
|
<v-col cols="auto" class="pa-0">
|
|
@@ -115,7 +179,15 @@ await missing_files();
|
|
|
115
179
|
</v-row>
|
|
116
180
|
<v-row>
|
|
117
181
|
<v-col cols="12">
|
|
182
|
+
<DragAndDrop
|
|
183
|
+
v-if="timeSeries"
|
|
184
|
+
directory
|
|
185
|
+
:show-extensions="false"
|
|
186
|
+
@files-selected="upload_folder"
|
|
187
|
+
@folder-read-error="warn_folder_read_error"
|
|
188
|
+
/>
|
|
118
189
|
<FileUploader
|
|
190
|
+
v-else
|
|
119
191
|
v-bind="{ multiple, accept, files, autoUpload: false }"
|
|
120
192
|
@files_uploaded="files_uploaded_event"
|
|
121
193
|
/>
|
|
@@ -44,7 +44,7 @@ async function takeScreenshot(): Promise<void> {
|
|
|
44
44
|
params,
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
|
-
// `blob` is
|
|
47
|
+
// `blob` is the image file content, received as binary (Uint8Array) through wslink
|
|
48
48
|
response_function: async ({ blob }) => {
|
|
49
49
|
if (screenshot_type.value === "file") {
|
|
50
50
|
fileDownload(blob, `${current_filename}.${output_extension.value}`);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js";
|
|
3
|
+
|
|
4
|
+
import FetchingData from "@ogw_front/components/FetchingData.vue";
|
|
5
|
+
import { useBackStore } from "@ogw_front/stores/back";
|
|
6
|
+
import { useDataStore } from "@ogw_front/stores/data";
|
|
7
|
+
|
|
8
|
+
const schema = schemas.opengeodeweb_back.time_series_allowed_objects;
|
|
9
|
+
|
|
10
|
+
interface Target {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
geode_object_type: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
update_values: [values: { target_id: string; geode_object_type: string }];
|
|
18
|
+
increment_step: [];
|
|
19
|
+
decrement_step: [];
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const { filename } = defineProps<{ filename: string }>();
|
|
23
|
+
|
|
24
|
+
const loading = ref(true);
|
|
25
|
+
const targets = ref<Target[]>([]);
|
|
26
|
+
|
|
27
|
+
async function get_targets(): Promise<void> {
|
|
28
|
+
const backStore = useBackStore();
|
|
29
|
+
const dataStore = useDataStore();
|
|
30
|
+
const [{ allowed_objects }, items] = await Promise.all([
|
|
31
|
+
backStore.request({ schema, params: { filename } }),
|
|
32
|
+
dataStore.allItems(),
|
|
33
|
+
]);
|
|
34
|
+
targets.value = items
|
|
35
|
+
.filter((item) => allowed_objects.includes(item.geode_object_type))
|
|
36
|
+
.map(({ id, name, geode_object_type }) => ({ id, name, geode_object_type }));
|
|
37
|
+
loading.value = false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function select(target: Target): void {
|
|
41
|
+
emit("update_values", { target_id: target.id, geode_object_type: target.geode_object_type });
|
|
42
|
+
emit("increment_step");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// oxlint-disable-next-line no-top-level-await
|
|
46
|
+
await get_targets();
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<FetchingData v-if="loading" />
|
|
51
|
+
<v-alert
|
|
52
|
+
v-else-if="targets.length === 0"
|
|
53
|
+
data-testid="noTimeSeriesTarget"
|
|
54
|
+
type="warning"
|
|
55
|
+
variant="tonal"
|
|
56
|
+
>
|
|
57
|
+
No compatible model loaded. Import a model (BRep) first, then drop the time series again.
|
|
58
|
+
</v-alert>
|
|
59
|
+
<v-list v-else bg-color="transparent">
|
|
60
|
+
<v-list-item
|
|
61
|
+
v-for="target in targets"
|
|
62
|
+
:key="target.id"
|
|
63
|
+
data-testid="timeSeriesTarget"
|
|
64
|
+
:title="target.name"
|
|
65
|
+
:subtitle="target.geode_object_type"
|
|
66
|
+
prepend-icon="mdi-cube-outline"
|
|
67
|
+
@click="select(target)"
|
|
68
|
+
/>
|
|
69
|
+
</v-list>
|
|
70
|
+
</template>
|
|
@@ -47,6 +47,11 @@ function asWidgetFactory(widget: PlaneWidget): vtkAbstractWidgetFactory<vtkAbstr
|
|
|
47
47
|
return widget as unknown as vtkAbstractWidgetFactory<vtkAbstractWidget>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function asWidgetHandle(widget: unknown): WidgetHandle {
|
|
51
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- vtk.js WidgetManager.addWidget is untyped for our custom WidgetHandle shape; narrowed here.
|
|
52
|
+
return widget as WidgetHandle;
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
interface WidgetEntryManagerParams {
|
|
51
56
|
planes: Ref<ClippingPlane[]>;
|
|
52
57
|
debouncedApply: (...args: readonly unknown[]) => void;
|
|
@@ -136,10 +141,7 @@ function useWidgetEntryManager({ planes, debouncedApply }: WidgetEntryManagerPar
|
|
|
136
141
|
if (!widgetEntries[idx]) {
|
|
137
142
|
// oxlint-disable-next-line no-unsafe-call no-unsafe-type-assertion -- vtk.js has no types for ImplicitPlaneWidget's factory; narrowed here.
|
|
138
143
|
const planeWidget = vtkImplicitPlaneWidget() as PlaneWidget;
|
|
139
|
-
const widgetHandle = widgetManager.addWidget(
|
|
140
|
-
asWidgetFactory(planeWidget),
|
|
141
|
-
// oxlint-disable-next-line no-unsafe-type-assertion -- vtk.js WidgetManager.addWidget is untyped for our custom WidgetHandle shape; narrowed here.
|
|
142
|
-
) as unknown as WidgetHandle;
|
|
144
|
+
const widgetHandle = asWidgetHandle(widgetManager.addWidget(asWidgetFactory(planeWidget)));
|
|
143
145
|
widgetEntries.push(createWidgetEntry(planeWidget, widgetHandle, idx));
|
|
144
146
|
}
|
|
145
147
|
const entry = widgetEntries[idx];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Observable } from "rxjs";
|
|
1
|
+
import type { Table } from "dexie";
|
|
3
2
|
import { useObservable } from "@vueuse/rxjs";
|
|
4
3
|
|
|
5
4
|
// Local imports
|
|
6
5
|
import type { CameraOptions } from "@ogw_internal/stores/hybrid_viewer/vtk_types.js";
|
|
7
6
|
import { database } from "@ogw_internal/database/database.js";
|
|
7
|
+
import { liveObservable } from "@ogw_front/utils/live_observable";
|
|
8
8
|
import { useViewerStore } from "@ogw_front/stores/viewer";
|
|
9
9
|
import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js";
|
|
10
10
|
|
|
@@ -26,16 +26,11 @@ export const useCameraManagerStore = defineStore("camera_manager", () => {
|
|
|
26
26
|
>;
|
|
27
27
|
|
|
28
28
|
function refAllCameraPositions(): Ref<CameraPositionRecord[]> {
|
|
29
|
-
// Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
|
|
30
|
-
// Actual rxjs Observable instance (useObservable's declared parameter type);
|
|
31
|
-
// The two are structurally close enough at runtime (vueuse only calls
|
|
32
|
-
// `.subscribe`) but not identical, hence the cast.
|
|
33
29
|
return useObservable(
|
|
34
|
-
|
|
30
|
+
liveObservable(async () => {
|
|
35
31
|
const positions = await camera_positions_db.toArray();
|
|
36
32
|
return positions;
|
|
37
|
-
|
|
38
|
-
}) as unknown as Observable<CameraPositionRecord[]>,
|
|
33
|
+
}),
|
|
39
34
|
{ initialValue: [] as CameraPositionRecord[] },
|
|
40
35
|
);
|
|
41
36
|
}
|
package/app/stores/data.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// oxlint-disable eslint/max-lines
|
|
2
2
|
// Third party imports
|
|
3
|
-
import {
|
|
4
|
-
import type { Observable } from "rxjs";
|
|
3
|
+
import type { Table } from "dexie";
|
|
5
4
|
import { useObservable } from "@vueuse/rxjs";
|
|
6
5
|
import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js";
|
|
7
6
|
|
|
8
7
|
// Local imports
|
|
9
8
|
import { type ModelComponentRecord, useDataMesh } from "./data_helpers/mesh.js";
|
|
10
9
|
import { database } from "@ogw_internal/database/database.js";
|
|
10
|
+
import { liveObservable } from "@ogw_front/utils/live_observable";
|
|
11
11
|
import { useDataCollections } from "./data_helpers/collections.js";
|
|
12
12
|
import { useViewerStore } from "@ogw_front/stores/viewer";
|
|
13
13
|
|
|
@@ -137,16 +137,11 @@ export const useDataStore = defineStore("data", () => {
|
|
|
137
137
|
return items;
|
|
138
138
|
}
|
|
139
139
|
function refItem(id: string): Ref<DataItem | undefined> {
|
|
140
|
-
// Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
|
|
141
|
-
// Actual rxjs Observable instance (useObservable's declared parameter type);
|
|
142
|
-
// The two are structurally close enough at runtime (vueuse only calls
|
|
143
|
-
// `.subscribe`) but not identical, hence the cast.
|
|
144
140
|
return useObservable(
|
|
145
|
-
|
|
141
|
+
liveObservable(async () => {
|
|
146
142
|
const data_item = await data_db.get(id);
|
|
147
143
|
return data_item;
|
|
148
|
-
|
|
149
|
-
}) as unknown as Observable<DataItem | undefined>,
|
|
144
|
+
}),
|
|
150
145
|
{
|
|
151
146
|
// oxlint-disable-next-line no-unsafe-type-assertion -- placeholder until the live query resolves; consumers must treat this as possibly incomplete.
|
|
152
147
|
initialValue: {} as DataItem,
|
|
@@ -155,11 +150,10 @@ export const useDataStore = defineStore("data", () => {
|
|
|
155
150
|
}
|
|
156
151
|
function refAllItems(): Ref<DataItem[]> {
|
|
157
152
|
return useObservable(
|
|
158
|
-
|
|
153
|
+
liveObservable(async () => {
|
|
159
154
|
const items = await data_db.toArray();
|
|
160
155
|
return items;
|
|
161
|
-
|
|
162
|
-
}) as unknown as Observable<DataItem[]>,
|
|
156
|
+
}),
|
|
163
157
|
{
|
|
164
158
|
initialValue: [] as DataItem[],
|
|
165
159
|
},
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { type FormattedComponent, type ModelComponentRecord, useDataMesh } from "./mesh.js";
|
|
2
2
|
import { MESH_COMPONENT_TYPES } from "@ogw_front/utils/default_styles";
|
|
3
|
-
import type { Observable } from "rxjs";
|
|
4
3
|
import type { Ref } from "vue";
|
|
5
4
|
import { getTable } from "@ogw_internal/database/database.js";
|
|
6
|
-
import {
|
|
5
|
+
import { liveObservable } from "@ogw_front/utils/live_observable";
|
|
7
6
|
import { useObservable } from "@vueuse/rxjs";
|
|
8
7
|
|
|
9
8
|
interface ModelComponentRelationRecord {
|
|
@@ -115,12 +114,10 @@ export function useDataCollections(): {
|
|
|
115
114
|
modelId: string,
|
|
116
115
|
): Ref<CollectionComponentGroup[] | undefined> {
|
|
117
116
|
return useObservable(
|
|
118
|
-
|
|
119
|
-
liveQuery(async () => {
|
|
117
|
+
liveObservable(async () => {
|
|
120
118
|
const groups = await formatedCollectionComponents(modelId);
|
|
121
119
|
return groups;
|
|
122
|
-
|
|
123
|
-
}) as unknown as Observable<CollectionComponentGroup[]>,
|
|
120
|
+
}),
|
|
124
121
|
{
|
|
125
122
|
initialValue: undefined,
|
|
126
123
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Observable } from "rxjs";
|
|
1
|
+
import type { Table } from "dexie";
|
|
3
2
|
import { database } from "@ogw_internal/database/database.js";
|
|
3
|
+
import { liveObservable } from "@ogw_front/utils/live_observable";
|
|
4
4
|
import { useObservable } from "@vueuse/rxjs";
|
|
5
5
|
|
|
6
6
|
interface ModelComponentRecord {
|
|
@@ -87,11 +87,10 @@ export function useDataMesh(): {
|
|
|
87
87
|
|
|
88
88
|
function refFormatedMeshComponents(modelId: string): Ref<FormattedComponentGroup[] | undefined> {
|
|
89
89
|
return useObservable(
|
|
90
|
-
|
|
90
|
+
liveObservable(async () => {
|
|
91
91
|
const components = await formatedMeshComponents(modelId);
|
|
92
92
|
return components;
|
|
93
|
-
|
|
94
|
-
}) as unknown as Observable<FormattedComponentGroup[]>,
|
|
93
|
+
}),
|
|
95
94
|
{
|
|
96
95
|
initialValue: undefined,
|
|
97
96
|
},
|
package/app/stores/viewer.ts
CHANGED
|
@@ -142,12 +142,13 @@ export const useViewerStore = defineStore(
|
|
|
142
142
|
}
|
|
143
143
|
try {
|
|
144
144
|
status.value = Status.CONNECTING;
|
|
145
|
-
|
|
145
|
+
const wsClient = await initWebSocketClient(base_url.value, client.value, {
|
|
146
146
|
onConnectionClose: () => {
|
|
147
147
|
status.value = Status.NOT_CONNECTED;
|
|
148
148
|
},
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
});
|
|
150
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- initWebSocketClient's return is not typed as RpcClient.
|
|
151
|
+
client.value = wsClient as unknown as RpcClient;
|
|
151
152
|
connectImageStream(client.value.getConnection().getSession());
|
|
152
153
|
// oxlint-disable-next-line no-unsafe-type-assertion -- endBusy is not part of the RpcClient type.
|
|
153
154
|
(client.value as unknown as { endBusy: () => void }).endBusy();
|
|
@@ -150,4 +150,29 @@ async function importWorkflowFromSnapshot(items: readonly NewDataItem[]): Promis
|
|
|
150
150
|
});
|
|
151
151
|
return ids;
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
|
|
154
|
+
// The time series only adds attributes to the model, so the viewer object is reloaded under the same id with its stored style.
|
|
155
|
+
async function applyTimeSeriesWorkflow(filename: string, id: string): Promise<string> {
|
|
156
|
+
const backStore = useBackStore();
|
|
157
|
+
const dataStore = useDataStore();
|
|
158
|
+
const dataStyleStore = useDataStyleStore();
|
|
159
|
+
const hybridViewerStore = useHybridViewerStore();
|
|
160
|
+
await backStore.request({
|
|
161
|
+
schema: back_schemas.opengeodeweb_back.apply_time_series,
|
|
162
|
+
params: { id, filename },
|
|
163
|
+
});
|
|
164
|
+
const item = await dataStore.item(id);
|
|
165
|
+
await dataStore.deregisterObject(id);
|
|
166
|
+
await dataStore.registerObject(id, item.name ?? id);
|
|
167
|
+
await dataStyleStore.applyDefaultStyle(id);
|
|
168
|
+
await hybridViewerStore.remoteRender();
|
|
169
|
+
return id;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export {
|
|
173
|
+
importFile,
|
|
174
|
+
importWorkflow,
|
|
175
|
+
importWorkflowFromSnapshot,
|
|
176
|
+
importItem,
|
|
177
|
+
applyTimeSeriesWorkflow,
|
|
178
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Third party imports
|
|
2
|
+
import type { Observable } from "rxjs";
|
|
3
|
+
import { liveQuery } from "dexie";
|
|
4
|
+
|
|
5
|
+
// Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
|
|
6
|
+
// Actual rxjs Observable instance (useObservable's declared parameter type);
|
|
7
|
+
// The two are structurally close enough at runtime (vueuse only calls
|
|
8
|
+
// `.subscribe`) but not identical, hence the cast.
|
|
9
|
+
function liveObservable<Value>(query: () => Promise<Value>): Observable<Value> {
|
|
10
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- trusted vueuse/Dexie Observable boundary; see comment above.
|
|
11
|
+
return liveQuery(query) as unknown as Observable<Value>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { liveObservable };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Files carry extra app-specific bookkeeping fields once picked up by the uploader.
|
|
2
|
+
type UploadFile = File & { relativePath?: string; isConfigured?: boolean; displayName?: string };
|
|
3
|
+
|
|
4
|
+
// Path of the file inside the back upload folder: keeps the folder tree of a dropped directory.
|
|
5
|
+
function uploadPath(file: UploadFile): string {
|
|
6
|
+
for (const candidate of [file.relativePath, file.webkitRelativePath]) {
|
|
7
|
+
if (candidate !== undefined && candidate !== "") {
|
|
8
|
+
return candidate;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return file.name;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fileExtension(path: string): string {
|
|
15
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
16
|
+
const index = name.lastIndexOf(".");
|
|
17
|
+
return index === -1 ? "" : name.slice(index + 1).toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type FileMatch =
|
|
21
|
+
| { status: "matched"; indices: (number | undefined)[] }
|
|
22
|
+
| { status: "ambiguous"; path: string };
|
|
23
|
+
|
|
24
|
+
// Number of folders the two paths have in common, in order: 0 when the file names differ.
|
|
25
|
+
function matchScore(path: string, expected_path: string): number {
|
|
26
|
+
const segments = path.split("/");
|
|
27
|
+
const expected_segments = expected_path.split("/");
|
|
28
|
+
if (segments.at(-1) !== expected_segments.at(-1)) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
// Longest common subsequence of the path segments
|
|
32
|
+
let previous = Array.from({ length: expected_segments.length + 1 }, () => 0);
|
|
33
|
+
for (const segment of segments) {
|
|
34
|
+
const current = [0];
|
|
35
|
+
for (const [index, expected_segment] of expected_segments.entries()) {
|
|
36
|
+
current.push(
|
|
37
|
+
segment === expected_segment
|
|
38
|
+
? (previous[index] ?? 0) + 1
|
|
39
|
+
: Math.max(previous[index + 1] ?? 0, current[index] ?? 0),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
previous = current;
|
|
43
|
+
}
|
|
44
|
+
return previous.at(-1) ?? 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Finds each expected file in a selected folder, whatever the folder layout: a selected file is
|
|
48
|
+
// Kept for an expected path when each is the other's closest match (most folders in common).
|
|
49
|
+
// Gives, for each expected path, the index of its selected path (undefined when not found).
|
|
50
|
+
function matchExpectedFiles(paths: readonly string[], expected: readonly string[]): FileMatch {
|
|
51
|
+
const scores = expected.map((expected_path) =>
|
|
52
|
+
paths.map((path) => matchScore(path, expected_path)),
|
|
53
|
+
);
|
|
54
|
+
const path_best_scores = paths.map((_path, index) =>
|
|
55
|
+
Math.max(0, ...scores.map((expected_scores) => expected_scores[index] ?? 0)),
|
|
56
|
+
);
|
|
57
|
+
const indices: (number | undefined)[] = [];
|
|
58
|
+
for (const [expected_index, expected_scores] of scores.entries()) {
|
|
59
|
+
const best = Math.max(0, ...expected_scores);
|
|
60
|
+
const candidates = expected_scores.flatMap((score, index) =>
|
|
61
|
+
best > 0 && score === best && path_best_scores[index] === best ? [index] : [],
|
|
62
|
+
);
|
|
63
|
+
if (candidates.length > 1) {
|
|
64
|
+
return { status: "ambiguous", path: expected[expected_index] ?? "" };
|
|
65
|
+
}
|
|
66
|
+
indices.push(candidates[0]);
|
|
67
|
+
}
|
|
68
|
+
return { status: "matched", indices };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Shared by every drop zone that refuses folders.
|
|
72
|
+
const foldersIgnoredWarning = "Folders can't be dropped here";
|
|
73
|
+
|
|
74
|
+
export { fileExtension, foldersIgnoredWarning, matchExpectedFiles, uploadPath };
|
|
75
|
+
export type { UploadFile };
|
|
@@ -3,6 +3,7 @@ import { CHUNK_SIZE_BYTES } from "@ogw_shared/utils/file.js";
|
|
|
3
3
|
import type { Microservice } from "./api_fetch.js";
|
|
4
4
|
import { consola } from "consola";
|
|
5
5
|
import { fetchRaw } from "@ogw_shared/utils/fetch_raw.js";
|
|
6
|
+
import { uploadPath } from "@ogw_front/utils/upload_path";
|
|
6
7
|
import { useFeedbackStore } from "@ogw_front/stores/feedback.js";
|
|
7
8
|
|
|
8
9
|
interface UploadFileParams {
|
|
@@ -86,7 +87,7 @@ async function upload_file(
|
|
|
86
87
|
const chunk = file.slice(start, start + CHUNK_SIZE_BYTES);
|
|
87
88
|
const query = new URLSearchParams({
|
|
88
89
|
...params,
|
|
89
|
-
filename: file
|
|
90
|
+
filename: uploadPath(file),
|
|
90
91
|
chunk_index: String(chunkIndex),
|
|
91
92
|
total_chunks: String(totalChunks),
|
|
92
93
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geode/opengeodeweb-front",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.40.0-rc.2",
|
|
4
4
|
"description": "OpenSource Vue/Nuxt/Pinia/Vuetify framework for web applications",
|
|
5
5
|
"homepage": "https://github.com/Geode-solutions/OpenGeodeWeb-Front",
|
|
6
6
|
"bugs": {
|
|
@@ -64,11 +64,12 @@
|
|
|
64
64
|
"prepublishOnly": "npm run build"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@geode/cloud-api": "
|
|
68
|
-
"@geode/opengeodeweb-back": "
|
|
69
|
-
"@geode/opengeodeweb-viewer": "
|
|
67
|
+
"@geode/cloud-api": "next",
|
|
68
|
+
"@geode/opengeodeweb-back": "next",
|
|
69
|
+
"@geode/opengeodeweb-viewer": "next",
|
|
70
70
|
"@kitware/vtk.js": "33.3.0",
|
|
71
71
|
"@mdi/font": "7.4.47",
|
|
72
|
+
"@msgpack/msgpack": "2.8.0",
|
|
72
73
|
"@pinia/nuxt": "1.0.1",
|
|
73
74
|
"@types/busboy": "1.5.4",
|
|
74
75
|
"@types/node": "22.20.3",
|
|
@@ -101,7 +102,7 @@
|
|
|
101
102
|
"vuetify": "4.2.0",
|
|
102
103
|
"vuetify-nuxt-module": "1.0.0-rc.5",
|
|
103
104
|
"ws": "8.21.2",
|
|
104
|
-
"wslink": "
|
|
105
|
+
"wslink": "2.5.0",
|
|
105
106
|
"zod": "4.4.3"
|
|
106
107
|
},
|
|
107
108
|
"devDependencies": {
|
package/server/utils/cleanup.ts
CHANGED
|
@@ -4,11 +4,13 @@ import path from "node:path";
|
|
|
4
4
|
import { setTimeout } from "node:timers/promises";
|
|
5
5
|
|
|
6
6
|
// Third party imports
|
|
7
|
-
import { WebSocket } from "ws";
|
|
8
7
|
import { consola } from "consola";
|
|
9
8
|
import pTimeout from "p-timeout";
|
|
10
9
|
import { rimraf } from "rimraf";
|
|
11
10
|
|
|
11
|
+
// Local imports
|
|
12
|
+
import { createServerWsRpcClient } from "./ws_client.js";
|
|
13
|
+
|
|
12
14
|
interface Microservice {
|
|
13
15
|
type: "back" | "viewer";
|
|
14
16
|
name: string;
|
|
@@ -88,49 +90,21 @@ async function killWebsocketMicroservice(microservice: Microservice): Promise<vo
|
|
|
88
90
|
if (microservice.url === undefined) {
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
|
-
const
|
|
93
|
+
const client = createServerWsRpcClient(microservice.url);
|
|
92
94
|
// oxlint-disable-next-line promise/avoid-new
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
socket.on("open", () => {
|
|
96
|
-
consola.info("Connected to WebSocket server");
|
|
97
|
-
socket.send(
|
|
98
|
-
JSON.stringify({
|
|
99
|
-
id: "system:hello",
|
|
100
|
-
method: "wslink.hello",
|
|
101
|
-
args: [
|
|
102
|
-
{
|
|
103
|
-
secret: "wslink-secret",
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
});
|
|
109
|
-
socket.on("message", (data: Buffer | string) => {
|
|
110
|
-
const message = data.toString();
|
|
111
|
-
consola.debug("Received from server:", message);
|
|
112
|
-
if (message.includes("hello")) {
|
|
113
|
-
socket.send(
|
|
114
|
-
JSON.stringify({
|
|
115
|
-
id: "application.exit",
|
|
116
|
-
method: "application.exit",
|
|
117
|
-
}),
|
|
118
|
-
);
|
|
119
|
-
consola.info(successMessage);
|
|
120
|
-
socket.close();
|
|
121
|
-
resolve();
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
socket.on("close", () => {
|
|
125
|
-
consola.info(successMessage);
|
|
126
|
-
resolve();
|
|
127
|
-
});
|
|
128
|
-
socket.on("error", (error: Error) => {
|
|
129
|
-
consola.error("WebSocket error:", error);
|
|
130
|
-
socket.close();
|
|
131
|
-
resolve();
|
|
132
|
-
});
|
|
95
|
+
const closed = new Promise<void>((resolve) => {
|
|
96
|
+
client.onConnectionClose(resolve);
|
|
133
97
|
});
|
|
98
|
+
try {
|
|
99
|
+
await client.notify("application.exit");
|
|
100
|
+
// Wslink handles messages asynchronously: closing right away would drop the exit request
|
|
101
|
+
await closed;
|
|
102
|
+
consola.info(successMessage);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
consola.error("WebSocket error:", error);
|
|
105
|
+
} finally {
|
|
106
|
+
client.close();
|
|
107
|
+
}
|
|
134
108
|
}
|
|
135
109
|
await pTimeout(do_kill(), {
|
|
136
110
|
milliseconds: 5000,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Third party imports
|
|
2
|
+
import { Decoder, encode } from "@msgpack/msgpack";
|
|
3
|
+
import { UnChunker, generateChunks } from "wslink/src/WebsocketConnection/chunking.js";
|
|
2
4
|
import { WebSocket } from "ws";
|
|
3
5
|
import { v4 as uuidv4 } from "uuid";
|
|
4
6
|
|
|
@@ -6,6 +8,8 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
6
8
|
|
|
7
9
|
const HELLO_ID = "system:hello";
|
|
8
10
|
const HELLO_SECRET = "wslink-secret";
|
|
11
|
+
// Wslink default (512 KiB), until the hello response gives the server's limit
|
|
12
|
+
const DEFAULT_MAX_MSG_SIZE = 524_288;
|
|
9
13
|
|
|
10
14
|
interface PendingCall {
|
|
11
15
|
resolve: (value: unknown) => void;
|
|
@@ -15,7 +19,7 @@ interface PendingCall {
|
|
|
15
19
|
interface WsRpcMessage {
|
|
16
20
|
id?: string;
|
|
17
21
|
error?: { message?: string };
|
|
18
|
-
result?: unknown
|
|
22
|
+
result?: { maxMsgSize?: number } & Record<string, unknown>;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
function isWsRpcMessage(value: unknown): value is WsRpcMessage {
|
|
@@ -23,14 +27,11 @@ function isWsRpcMessage(value: unknown): value is WsRpcMessage {
|
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// oxlint-disable-next-line prefer-readonly-parameter-types
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
return raw
|
|
30
|
+
function rawDataToBlob(raw: WebSocket.RawData): Blob {
|
|
31
|
+
if (raw instanceof ArrayBuffer) {
|
|
32
|
+
return new Blob([raw]);
|
|
29
33
|
}
|
|
30
|
-
|
|
31
|
-
return Buffer.concat(raw).toString();
|
|
32
|
-
}
|
|
33
|
-
return Buffer.from(raw).toString();
|
|
34
|
+
return new Blob([Uint8Array.from(Array.isArray(raw) ? Buffer.concat(raw) : raw)]);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
interface ServerWsRpcClient {
|
|
@@ -42,6 +43,7 @@ interface ServerWsRpcClient {
|
|
|
42
43
|
};
|
|
43
44
|
};
|
|
44
45
|
isOpen: () => boolean;
|
|
46
|
+
notify: (method: string) => Promise<void>;
|
|
45
47
|
onConnectionClose: (callback: () => void) => void;
|
|
46
48
|
onConnectionError: (callback: (error: unknown) => void) => void;
|
|
47
49
|
ready: Promise<void>;
|
|
@@ -51,35 +53,43 @@ interface ServerWsRpcClient {
|
|
|
51
53
|
function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
|
|
52
54
|
const socket = new WebSocket(baseUrl);
|
|
53
55
|
const pending = new Map<string, PendingCall>();
|
|
56
|
+
// Wslink 2 messages are msgpack-encoded and split into chunks
|
|
57
|
+
const unChunker = new UnChunker();
|
|
58
|
+
let maxMsgSize = DEFAULT_MAX_MSG_SIZE;
|
|
54
59
|
let onCloseCallback: (() => void) | undefined = undefined;
|
|
55
60
|
let onErrorCallback: ((error: unknown) => void) | undefined = undefined;
|
|
56
61
|
|
|
62
|
+
function send(message: object): void {
|
|
63
|
+
for (const chunk of generateChunks(encode(message), maxMsgSize)) {
|
|
64
|
+
socket.send(chunk);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
//oxlint-disable-next-line promise/avoid-new max-lines-per-function
|
|
58
69
|
const ready = new Promise<void>((resolve, reject) => {
|
|
59
70
|
socket.on("open", () => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
71
|
+
send({
|
|
72
|
+
id: HELLO_ID,
|
|
73
|
+
method: "wslink.hello",
|
|
74
|
+
args: [{ secret: HELLO_SECRET }],
|
|
75
|
+
});
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
// oxlint-disable-next-line prefer-readonly-parameter-types
|
|
70
|
-
|
|
79
|
+
async function handleMessage(raw: WebSocket.RawData): Promise<void> {
|
|
71
80
|
let message: WsRpcMessage | undefined = undefined;
|
|
72
81
|
try {
|
|
73
|
-
const
|
|
74
|
-
if (!isWsRpcMessage(
|
|
82
|
+
const decoded = await unChunker.processChunk(rawDataToBlob(raw), () => new Decoder());
|
|
83
|
+
if (!isWsRpcMessage(decoded)) {
|
|
75
84
|
return;
|
|
76
85
|
}
|
|
77
|
-
message =
|
|
86
|
+
message = decoded;
|
|
78
87
|
} catch {
|
|
79
88
|
return;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
if (message.id === HELLO_ID) {
|
|
92
|
+
maxMsgSize = message.result?.maxMsgSize ?? maxMsgSize;
|
|
83
93
|
resolve();
|
|
84
94
|
return;
|
|
85
95
|
}
|
|
@@ -98,6 +108,11 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
|
|
|
98
108
|
} else {
|
|
99
109
|
entry.resolve(message.result);
|
|
100
110
|
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types
|
|
114
|
+
socket.on("message", (raw: WebSocket.RawData) => {
|
|
115
|
+
void handleMessage(raw);
|
|
101
116
|
});
|
|
102
117
|
|
|
103
118
|
socket.on("error", (error: Error) => {
|
|
@@ -106,6 +121,7 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
|
|
|
106
121
|
});
|
|
107
122
|
|
|
108
123
|
socket.on("close", () => {
|
|
124
|
+
reject(new Error("WebSocket closed"));
|
|
109
125
|
onCloseCallback?.();
|
|
110
126
|
for (const { reject: rejectPending } of pending.values()) {
|
|
111
127
|
rejectPending(new Error("WebSocket closed"));
|
|
@@ -120,18 +136,21 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
|
|
|
120
136
|
//oxlint-disable-next-line promise/avoid-new
|
|
121
137
|
return new Promise((resolve, reject) => {
|
|
122
138
|
pending.set(id, { resolve, reject });
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
kwargs: { stream: true },
|
|
130
|
-
}),
|
|
131
|
-
);
|
|
139
|
+
send({
|
|
140
|
+
id,
|
|
141
|
+
method: rpc,
|
|
142
|
+
args: [params],
|
|
143
|
+
kwargs: { stream: true },
|
|
144
|
+
});
|
|
132
145
|
});
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
// Call an RPC without arguments nor waiting for its response (e.g. "application.exit")
|
|
149
|
+
async function notify(method: string): Promise<void> {
|
|
150
|
+
await ready;
|
|
151
|
+
send({ id: method, method });
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
function close(): void {
|
|
136
155
|
socket.close();
|
|
137
156
|
}
|
|
@@ -161,7 +180,16 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
|
|
|
161
180
|
onErrorCallback = callback;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
call,
|
|
185
|
+
close,
|
|
186
|
+
getConnection,
|
|
187
|
+
isOpen,
|
|
188
|
+
notify,
|
|
189
|
+
onConnectionClose,
|
|
190
|
+
onConnectionError,
|
|
191
|
+
ready,
|
|
192
|
+
};
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
export { createServerWsRpcClient };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Wslink ships no types for its chunking module; this describes what ws_client.ts uses.
|
|
2
|
+
declare module "wslink/src/WebsocketConnection/chunking.js" {
|
|
3
|
+
interface Decoder {
|
|
4
|
+
decode: (buffer: Uint8Array) => unknown;
|
|
5
|
+
}
|
|
6
|
+
function generateChunks(message: Uint8Array, maxSize: number): Generator<Uint8Array>;
|
|
7
|
+
class UnChunker {
|
|
8
|
+
public processChunk(chunk: Blob, decoderFactory: () => Decoder): Promise<unknown>;
|
|
9
|
+
public releasePendingMessages(): void;
|
|
10
|
+
}
|
|
11
|
+
export { UnChunker, generateChunks };
|
|
12
|
+
}
|
package/shared/utils/call_raw.ts
CHANGED
|
@@ -26,12 +26,35 @@ interface CallRawOptions extends CallClientOptions {
|
|
|
26
26
|
timeout?: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return _.isPlainObject(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Wslink encodes messages with msgpack, which sends undefined values as null where JSON omitted them
|
|
34
|
+
function omitUndefinedFields(record: object): Record<string, unknown> {
|
|
35
|
+
function omitUndefined(value: unknown): unknown {
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return value.map((item: unknown) => omitUndefined(item));
|
|
38
|
+
}
|
|
39
|
+
if (isPlainRecord(value)) {
|
|
40
|
+
return omitUndefinedFields(value);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
Object.entries(record)
|
|
46
|
+
.filter(([, value]) => value !== undefined)
|
|
47
|
+
.map(([key, value]) => [key, omitUndefined(value)]),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
async function callClient({ rpc, params = {}, client }: CallClientOptions): Promise<unknown> {
|
|
52
|
+
const definedParams = omitUndefinedFields(params);
|
|
30
53
|
if (globalThis.window !== undefined) {
|
|
31
|
-
const response = await client.getConnection().getSession().call(rpc, [
|
|
54
|
+
const response = await client.getConnection().getSession().call(rpc, [definedParams]);
|
|
32
55
|
return response;
|
|
33
56
|
}
|
|
34
|
-
const response = await client.call(rpc,
|
|
57
|
+
const response = await client.call(rpc, definedParams);
|
|
35
58
|
return response;
|
|
36
59
|
}
|
|
37
60
|
|