@quazardous/qdadm 2.31.0 → 2.33.0
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/dist/styles/qdadm.css +1 -1
- package/package.json +8 -2
- package/src/components/display/EmailPreview.vue +227 -0
- package/src/components/display/EntityTimeline.vue +408 -0
- package/src/components/display/LogStream.vue +364 -0
- package/src/components/display/PayloadDiff.vue +236 -0
- package/src/components/display/PayloadViewer.vue +246 -0
- package/src/components/index.ts +8 -0
- package/src/components/layout/AppLayout.vue +31 -11
- package/src/components/layout/defaults/DefaultMenu.vue +23 -11
- package/src/components/tasks/TaskDialog.vue +95 -0
- package/src/components/tasks/TaskHost.vue +45 -0
- package/src/components/tasks/TaskTray.vue +54 -0
- package/src/composables/index.ts +11 -0
- package/src/composables/useListPage.filters.ts +26 -1
- package/src/composables/useLogStream.ts +264 -0
- package/src/composables/useNavigation.ts +39 -3
- package/src/email/index.ts +46 -0
- package/src/entity/storage/SdkStorage.ts +53 -1
- package/src/entity/storage/index.ts +2 -0
- package/src/i18n/defaults/core.en.generated.ts +85 -0
- package/src/i18n/defaults/core.en.yml +85 -0
- package/src/i18n/defaults/core.fr.generated.ts +85 -0
- package/src/i18n/defaults/core.fr.yml +85 -0
- package/src/index.ts +21 -0
- package/src/kernel/Kernel.vue.ts +9 -2
- package/src/kernel/KernelContext.types.ts +11 -1
- package/src/module/moduleRegistry.ts +17 -1
- package/src/styles/_email.scss +188 -0
- package/src/styles/_log-stream.scss +167 -0
- package/src/styles/_payload.scss +312 -0
- package/src/styles/_sidebar.scss +8 -0
- package/src/styles/_tasks.scss +212 -0
- package/src/styles/_timeline.scss +229 -0
- package/src/styles/index.scss +5 -0
- package/src/tasks/taskStore.ts +460 -0
- package/src/tasks/useTaskRunner.ts +52 -0
- package/src/utils/ansi.ts +127 -0
- package/src/utils/email.ts +270 -0
- package/src/utils/index.ts +32 -0
- package/src/utils/payload.ts +480 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLogStream - the line buffer behind LogStream (#4110)
|
|
3
|
+
*
|
|
4
|
+
* Reads a backend log as it is written, from one of four sources the app
|
|
5
|
+
* picks, and keeps the lines parsed once (ANSI styles, error flag) so the
|
|
6
|
+
* view only slices them.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* // Offsets: GET /tasks/42/log?from=<n> → { lines, next, done }
|
|
10
|
+
* useLogStream({ poll: (from) => api.get(`/tasks/42/log`, { params: { from } }).then((r) => r.data) })
|
|
11
|
+
*
|
|
12
|
+
* // Server-sent events, one line (or several, newline-separated) per message
|
|
13
|
+
* useLogStream({ sse: '/tasks/42/log/stream', endEvent: 'end' })
|
|
14
|
+
*
|
|
15
|
+
* // Anything that pushes: an SSEBridge event, a WebSocket…
|
|
16
|
+
* useLogStream({ subscribe: ({ push, end }) => bridge.onEvent('task:log', (p) => push(p.data.lines)) })
|
|
17
|
+
*
|
|
18
|
+
* // A finished log
|
|
19
|
+
* useLogStream({ lines: text.split('\n') })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { shallowRef, ref, onScopeDispose, getCurrentScope, type Ref, type ShallowRef } from 'vue'
|
|
24
|
+
import { parseAnsiLine, stripAnsi, type AnsiSegment, type AnsiStyle } from '../utils/ansi'
|
|
25
|
+
|
|
26
|
+
export interface LogPollResult {
|
|
27
|
+
lines: string[]
|
|
28
|
+
/** Offset to ask for next. Defaults to the previous one plus the lines received. */
|
|
29
|
+
next?: number
|
|
30
|
+
/** True once the log is complete: polling stops. */
|
|
31
|
+
done?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LogPushApi {
|
|
35
|
+
push: (lines: string | string[]) => void
|
|
36
|
+
end: () => void
|
|
37
|
+
fail: (error: unknown) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LogSource =
|
|
41
|
+
| { poll: (from: number) => Promise<LogPollResult>; intervalMs?: number; from?: number }
|
|
42
|
+
| {
|
|
43
|
+
sse: string | (() => EventSource)
|
|
44
|
+
/** Event carrying lines. Default `message`. */
|
|
45
|
+
event?: string
|
|
46
|
+
/** Event that closes the log. Default `end`. */
|
|
47
|
+
endEvent?: string
|
|
48
|
+
/** Turn a message's data into lines. Default: JSON `{ lines, done }` or plain text split on newlines. */
|
|
49
|
+
parse?: (data: string) => string[] | LogPollResult
|
|
50
|
+
}
|
|
51
|
+
| { subscribe: (api: LogPushApi) => (() => void) | void }
|
|
52
|
+
| { lines: string[] }
|
|
53
|
+
|
|
54
|
+
export interface LogLine {
|
|
55
|
+
/** 1-based number in the whole log, kept when old lines are dropped. */
|
|
56
|
+
n: number
|
|
57
|
+
raw: string
|
|
58
|
+
/** The line without ANSI escapes: what search, errors and download read. */
|
|
59
|
+
text: string
|
|
60
|
+
segments: AnsiSegment[]
|
|
61
|
+
error: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type LogStreamStatus = 'idle' | 'streaming' | 'done' | 'error'
|
|
65
|
+
|
|
66
|
+
export interface UseLogStreamOptions {
|
|
67
|
+
/** Lines matching it are flagged as errors. */
|
|
68
|
+
errorPattern?: RegExp | string | null
|
|
69
|
+
/** Oldest lines are dropped past this count. Default 100 000. */
|
|
70
|
+
maxLines?: number
|
|
71
|
+
/** Start reading at once. Default true. */
|
|
72
|
+
immediate?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface UseLogStreamReturn {
|
|
76
|
+
lines: ShallowRef<LogLine[]>
|
|
77
|
+
status: Ref<LogStreamStatus>
|
|
78
|
+
error: Ref<string | null>
|
|
79
|
+
/** Lines dropped from the start because of maxLines. */
|
|
80
|
+
dropped: Ref<number>
|
|
81
|
+
start: () => void
|
|
82
|
+
stop: () => void
|
|
83
|
+
/** Empty the buffer and read the source again from its start. */
|
|
84
|
+
restart: () => void
|
|
85
|
+
/** Add lines by hand (used by the push sources, open to the app). */
|
|
86
|
+
append: (lines: string | string[]) => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MAX_BACKOFF_MS = 30_000
|
|
90
|
+
|
|
91
|
+
function toRegExp(pattern: RegExp | string | null | undefined): RegExp | null {
|
|
92
|
+
if (!pattern) return null
|
|
93
|
+
if (typeof pattern === 'string') return new RegExp(pattern, 'i')
|
|
94
|
+
// A global or sticky regex keeps lastIndex between test() calls and skips lines.
|
|
95
|
+
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function splitLines(chunk: string | string[]): string[] {
|
|
99
|
+
const list = Array.isArray(chunk) ? chunk : [chunk]
|
|
100
|
+
return list.flatMap((l) => String(l).replace(/\r\n?/g, '\n').split('\n'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defaultParse(data: string): string[] | LogPollResult {
|
|
104
|
+
const trimmed = data.trimStart()
|
|
105
|
+
if (trimmed.startsWith('{')) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(data)
|
|
108
|
+
if (parsed && Array.isArray(parsed.lines)) return parsed as LogPollResult
|
|
109
|
+
} catch {
|
|
110
|
+
// not JSON: a line that happens to start with a brace
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return splitLines(data)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useLogStream(source: LogSource, options: UseLogStreamOptions = {}): UseLogStreamReturn {
|
|
117
|
+
const { maxLines = 100_000, immediate = true } = options
|
|
118
|
+
const errorRe = toRegExp(options.errorPattern)
|
|
119
|
+
|
|
120
|
+
const lines = shallowRef<LogLine[]>([])
|
|
121
|
+
const status = ref<LogStreamStatus>('idle')
|
|
122
|
+
const error = ref<string | null>(null)
|
|
123
|
+
const dropped = ref(0)
|
|
124
|
+
|
|
125
|
+
let ansiState: AnsiStyle = {}
|
|
126
|
+
let counter = 0
|
|
127
|
+
let teardown: (() => void) | null = null
|
|
128
|
+
// A restart must not let a poll answer from the previous run append.
|
|
129
|
+
let run = 0
|
|
130
|
+
|
|
131
|
+
function append(chunk: string | string[]): void {
|
|
132
|
+
const incoming = splitLines(chunk)
|
|
133
|
+
if (incoming.length === 0) return
|
|
134
|
+
const parsed: LogLine[] = incoming.map((raw) => {
|
|
135
|
+
const { segments, state } = parseAnsiLine(raw, ansiState)
|
|
136
|
+
ansiState = state
|
|
137
|
+
const text = stripAnsi(raw)
|
|
138
|
+
return { n: ++counter, raw, text, segments, error: errorRe ? errorRe.test(text) : false }
|
|
139
|
+
})
|
|
140
|
+
let next = lines.value.concat(parsed)
|
|
141
|
+
if (next.length > maxLines) {
|
|
142
|
+
dropped.value += next.length - maxLines
|
|
143
|
+
next = next.slice(next.length - maxLines)
|
|
144
|
+
}
|
|
145
|
+
lines.value = next
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function finish(): void {
|
|
149
|
+
status.value = 'done'
|
|
150
|
+
teardown?.()
|
|
151
|
+
teardown = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fail(err: unknown): void {
|
|
155
|
+
error.value = err instanceof Error ? err.message : String(err)
|
|
156
|
+
status.value = 'error'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function startPoll(src: Extract<LogSource, { poll: unknown }>): void {
|
|
160
|
+
const interval = src.intervalMs ?? 1000
|
|
161
|
+
const current = run
|
|
162
|
+
let offset = src.from ?? 0
|
|
163
|
+
let delay = interval
|
|
164
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
165
|
+
let stopped = false
|
|
166
|
+
|
|
167
|
+
const tick = async () => {
|
|
168
|
+
if (stopped || current !== run) return
|
|
169
|
+
try {
|
|
170
|
+
const result = await src.poll(offset)
|
|
171
|
+
if (stopped || current !== run) return
|
|
172
|
+
const received = result?.lines ?? []
|
|
173
|
+
append(received)
|
|
174
|
+
offset = result?.next ?? offset + received.length
|
|
175
|
+
status.value = 'streaming'
|
|
176
|
+
error.value = null
|
|
177
|
+
delay = interval
|
|
178
|
+
if (result?.done) return finish()
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (stopped || current !== run) return
|
|
181
|
+
fail(err)
|
|
182
|
+
delay = Math.min(delay * 2, MAX_BACKOFF_MS)
|
|
183
|
+
}
|
|
184
|
+
timer = setTimeout(tick, delay)
|
|
185
|
+
}
|
|
186
|
+
teardown = () => {
|
|
187
|
+
stopped = true
|
|
188
|
+
if (timer) clearTimeout(timer)
|
|
189
|
+
}
|
|
190
|
+
void tick()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function startSse(src: Extract<LogSource, { sse: unknown }>): void {
|
|
194
|
+
const es = typeof src.sse === 'string' ? new EventSource(src.sse) : src.sse()
|
|
195
|
+
const parse = src.parse ?? defaultParse
|
|
196
|
+
const onLines = (event: Event) => {
|
|
197
|
+
const result = parse(String((event as MessageEvent).data ?? ''))
|
|
198
|
+
if (Array.isArray(result)) append(result)
|
|
199
|
+
else {
|
|
200
|
+
append(result.lines ?? [])
|
|
201
|
+
if (result.done) finish()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const onEnd = () => finish()
|
|
205
|
+
const onError = () => {
|
|
206
|
+
// EventSource reconnects by itself; only a closed source is a failure.
|
|
207
|
+
if (es.readyState === 2) fail('The log stream closed')
|
|
208
|
+
}
|
|
209
|
+
es.addEventListener(src.event ?? 'message', onLines)
|
|
210
|
+
es.addEventListener(src.endEvent ?? 'end', onEnd)
|
|
211
|
+
es.addEventListener('error', onError)
|
|
212
|
+
teardown = () => es.close()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function startSubscribe(src: Extract<LogSource, { subscribe: unknown }>): void {
|
|
216
|
+
const current = run
|
|
217
|
+
const guard = <A extends unknown[]>(fn: (...args: A) => void) => (...args: A) => {
|
|
218
|
+
if (current === run && status.value === 'streaming') fn(...args)
|
|
219
|
+
}
|
|
220
|
+
const unsubscribe = src.subscribe({ push: guard(append), end: guard(finish), fail: guard(fail) })
|
|
221
|
+
if (status.value === 'streaming' && current === run) {
|
|
222
|
+
teardown = () => unsubscribe?.()
|
|
223
|
+
} else {
|
|
224
|
+
unsubscribe?.()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function start(): void {
|
|
229
|
+
if (teardown || status.value === 'streaming' || status.value === 'done') return
|
|
230
|
+
run++
|
|
231
|
+
error.value = null
|
|
232
|
+
if ('lines' in source) {
|
|
233
|
+
append(source.lines)
|
|
234
|
+
status.value = 'done'
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
status.value = 'streaming'
|
|
238
|
+
if ('poll' in source) startPoll(source)
|
|
239
|
+
else if ('sse' in source) startSse(source)
|
|
240
|
+
else startSubscribe(source)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stop(): void {
|
|
244
|
+
run++
|
|
245
|
+
teardown?.()
|
|
246
|
+
teardown = null
|
|
247
|
+
if (status.value === 'streaming') status.value = 'idle'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function restart(): void {
|
|
251
|
+
stop()
|
|
252
|
+
lines.value = []
|
|
253
|
+
dropped.value = 0
|
|
254
|
+
counter = 0
|
|
255
|
+
ansiState = {}
|
|
256
|
+
status.value = 'idle'
|
|
257
|
+
start()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (getCurrentScope()) onScopeDispose(stop)
|
|
261
|
+
if (immediate) start()
|
|
262
|
+
|
|
263
|
+
return { lines, status, error, dropped, start, stop, restart, append }
|
|
264
|
+
}
|
|
@@ -26,7 +26,19 @@ type Orchestrator = OrchestratorLike
|
|
|
26
26
|
*/
|
|
27
27
|
export interface NavItem {
|
|
28
28
|
label: string
|
|
29
|
-
route
|
|
29
|
+
route?: string
|
|
30
|
+
/**
|
|
31
|
+
* A link out of the app instead of a route (#3675) — exactly one of `route`
|
|
32
|
+
* and `href`. An absolute URL opens in a new tab unless `target` says otherwise.
|
|
33
|
+
*/
|
|
34
|
+
href?: string
|
|
35
|
+
/** Where an `href` entry opens: default `_blank` for an absolute URL, same tab otherwise. */
|
|
36
|
+
target?: string
|
|
37
|
+
/** An `href` entry is shown only to whoever is granted this attribute. */
|
|
38
|
+
permission?: string
|
|
39
|
+
/** Set on `href` entries by `navSections`: opens outside the app in another tab. */
|
|
40
|
+
external?: boolean
|
|
41
|
+
rel?: string
|
|
30
42
|
icon?: string
|
|
31
43
|
entity?: string
|
|
32
44
|
exact?: boolean
|
|
@@ -79,6 +91,8 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
79
91
|
const router = useRouter()
|
|
80
92
|
const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator', null)
|
|
81
93
|
const hooks = inject<HookRegistry | null>('qdadmHooks', null)
|
|
94
|
+
// null default: an app without security shows every link (#3675).
|
|
95
|
+
const securityChecker = inject<{ isGranted(attribute: string, subject?: unknown): boolean } | null>('qdadmSecurityChecker', null)
|
|
82
96
|
|
|
83
97
|
// i18n integration — labels resolve through nav.sections.* / nav.routes.*
|
|
84
98
|
const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
|
|
@@ -96,13 +110,17 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
96
110
|
* Check if user can access a nav item: canList() when it opens a list route, canRead() otherwise
|
|
97
111
|
*/
|
|
98
112
|
function canAccessNavItem(item: NavItem): boolean {
|
|
113
|
+
// A link out has no page to guard: only its own permission, if any (#3675).
|
|
114
|
+
if (item.href) {
|
|
115
|
+
return !item.permission || !securityChecker || securityChecker.isGranted(item.permission)
|
|
116
|
+
}
|
|
99
117
|
if (!item.entity || !orchestrator) return true
|
|
100
118
|
const manager = orchestrator.get(item.entity)
|
|
101
119
|
if (!manager) return true
|
|
102
120
|
// A menu entry is shown to whoever may do what its page does (#2497): a
|
|
103
121
|
// ctx.crud() entry opens a list, so it follows `list`. A route that
|
|
104
122
|
// declares no action, or is not registered, keeps `read`.
|
|
105
|
-
if (routeAction(item.route) === 'list' && manager.canList) return manager.canList()
|
|
123
|
+
if (item.route && routeAction(item.route) === 'list' && manager.canList) return manager.canList()
|
|
106
124
|
return manager.canRead()
|
|
107
125
|
}
|
|
108
126
|
|
|
@@ -126,6 +144,21 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
126
144
|
}
|
|
127
145
|
})
|
|
128
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A link out of the app as the renderers draw it (#3675): an absolute URL
|
|
149
|
+
* opens in a new tab, without handing it this window.
|
|
150
|
+
*/
|
|
151
|
+
function linkItem(item: NavItem): NavItem {
|
|
152
|
+
const absolute = /^([a-z][a-z\d+.-]*:|\/\/)/i.test(item.href!)
|
|
153
|
+
const target = item.target ?? (absolute ? '_blank' : undefined)
|
|
154
|
+
return {
|
|
155
|
+
...item,
|
|
156
|
+
...(target ? { target } : {}),
|
|
157
|
+
external: target === '_blank',
|
|
158
|
+
...(target === '_blank' ? { rel: 'noopener noreferrer' } : {}),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
129
162
|
/**
|
|
130
163
|
* Resolve a label through i18n with the given convention key, falling back
|
|
131
164
|
* to the inline label when the bundle has no matching translation.
|
|
@@ -153,7 +186,7 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
153
186
|
title: resolveNavLabel(`nav.sections.${section.title}`, section.title),
|
|
154
187
|
items: section.items
|
|
155
188
|
.filter((item) => canAccessNavItem(item))
|
|
156
|
-
.map((item) => ({
|
|
189
|
+
.map((item) => (item.href ? linkItem(item) : {
|
|
157
190
|
...item,
|
|
158
191
|
label: resolveNavLabel(`nav.routes.${item.route}`, item.label),
|
|
159
192
|
})),
|
|
@@ -166,6 +199,8 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
166
199
|
* Uses semantic breadcrumb only - no route segment deduction
|
|
167
200
|
*/
|
|
168
201
|
function isNavActive(item: NavItem): boolean {
|
|
202
|
+
// A link out never stands for the page on screen (#3675).
|
|
203
|
+
if (item.href) return false
|
|
169
204
|
// Exact match mode
|
|
170
205
|
if (item.exact) {
|
|
171
206
|
return route.name === item.route
|
|
@@ -199,6 +234,7 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
199
234
|
* Handle nav click - force navigation if on same route with query params
|
|
200
235
|
*/
|
|
201
236
|
function handleNavClick(event: Event, item: NavItem): void {
|
|
237
|
+
if (item.href) return
|
|
202
238
|
if (route.name === item.route && Object.keys(route.query).length > 0) {
|
|
203
239
|
event.preventDefault()
|
|
204
240
|
router.push({ name: item.route })
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm/email - raw MIME parsing for EmailPreview (#4113), through postal-mime
|
|
3
|
+
*
|
|
4
|
+
* Import from '@quazardous/qdadm/email' only if the app shows raw MIME
|
|
5
|
+
* messages (an .eml, a sink that returns the source). postal-mime is an
|
|
6
|
+
* optional peer dependency: install it to use this entry point. A parsed
|
|
7
|
+
* email ({ headers, html, text, attachments }) needs neither.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { parseMime } from '@quazardous/qdadm/email'
|
|
11
|
+
* <EmailPreview :raw="capture.source" :parser="parseMime" />
|
|
12
|
+
*/
|
|
13
|
+
import PostalMime from 'postal-mime'
|
|
14
|
+
import type { EmailAddress, ParsedEmail } from '../utils/email'
|
|
15
|
+
|
|
16
|
+
type PostalAddress = { name: string; address?: string; group?: Array<{ name: string; address: string }> }
|
|
17
|
+
|
|
18
|
+
function flatten(list: PostalAddress[] | PostalAddress | undefined): EmailAddress[] {
|
|
19
|
+
const items = !list ? [] : Array.isArray(list) ? list : [list]
|
|
20
|
+
return items.flatMap((a): Array<{ name: string; address?: string }> => (a.group ? a.group : [a])).map((a) => ({ name: a.name || null, address: a.address ?? null }))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse a raw MIME message. The source is kept for the Source tab. */
|
|
24
|
+
export async function parseMime(raw: string | ArrayBuffer | Uint8Array): Promise<ParsedEmail> {
|
|
25
|
+
const email = await new PostalMime({ attachmentEncoding: 'base64' }).parse(raw)
|
|
26
|
+
return {
|
|
27
|
+
from: flatten(email.from as PostalAddress | undefined)[0] ?? null,
|
|
28
|
+
to: flatten(email.to as PostalAddress[] | undefined),
|
|
29
|
+
cc: flatten(email.cc as PostalAddress[] | undefined),
|
|
30
|
+
replyTo: flatten(email.replyTo as PostalAddress[] | undefined),
|
|
31
|
+
subject: email.subject ?? null,
|
|
32
|
+
date: email.date ?? null,
|
|
33
|
+
messageId: email.messageId ?? null,
|
|
34
|
+
headers: email.headers.map((h) => ({ key: h.originalKey ?? h.key, value: h.value })),
|
|
35
|
+
html: email.html ?? null,
|
|
36
|
+
text: email.text ?? null,
|
|
37
|
+
raw: typeof raw === 'string' ? raw : new TextDecoder().decode(raw),
|
|
38
|
+
attachments: email.attachments.map((a) => ({
|
|
39
|
+
filename: a.filename,
|
|
40
|
+
contentType: a.mimeType,
|
|
41
|
+
content: a.content as string,
|
|
42
|
+
contentId: a.contentId ?? null,
|
|
43
|
+
disposition: a.disposition,
|
|
44
|
+
})),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -62,6 +62,29 @@ export interface SdkMethods<T extends EntityRecord = EntityRecord> {
|
|
|
62
62
|
[key: string]: SdkMethodConfig<T> | undefined
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The options of a path request, as `EntityManager.request` passes them. */
|
|
66
|
+
export interface SdkPathRequestOptions {
|
|
67
|
+
data?: unknown
|
|
68
|
+
params?: Record<string, unknown>
|
|
69
|
+
headers?: Record<string, string>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How a path request (`'GET', 'distinct/status'`) reaches the backend (#4069).
|
|
74
|
+
*
|
|
75
|
+
* A generated SDK has no method for an arbitrary path, and no reliable way
|
|
76
|
+
* to tell its base URL or this entity's endpoint, so the app supplies it —
|
|
77
|
+
* usually through the SDK's own client.
|
|
78
|
+
*/
|
|
79
|
+
export type SdkPathRequest = (
|
|
80
|
+
method: string,
|
|
81
|
+
path: string,
|
|
82
|
+
options: SdkPathRequestOptions,
|
|
83
|
+
sdk: SdkInstance
|
|
84
|
+
) => Promise<unknown>
|
|
85
|
+
|
|
86
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* SdkStorage options
|
|
67
90
|
*/
|
|
@@ -74,6 +97,8 @@ export interface SdkStorageOptions<T extends EntityRecord = EntityRecord> {
|
|
|
74
97
|
transforms?: Partial<Record<string, SdkMethodTransforms<T>>>
|
|
75
98
|
responseFormat?: SdkResponseFormat | null
|
|
76
99
|
clientSidePagination?: boolean
|
|
100
|
+
/** Runs `request(method, path, options)` calls — `optionsEndpoint` filters among them. */
|
|
101
|
+
request?: SdkPathRequest | null
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
/**
|
|
@@ -101,6 +126,7 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
101
126
|
protected _transformResponse: SdkGlobalResponseTransform<T> | null
|
|
102
127
|
protected _transforms: Partial<Record<string, SdkMethodTransforms<T>>>
|
|
103
128
|
protected _responseFormat: SdkResponseFormat | null
|
|
129
|
+
protected _pathRequest: SdkPathRequest | null
|
|
104
130
|
|
|
105
131
|
constructor(options: SdkStorageOptions<T> = {}) {
|
|
106
132
|
super()
|
|
@@ -113,6 +139,7 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
113
139
|
transforms = {},
|
|
114
140
|
responseFormat = null,
|
|
115
141
|
clientSidePagination = false,
|
|
142
|
+
request = null,
|
|
116
143
|
} = options
|
|
117
144
|
|
|
118
145
|
this._sdk = sdk
|
|
@@ -123,6 +150,12 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
123
150
|
this._transforms = transforms
|
|
124
151
|
this._responseFormat = responseFormat
|
|
125
152
|
this.clientSidePagination = clientSidePagination
|
|
153
|
+
this._pathRequest = request
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether `request(method, path)` can run, i.e. a `request` option was given. */
|
|
157
|
+
get supportsPathRequests(): boolean {
|
|
158
|
+
return this._pathRequest !== null
|
|
126
159
|
}
|
|
127
160
|
|
|
128
161
|
get sdk(): SdkInstance {
|
|
@@ -335,7 +368,26 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
335
368
|
await this._execute('delete', pathParams)
|
|
336
369
|
}
|
|
337
370
|
|
|
338
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Two calls share this name. `request('GET', 'distinct/status', options)` is
|
|
373
|
+
* the path request every storage answers, the one `EntityManager.request`
|
|
374
|
+
* makes; it goes through the `request` option. `request('getStats', params)`
|
|
375
|
+
* calls a configured method or an SDK method by name.
|
|
376
|
+
*
|
|
377
|
+
* Told apart by the verb: no SDK method is called `GET`, and before #4069
|
|
378
|
+
* the path form looked one up and failed with "SDK method 'GET' not found".
|
|
379
|
+
*/
|
|
380
|
+
async request<R = unknown>(methodName: string, params: unknown = {}, options: SdkPathRequestOptions = {}): Promise<R> {
|
|
381
|
+
if (HTTP_METHODS.has(methodName) && typeof params === 'string') {
|
|
382
|
+
if (!this._pathRequest) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`SdkStorage cannot run ${methodName} '${params}': a generated SDK has no method for a path. ` +
|
|
385
|
+
`Pass a request option, e.g. request: (method, url, o, sdk) => sdk.client.request({ method, url, query: o.params, body: o.data })`
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
return (await this._pathRequest(methodName, params, options, this.sdk)) as R
|
|
389
|
+
}
|
|
390
|
+
|
|
339
391
|
if (this._methods[methodName]) {
|
|
340
392
|
return (await this._execute(methodName, params)) as R
|
|
341
393
|
}
|
|
@@ -71,6 +71,91 @@ export default `core:
|
|
|
71
71
|
notRegistered: not registered
|
|
72
72
|
remove: Remove
|
|
73
73
|
via: "via {origin}"
|
|
74
|
+
# LogStream (#4110)
|
|
75
|
+
log:
|
|
76
|
+
streaming: Streaming
|
|
77
|
+
done: Complete
|
|
78
|
+
stopped: Stopped
|
|
79
|
+
failed: "Failed: {error}"
|
|
80
|
+
lines: "{count} lines"
|
|
81
|
+
search: Search the log
|
|
82
|
+
previous: Previous match
|
|
83
|
+
next: Next match
|
|
84
|
+
onlyMatches: Only matching lines
|
|
85
|
+
errors: "{count} errors"
|
|
86
|
+
firstError: Go to the next error line
|
|
87
|
+
wrap: Wrap long lines
|
|
88
|
+
follow: Follow the end of the log
|
|
89
|
+
download: Download the log
|
|
90
|
+
newLines: "{count} new lines, resume"
|
|
91
|
+
empty: No output yet
|
|
92
|
+
# EntityTimeline (#4111)
|
|
93
|
+
timeline:
|
|
94
|
+
actors: Filter by actor
|
|
95
|
+
loading: Loading…
|
|
96
|
+
empty: Nothing happened yet
|
|
97
|
+
newEvents: "{count} new events"
|
|
98
|
+
oldestFirst: Oldest first
|
|
99
|
+
newestFirst: Newest first
|
|
100
|
+
# PayloadViewer / PayloadDiff (#4112)
|
|
101
|
+
payload:
|
|
102
|
+
headers: "Headers ({count})"
|
|
103
|
+
headerRow: First row is a header
|
|
104
|
+
copy: Copy
|
|
105
|
+
copied: Copied to the clipboard
|
|
106
|
+
copyFailed: Could not copy
|
|
107
|
+
download: Download
|
|
108
|
+
showAll: "Show all {count} rows"
|
|
109
|
+
binary: "Binary content, {size}. First bytes:"
|
|
110
|
+
malformed: "Not valid {format} ({error}), shown as text"
|
|
111
|
+
expected: Expected
|
|
112
|
+
captured: Captured
|
|
113
|
+
identical: Identical
|
|
114
|
+
unchanged: "{count} unchanged lines"
|
|
115
|
+
showSame: "Show {count} unchanged rows"
|
|
116
|
+
inline: Inline
|
|
117
|
+
split: Side by side
|
|
118
|
+
# EmailPreview (#4113)
|
|
119
|
+
email:
|
|
120
|
+
from: From
|
|
121
|
+
to: To
|
|
122
|
+
cc: Cc
|
|
123
|
+
replyTo: Reply to
|
|
124
|
+
date: Date
|
|
125
|
+
noSubject: (no subject)
|
|
126
|
+
allHeaders: "All headers ({count})"
|
|
127
|
+
noParser: "A raw message needs a parser: pass parseMime from @quazardous/qdadm/email. Showing the source."
|
|
128
|
+
tab:
|
|
129
|
+
html: HTML
|
|
130
|
+
text: Text
|
|
131
|
+
source: Source
|
|
132
|
+
imagesBlocked: "{count} remote images blocked"
|
|
133
|
+
loadImages: Load images
|
|
134
|
+
blockImages: Block images
|
|
135
|
+
empty: This email has no body
|
|
136
|
+
unnamed: (unnamed)
|
|
137
|
+
inline: inline
|
|
138
|
+
download: Download
|
|
139
|
+
# TaskRunner (#4114)
|
|
140
|
+
task:
|
|
141
|
+
status:
|
|
142
|
+
starting: Starting
|
|
143
|
+
queued: Queued
|
|
144
|
+
running: Running
|
|
145
|
+
succeeded: Done
|
|
146
|
+
failed: Failed
|
|
147
|
+
cancelled: Cancelled
|
|
148
|
+
cancel: Cancel the task
|
|
149
|
+
close: Close
|
|
150
|
+
background: Run in the background
|
|
151
|
+
open: Open the task
|
|
152
|
+
readError: "Could not read the task ({error}), still trying"
|
|
153
|
+
start: Start
|
|
154
|
+
dontStart: Do not start
|
|
155
|
+
typeToConfirm: "Type {value} to confirm"
|
|
156
|
+
tray: "{count} running tasks"
|
|
157
|
+
trayDone: Recent tasks
|
|
158
|
+
clear: Clear finished
|
|
74
159
|
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
75
160
|
breadcrumb:
|
|
76
161
|
view: View
|
|
@@ -69,6 +69,91 @@ core:
|
|
|
69
69
|
notRegistered: not registered
|
|
70
70
|
remove: Remove
|
|
71
71
|
via: "via {origin}"
|
|
72
|
+
# LogStream (#4110)
|
|
73
|
+
log:
|
|
74
|
+
streaming: Streaming
|
|
75
|
+
done: Complete
|
|
76
|
+
stopped: Stopped
|
|
77
|
+
failed: "Failed: {error}"
|
|
78
|
+
lines: "{count} lines"
|
|
79
|
+
search: Search the log
|
|
80
|
+
previous: Previous match
|
|
81
|
+
next: Next match
|
|
82
|
+
onlyMatches: Only matching lines
|
|
83
|
+
errors: "{count} errors"
|
|
84
|
+
firstError: Go to the next error line
|
|
85
|
+
wrap: Wrap long lines
|
|
86
|
+
follow: Follow the end of the log
|
|
87
|
+
download: Download the log
|
|
88
|
+
newLines: "{count} new lines, resume"
|
|
89
|
+
empty: No output yet
|
|
90
|
+
# EntityTimeline (#4111)
|
|
91
|
+
timeline:
|
|
92
|
+
actors: Filter by actor
|
|
93
|
+
loading: Loading…
|
|
94
|
+
empty: Nothing happened yet
|
|
95
|
+
newEvents: "{count} new events"
|
|
96
|
+
oldestFirst: Oldest first
|
|
97
|
+
newestFirst: Newest first
|
|
98
|
+
# PayloadViewer / PayloadDiff (#4112)
|
|
99
|
+
payload:
|
|
100
|
+
headers: "Headers ({count})"
|
|
101
|
+
headerRow: First row is a header
|
|
102
|
+
copy: Copy
|
|
103
|
+
copied: Copied to the clipboard
|
|
104
|
+
copyFailed: Could not copy
|
|
105
|
+
download: Download
|
|
106
|
+
showAll: "Show all {count} rows"
|
|
107
|
+
binary: "Binary content, {size}. First bytes:"
|
|
108
|
+
malformed: "Not valid {format} ({error}), shown as text"
|
|
109
|
+
expected: Expected
|
|
110
|
+
captured: Captured
|
|
111
|
+
identical: Identical
|
|
112
|
+
unchanged: "{count} unchanged lines"
|
|
113
|
+
showSame: "Show {count} unchanged rows"
|
|
114
|
+
inline: Inline
|
|
115
|
+
split: Side by side
|
|
116
|
+
# EmailPreview (#4113)
|
|
117
|
+
email:
|
|
118
|
+
from: From
|
|
119
|
+
to: To
|
|
120
|
+
cc: Cc
|
|
121
|
+
replyTo: Reply to
|
|
122
|
+
date: Date
|
|
123
|
+
noSubject: (no subject)
|
|
124
|
+
allHeaders: "All headers ({count})"
|
|
125
|
+
noParser: "A raw message needs a parser: pass parseMime from @quazardous/qdadm/email. Showing the source."
|
|
126
|
+
tab:
|
|
127
|
+
html: HTML
|
|
128
|
+
text: Text
|
|
129
|
+
source: Source
|
|
130
|
+
imagesBlocked: "{count} remote images blocked"
|
|
131
|
+
loadImages: Load images
|
|
132
|
+
blockImages: Block images
|
|
133
|
+
empty: This email has no body
|
|
134
|
+
unnamed: (unnamed)
|
|
135
|
+
inline: inline
|
|
136
|
+
download: Download
|
|
137
|
+
# TaskRunner (#4114)
|
|
138
|
+
task:
|
|
139
|
+
status:
|
|
140
|
+
starting: Starting
|
|
141
|
+
queued: Queued
|
|
142
|
+
running: Running
|
|
143
|
+
succeeded: Done
|
|
144
|
+
failed: Failed
|
|
145
|
+
cancelled: Cancelled
|
|
146
|
+
cancel: Cancel the task
|
|
147
|
+
close: Close
|
|
148
|
+
background: Run in the background
|
|
149
|
+
open: Open the task
|
|
150
|
+
readError: "Could not read the task ({error}), still trying"
|
|
151
|
+
start: Start
|
|
152
|
+
dontStart: Do not start
|
|
153
|
+
typeToConfirm: "Type {value} to confirm"
|
|
154
|
+
tray: "{count} running tasks"
|
|
155
|
+
trayDone: Recent tasks
|
|
156
|
+
clear: Clear finished
|
|
72
157
|
# The breadcrumb's View↔Edit toggle (useNavContext).
|
|
73
158
|
breadcrumb:
|
|
74
159
|
view: View
|