@k2b/cloud 0.23.0 → 0.25.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/package.json +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -79
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { showFileDialog } from "@k2b/stdlib/browser";
|
|
2
|
+
import { dialogCore, panelDialogWorkspaceOptions } from "@k2b/ui";
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
|
+
import { type ChooseFilesOptions, FileChooser, type FileProviderList } from "./FileChooser";
|
|
5
|
+
import type { FileProviderCaller, FileProviderSource } from "./file-providers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One page's file choosing: the provider list is loaded once and kept; a failed load is tried again on the next
|
|
9
|
+
* request. `caller` carries the locale and, in tests, the transport.
|
|
10
|
+
*/
|
|
11
|
+
export const createFileChoosing = (
|
|
12
|
+
loadProviders: () => Promise<FileProviderSource[]>,
|
|
13
|
+
caller: () => FileProviderCaller = () => ({ locale: document.documentElement.lang || "en" }),
|
|
14
|
+
) => {
|
|
15
|
+
let request: Promise<readonly FileProviderSource[]> | undefined;
|
|
16
|
+
let known: readonly FileProviderSource[] | undefined;
|
|
17
|
+
const providers = (): Promise<readonly FileProviderSource[]> => {
|
|
18
|
+
request ??= loadProviders().then(
|
|
19
|
+
(list) => {
|
|
20
|
+
known = list;
|
|
21
|
+
return list;
|
|
22
|
+
},
|
|
23
|
+
(error: unknown) => {
|
|
24
|
+
request = undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
return request;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const chooseFiles = (options: ChooseFilesOptions = {}): Promise<File[]> => {
|
|
32
|
+
const { signal } = options;
|
|
33
|
+
if (signal?.aborted) return Promise.resolve([]);
|
|
34
|
+
if (known?.length === 0) {
|
|
35
|
+
// Synchronous on purpose: the native dialog needs the caller's user activation.
|
|
36
|
+
const picked = options.multiple
|
|
37
|
+
? showFileDialog({ accept: options.accept, multiple: true })
|
|
38
|
+
: showFileDialog({ accept: options.accept }).then((file) => [file]);
|
|
39
|
+
// The page cannot close a native dialog; an abort resolves `[]` at once and a later pick is dropped.
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const abort = () => resolve([]);
|
|
42
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
43
|
+
picked.then(resolve, () => resolve([])).finally(() => signal?.removeEventListener("abort", abort));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const [list, setList] = createSignal<FileProviderList>(known ? { state: "ready", providers: known } : { state: "loading" });
|
|
47
|
+
const load = () => {
|
|
48
|
+
setList({ state: "loading" });
|
|
49
|
+
providers().then(
|
|
50
|
+
(value) => setList({ state: "ready", providers: value }),
|
|
51
|
+
() => setList({ state: "error" }),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
if (!known) load();
|
|
55
|
+
return dialogCore
|
|
56
|
+
.open<File[]>((close) => <FileChooser options={options} providers={list} retryProviders={load} caller={caller} close={close} />, {
|
|
57
|
+
...panelDialogWorkspaceOptions,
|
|
58
|
+
panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
|
|
59
|
+
initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
history: true,
|
|
62
|
+
})
|
|
63
|
+
.then((files) => files ?? []);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
chooseFiles,
|
|
68
|
+
prefetch: () => void providers().catch(() => undefined),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const fileChooserMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
title: ({ multiple }: { multiple: boolean }): string => (multiple ? "Add files" : "Add a file"),
|
|
8
|
+
sources: "Sources",
|
|
9
|
+
sourceList: "Where the files come from",
|
|
10
|
+
device: "This device",
|
|
11
|
+
providersLoading: "Looking for apps with files…",
|
|
12
|
+
providersFailed: "Apps with files could not be loaded.",
|
|
13
|
+
retry: "Try again",
|
|
14
|
+
up: "Up",
|
|
15
|
+
breadcrumbs: "Folder path",
|
|
16
|
+
filter: "Filter by name",
|
|
17
|
+
entries: ({ folder }: { folder: string }) => `Contents of ${folder}`,
|
|
18
|
+
loading: "Loading…",
|
|
19
|
+
loadMore: "Load more",
|
|
20
|
+
emptyFolder: "This folder is empty",
|
|
21
|
+
noMatches: ({ query }: { query: string }) => `Nothing matches “${query}”`,
|
|
22
|
+
clearFilter: "Clear filter",
|
|
23
|
+
offline: "You are offline",
|
|
24
|
+
offlineHint: "Check your connection, then try again.",
|
|
25
|
+
unavailable: ({ app }: { app: string }) => `${app} is not available right now`,
|
|
26
|
+
unavailableHint: "Try again in a moment.",
|
|
27
|
+
noAccess: "You no longer have access to this folder",
|
|
28
|
+
notFound: "This folder no longer exists",
|
|
29
|
+
backHint: "Go back and choose another folder.",
|
|
30
|
+
loadFailed: "This folder could not be loaded",
|
|
31
|
+
folderAnnounced: ({ folder, count, more }: { folder: string; count: number; more: boolean }) =>
|
|
32
|
+
`${folder}, ${i18n.plural(count, "en", { one: `${count} entry`, other: `${count} entries` })}${more ? ", more available" : ""}`,
|
|
33
|
+
tooLarge: ({ limit }: { limit: string }) => `Too large, up to ${limit}`,
|
|
34
|
+
tooLargeTogether: ({ limit }: { limit: string }) => `Too large together, up to ${limit}`,
|
|
35
|
+
wrongType: "Not supported here",
|
|
36
|
+
selected: ({ count }: { count: number }) => i18n.plural(count, "en", { one: "1 file selected", other: `${count} files selected` }),
|
|
37
|
+
selectHint: ({ multiple }: { multiple: boolean }): string => (multiple ? "Select one or more files" : "Select a file"),
|
|
38
|
+
cancel: "Cancel",
|
|
39
|
+
add: "Add",
|
|
40
|
+
downloading: ({ done, total, failed }: { done: number; total: number; failed: number }) =>
|
|
41
|
+
`${done} of ${total} ready${failed > 0 ? `, ${failed} failed` : ""}`,
|
|
42
|
+
downloadProgress: ({ name }: { name: string }) => `Downloading ${name}`,
|
|
43
|
+
waiting: "Waiting",
|
|
44
|
+
readFailed: "Could not be downloaded",
|
|
45
|
+
},
|
|
46
|
+
de: {
|
|
47
|
+
title: ({ multiple }) => (multiple ? "Dateien hinzufügen" : "Datei hinzufügen"),
|
|
48
|
+
sources: "Quellen",
|
|
49
|
+
sourceList: "Woher die Dateien kommen",
|
|
50
|
+
device: "Dieses Gerät",
|
|
51
|
+
providersLoading: "Apps mit Dateien werden gesucht…",
|
|
52
|
+
providersFailed: "Apps mit Dateien konnten nicht geladen werden.",
|
|
53
|
+
retry: "Erneut versuchen",
|
|
54
|
+
up: "Nach oben",
|
|
55
|
+
breadcrumbs: "Ordnerpfad",
|
|
56
|
+
filter: "Nach Namen filtern",
|
|
57
|
+
entries: ({ folder }) => `Inhalt von ${folder}`,
|
|
58
|
+
loading: "Wird geladen…",
|
|
59
|
+
loadMore: "Mehr laden",
|
|
60
|
+
emptyFolder: "Dieser Ordner ist leer",
|
|
61
|
+
noMatches: ({ query }) => `Nichts passt zu „${query}“`,
|
|
62
|
+
clearFilter: "Filter löschen",
|
|
63
|
+
offline: "Du bist offline",
|
|
64
|
+
offlineHint: "Prüfe deine Verbindung und versuche es erneut.",
|
|
65
|
+
unavailable: ({ app }) => `${app} ist gerade nicht erreichbar`,
|
|
66
|
+
unavailableHint: "Versuche es gleich noch einmal.",
|
|
67
|
+
noAccess: "Du hast keinen Zugriff mehr auf diesen Ordner",
|
|
68
|
+
notFound: "Diesen Ordner gibt es nicht mehr",
|
|
69
|
+
backHint: "Geh zurück und wähle einen anderen Ordner.",
|
|
70
|
+
loadFailed: "Dieser Ordner konnte nicht geladen werden",
|
|
71
|
+
folderAnnounced: ({ folder, count, more }) =>
|
|
72
|
+
`${folder}, ${i18n.plural(count, "de", { one: `${count} Eintrag`, other: `${count} Einträge` })}${more ? ", weitere verfügbar" : ""}`,
|
|
73
|
+
tooLarge: ({ limit }) => `Zu groß, höchstens ${limit}`,
|
|
74
|
+
tooLargeTogether: ({ limit }) => `Zusammen zu groß, höchstens ${limit}`,
|
|
75
|
+
wrongType: "Hier nicht unterstützt",
|
|
76
|
+
selected: ({ count }) => i18n.plural(count, "de", { one: "1 Datei ausgewählt", other: `${count} Dateien ausgewählt` }),
|
|
77
|
+
selectHint: ({ multiple }) => (multiple ? "Wähle eine oder mehrere Dateien" : "Wähle eine Datei"),
|
|
78
|
+
cancel: "Abbrechen",
|
|
79
|
+
add: "Hinzufügen",
|
|
80
|
+
downloading: ({ done, total, failed }) => `${done} von ${total} bereit${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}`,
|
|
81
|
+
downloadProgress: ({ name }) => `${name} wird heruntergeladen`,
|
|
82
|
+
waiting: "Wartet",
|
|
83
|
+
readFailed: "Konnte nicht heruntergeladen werden",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { checkMimeType } from "@k2b/stdlib/browser";
|
|
2
|
+
import { invokeCapabilityWithDataSchema, listCapabilityCatalog } from "../capabilities/client";
|
|
3
|
+
import { transferCapabilityStream } from "../capabilities/streams";
|
|
4
|
+
import type { CapabilityCatalogApp, CapabilityClientError, CapabilityHttpOptions } from "../capabilities/types";
|
|
5
|
+
import { CapabilityErrorSchema } from "../contracts/capabilities";
|
|
6
|
+
import {
|
|
7
|
+
type FileProviderEntry,
|
|
8
|
+
FileProviderListDataSchema,
|
|
9
|
+
FileProviderReadDataSchema,
|
|
10
|
+
fileProviderIssues,
|
|
11
|
+
} from "../contracts/file-provider";
|
|
12
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
13
|
+
|
|
14
|
+
/** One live application whose catalog manifest offers files, reduced to what choosing a file needs. */
|
|
15
|
+
export type FileProviderSource = {
|
|
16
|
+
appId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
icon: string;
|
|
19
|
+
list: string;
|
|
20
|
+
read: string;
|
|
21
|
+
/** The provider's read limit; a consumer combines it with its own. */
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FileProviderCaller = Pick<CapabilityHttpOptions, "fetch" | "baseUrl"> & { locale: string };
|
|
26
|
+
|
|
27
|
+
/** A provider failure the chooser explains; `status` 0 means the browser is offline. */
|
|
28
|
+
export class FileProviderError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly code: string,
|
|
31
|
+
message: string,
|
|
32
|
+
readonly status: number,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "FileProviderError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The page size the chooser asks for; the contract allows 1 to 100. */
|
|
40
|
+
export const FILE_PROVIDER_PAGE_SIZE = 50;
|
|
41
|
+
/** At most this many provider reads run at the same time. */
|
|
42
|
+
export const FILE_PROVIDER_PARALLEL_READS = 2;
|
|
43
|
+
|
|
44
|
+
const ICON = /^ti(?: ti-[a-z0-9-]{1,60}){1,2}$/;
|
|
45
|
+
/** Icons come from other applications; only a Tabler icon class may reach the DOM, never arbitrary classes. */
|
|
46
|
+
export const providerIcon = (icon: string | undefined, fallback: string): string => (icon && ICON.test(icon) ? icon : fallback);
|
|
47
|
+
|
|
48
|
+
const options = (caller: FileProviderCaller): CapabilityHttpOptions => ({
|
|
49
|
+
fetch: caller.fetch,
|
|
50
|
+
baseUrl: caller.baseUrl,
|
|
51
|
+
headers: { [LOCALE_HEADER]: caller.locale },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const failure = (error: CapabilityClientError): FileProviderError =>
|
|
55
|
+
new FileProviderError(error.code, error.message, typeof navigator !== "undefined" && navigator.onLine === false ? 0 : error.status);
|
|
56
|
+
|
|
57
|
+
/** One unreadable manifest leaves out that application, not the whole list. */
|
|
58
|
+
const usable = (app: CapabilityCatalogApp): boolean => {
|
|
59
|
+
try {
|
|
60
|
+
return !fileProviderIssues(app.manifest).some((issue) => issue.function !== "save");
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keeps the catalog apps that offer files with a usable `list` and `read`. Core already drops invalid declarations;
|
|
68
|
+
* the check repeats here because a manifest from another Cloud release may name an operation this reader left out.
|
|
69
|
+
*/
|
|
70
|
+
export const fileProviderSources = (apps: readonly CapabilityCatalogApp[], locale: string): FileProviderSource[] =>
|
|
71
|
+
apps
|
|
72
|
+
.flatMap((app) => {
|
|
73
|
+
const declaration = app.manifest.fileProvider;
|
|
74
|
+
if (!declaration || !usable(app)) return [];
|
|
75
|
+
const read = app.manifest.queries.find((query) => query.localId === declaration.read);
|
|
76
|
+
if (!read?.stream) return [];
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
appId: app.appId,
|
|
80
|
+
name: app.appName,
|
|
81
|
+
icon: providerIcon(app.appIcon, "ti ti-folders"),
|
|
82
|
+
list: declaration.list,
|
|
83
|
+
read: declaration.read,
|
|
84
|
+
maxBytes: read.stream.maxBytes,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
})
|
|
88
|
+
.sort((a, b) => a.name.localeCompare(b.name, locale));
|
|
89
|
+
|
|
90
|
+
/** Reads every catalog page; the catalog is the only discovery, there is no provider route. */
|
|
91
|
+
export const loadFileProviders = async (caller: FileProviderCaller, signal?: AbortSignal): Promise<FileProviderSource[]> => {
|
|
92
|
+
const apps: CapabilityCatalogApp[] = [];
|
|
93
|
+
let cursor: string | undefined;
|
|
94
|
+
do {
|
|
95
|
+
const result = await listCapabilityCatalog({ ...options(caller), limit: 25, cursor, signal });
|
|
96
|
+
if (!result.ok) throw failure(result.error);
|
|
97
|
+
apps.push(...result.data.apps);
|
|
98
|
+
const next = result.data.page.hasMore ? result.data.page.nextCursor : undefined;
|
|
99
|
+
if (next && cursor && next <= cursor) throw new FileProviderError("INVALID_APP_RESPONSE", "Invalid catalog cursor", 502);
|
|
100
|
+
cursor = next;
|
|
101
|
+
} while (cursor);
|
|
102
|
+
return fileProviderSources(apps, caller.locale);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type FileProviderPage = { items: FileProviderEntry[]; next: string | null };
|
|
106
|
+
|
|
107
|
+
/** One folder page. Without `parent` it is the provider's root. */
|
|
108
|
+
export const listProviderFolder = async (
|
|
109
|
+
provider: FileProviderSource,
|
|
110
|
+
input: { parent?: string; query?: string; cursor?: string },
|
|
111
|
+
caller: FileProviderCaller,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
): Promise<FileProviderPage> => {
|
|
114
|
+
const result = await invokeCapabilityWithDataSchema(
|
|
115
|
+
{
|
|
116
|
+
appId: provider.appId,
|
|
117
|
+
capabilityId: provider.list,
|
|
118
|
+
kind: "query",
|
|
119
|
+
input: {
|
|
120
|
+
...(input.parent === undefined ? {} : { parent: input.parent }),
|
|
121
|
+
...(input.query ? { query: input.query } : {}),
|
|
122
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
123
|
+
limit: FILE_PROVIDER_PAGE_SIZE,
|
|
124
|
+
},
|
|
125
|
+
signal,
|
|
126
|
+
},
|
|
127
|
+
FileProviderListDataSchema,
|
|
128
|
+
options(caller),
|
|
129
|
+
);
|
|
130
|
+
if (!result.ok) throw failure(result.error);
|
|
131
|
+
return { items: result.data.data.items, next: result.data.data.next };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type EntryProblem = "type" | "size";
|
|
135
|
+
|
|
136
|
+
/** Why a file cannot be chosen: its type does not match `accept`, or it is larger than the effective limit. */
|
|
137
|
+
export const entryProblem = (entry: FileProviderEntry, accept: string | undefined, maxBytes: number): EntryProblem | undefined => {
|
|
138
|
+
if (entry.kind === "folder") return undefined;
|
|
139
|
+
if (accept && !checkMimeType(new File([], entry.name, { type: entry.mediaType ?? "" }), accept)) return "type";
|
|
140
|
+
if (entry.size > maxBytes) return "size";
|
|
141
|
+
return undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const streamFailure = async (response: Response): Promise<FileProviderError> => {
|
|
145
|
+
const body = CapabilityErrorSchema.safeParse(await response.json().catch(() => null));
|
|
146
|
+
return body.success
|
|
147
|
+
? new FileProviderError(body.data.code, body.data.message, response.status)
|
|
148
|
+
: new FileProviderError("INVALID_APP_RESPONSE", `Read failed with status ${response.status}`, response.status);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reads one file through the provider's read stream into a `File`. Before any byte moves it checks the read's own
|
|
153
|
+
* size and media type against `maxBytes` and `accept` again, since the file may have changed since it was listed.
|
|
154
|
+
* It rejects a body that differs from the announced size and reports received bytes. Aborting the signal cancels
|
|
155
|
+
* the transfer.
|
|
156
|
+
*/
|
|
157
|
+
export const readProviderFile = async (
|
|
158
|
+
provider: FileProviderSource,
|
|
159
|
+
entry: Extract<FileProviderEntry, { kind: "file" }>,
|
|
160
|
+
caller: FileProviderCaller & {
|
|
161
|
+
maxBytes: number;
|
|
162
|
+
accept?: string;
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
onProgress?: (loaded: number, total: number) => void;
|
|
165
|
+
},
|
|
166
|
+
): Promise<File> => {
|
|
167
|
+
const result = await invokeCapabilityWithDataSchema(
|
|
168
|
+
{ appId: provider.appId, capabilityId: provider.read, kind: "query", input: { id: entry.id }, signal: caller.signal },
|
|
169
|
+
FileProviderReadDataSchema,
|
|
170
|
+
options(caller),
|
|
171
|
+
);
|
|
172
|
+
if (!result.ok) throw failure(result.error);
|
|
173
|
+
const stream = result.data.stream;
|
|
174
|
+
if (stream?.direction !== "read") throw new FileProviderError("INVALID_APP_RESPONSE", "The provider returned no read stream", 502);
|
|
175
|
+
const problem = entryProblem({ ...entry, size: stream.size, mediaType: stream.mediaType }, caller.accept, caller.maxBytes);
|
|
176
|
+
if (problem === "size") throw new FileProviderError("FILE_TOO_LARGE", "The file is larger than allowed", 413);
|
|
177
|
+
if (problem === "type") throw new FileProviderError("UNSUPPORTED_MEDIA_TYPE", "The file type is not accepted", 415);
|
|
178
|
+
let response: Response;
|
|
179
|
+
try {
|
|
180
|
+
response = await transferCapabilityStream(stream, "read", { ...options(caller), signal: caller.signal });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (caller.signal?.aborted) throw error;
|
|
183
|
+
throw new FileProviderError("APP_UNAVAILABLE", "The file could not be read", navigator.onLine === false ? 0 : 503);
|
|
184
|
+
}
|
|
185
|
+
if (!response.ok || !response.body) throw await streamFailure(response);
|
|
186
|
+
const reader = response.body.getReader();
|
|
187
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
188
|
+
let loaded = 0;
|
|
189
|
+
caller.onProgress?.(0, stream.size);
|
|
190
|
+
try {
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { done, value } = await reader.read();
|
|
193
|
+
caller.signal?.throwIfAborted();
|
|
194
|
+
if (done) break;
|
|
195
|
+
loaded += value.byteLength;
|
|
196
|
+
if (loaded > stream.size) throw new FileProviderError("INVALID_APP_RESPONSE", "The file is larger than announced", 502);
|
|
197
|
+
chunks.push(value);
|
|
198
|
+
caller.onProgress?.(loaded, stream.size);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await reader.cancel().catch(() => undefined);
|
|
202
|
+
if (error instanceof FileProviderError || caller.signal?.aborted) throw error;
|
|
203
|
+
throw new FileProviderError("APP_UNAVAILABLE", "The file could not be read", navigator.onLine === false ? 0 : 503);
|
|
204
|
+
}
|
|
205
|
+
if (loaded !== stream.size) throw new FileProviderError("INVALID_APP_RESPONSE", "The file ended early", 502);
|
|
206
|
+
const lastModified = entry.updatedAt ? Date.parse(entry.updatedAt) : Date.now();
|
|
207
|
+
return new File(chunks, entry.name, { type: stream.mediaType, lastModified });
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Runs `work` for every item, at most `limit` at once. `work` handles its own failures. */
|
|
211
|
+
export const eachLimited = async <T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> => {
|
|
212
|
+
let index = 0;
|
|
213
|
+
const run = async () => {
|
|
214
|
+
while (index < items.length) await work(items[index++]!);
|
|
215
|
+
};
|
|
216
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
|
|
217
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createFileChoosing } from "./choose-files";
|
|
2
|
+
import { loadFileProviders } from "./file-providers";
|
|
3
|
+
|
|
4
|
+
export type { ChooseFilesOptions } from "./FileChooser";
|
|
5
|
+
|
|
6
|
+
const pageLocale = () => (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
7
|
+
const choosing = createFileChoosing(() => loadFileProviders({ locale: pageLocale() }));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lets the user choose files from this device or from any Cloud application that offers files.
|
|
11
|
+
*
|
|
12
|
+
* Call it from the click or key handler of your attach action. Without providers it opens the device's file
|
|
13
|
+
* dialog directly; otherwise it opens the source chooser, "This device" first. Files from a provider are read
|
|
14
|
+
* through its stream, at most two at a time, and never above `min(maxBytes, provider limit)`. Hand the result
|
|
15
|
+
* to your existing upload path: it resolves the chosen `File`s, or `[]` when the user cancels.
|
|
16
|
+
*/
|
|
17
|
+
export const chooseFiles = choosing.chooseFiles;
|
|
18
|
+
|
|
19
|
+
// Discovery starts while the page is idle, so the first click already knows whether providers exist.
|
|
20
|
+
if (typeof window !== "undefined") {
|
|
21
|
+
const idle = window.requestIdleCallback ?? ((callback: () => void) => window.setTimeout(callback, 1_000));
|
|
22
|
+
idle(() => choosing.prefetch());
|
|
23
|
+
}
|
|
@@ -12,7 +12,15 @@ export const resourceSearchMessages = i18n.define({
|
|
|
12
12
|
commandHint: "Choose an action, or type > next time.",
|
|
13
13
|
searchFailed: "Search is currently unavailable. Try again.",
|
|
14
14
|
navigationFailed: "Could not open this result. Try again.",
|
|
15
|
-
|
|
15
|
+
stillSearching: ({ apps, count }: { apps: string; count: number }) =>
|
|
16
|
+
count === 1 ? `${apps} is still searching…` : `${apps} are still searching…`,
|
|
17
|
+
moreApps: ({ count }: { count: number }) => `${count} more`,
|
|
18
|
+
appTimedOut: ({ app }: { app: string }) => `${app} did not respond in time`,
|
|
19
|
+
appUnavailable: ({ app }: { app: string }) => `${app} is not available right now`,
|
|
20
|
+
appNoMatches: ({ app, query }: { app: string; query: string }) =>
|
|
21
|
+
query ? `${app}: no matches for “${query}”` : `${app}: no matches`,
|
|
22
|
+
searchDone: ({ count }: { count: number }) =>
|
|
23
|
+
i18n.plural(count, "en", { one: `Search complete, ${count} result`, other: `Search complete, ${count} results` }),
|
|
16
24
|
searchPlaceholder: "Search Cloud…",
|
|
17
25
|
scopedSearchPlaceholder: "Search…",
|
|
18
26
|
searchCloudResources: "Search Cloud resources",
|
|
@@ -54,7 +62,12 @@ export const resourceSearchMessages = i18n.define({
|
|
|
54
62
|
commandHint: "Wähle eine Aktion – beim nächsten Mal geht auch >.",
|
|
55
63
|
searchFailed: "Suche gerade nicht erreichbar. Versuche es erneut.",
|
|
56
64
|
navigationFailed: "Der Treffer konnte nicht geöffnet werden. Versuche es erneut.",
|
|
57
|
-
|
|
65
|
+
stillSearching: ({ apps, count }) => (count === 1 ? `${apps} sucht noch …` : `${apps} suchen noch …`),
|
|
66
|
+
moreApps: ({ count }) => `${count} weitere`,
|
|
67
|
+
appTimedOut: ({ app }) => `${app} hat nicht rechtzeitig geantwortet`,
|
|
68
|
+
appUnavailable: ({ app }) => `${app} ist gerade nicht erreichbar`,
|
|
69
|
+
appNoMatches: ({ app, query }) => (query ? `${app}: keine Treffer für „${query}“` : `${app}: keine Treffer`),
|
|
70
|
+
searchDone: ({ count }) => `Suche abgeschlossen, ${count} Treffer`,
|
|
58
71
|
searchPlaceholder: "Cloud durchsuchen…",
|
|
59
72
|
scopedSearchPlaceholder: "Durchsuchen…",
|
|
60
73
|
searchCloudResources: "Cloud-Inhalte durchsuchen",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
|
|
2
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
/** Kept local so the browser bundle does not load the server-side search schemas. */
|
|
5
|
+
const STREAM_CONTENT_TYPE = "application/x-ndjson";
|
|
6
|
+
|
|
7
|
+
export type SearchAppStatus = SearchProviderStatus | "searching";
|
|
8
|
+
|
|
9
|
+
/** Rows one provider line added. A retried app adds a block of its own at the end. */
|
|
10
|
+
export type SearchBlock = { appId: string; items: SearchItem[] };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One streamed search as the browser shows it. Blocks only ever grow at the end, so nothing already visible moves;
|
|
14
|
+
* `status` says for every app named in `start` whether it is still searching and how it finished.
|
|
15
|
+
*/
|
|
16
|
+
export type SearchRun = {
|
|
17
|
+
apps: SearchApp[];
|
|
18
|
+
providers: string[];
|
|
19
|
+
status: Record<string, SearchAppStatus>;
|
|
20
|
+
blocks: SearchBlock[];
|
|
21
|
+
unsupportedTags: string[];
|
|
22
|
+
/** The stream has ended; a retried app may still search afterwards. */
|
|
23
|
+
done: boolean;
|
|
24
|
+
/** The request failed before any app was named. */
|
|
25
|
+
failed: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const emptySearchRun = (): SearchRun => ({
|
|
29
|
+
apps: [],
|
|
30
|
+
providers: [],
|
|
31
|
+
status: {},
|
|
32
|
+
blocks: [],
|
|
33
|
+
unsupportedTags: [],
|
|
34
|
+
done: false,
|
|
35
|
+
failed: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const itemKey = (item: SearchItem) => `${item.ref.type}\u0000${item.ref.id}`;
|
|
39
|
+
|
|
40
|
+
export const applySearchLine = (run: SearchRun, line: SearchStreamLine): SearchRun => {
|
|
41
|
+
if (line.type === "start")
|
|
42
|
+
return {
|
|
43
|
+
...run,
|
|
44
|
+
apps: line.apps,
|
|
45
|
+
providers: line.providers,
|
|
46
|
+
status: Object.fromEntries(line.providers.map((appId) => [appId, "searching" as const])),
|
|
47
|
+
unsupportedTags: line.unsupportedTags ?? [],
|
|
48
|
+
};
|
|
49
|
+
if (line.type === "done") return { ...run, done: true };
|
|
50
|
+
// Only an app that is searching takes a line, which bounds the lines a run accepts.
|
|
51
|
+
if (run.status[line.provider] !== "searching") return run;
|
|
52
|
+
const shown = new Set(run.blocks.flatMap((block) => block.items.map(itemKey)));
|
|
53
|
+
const items = line.results.filter((item) => !shown.has(itemKey(item)));
|
|
54
|
+
return {
|
|
55
|
+
...run,
|
|
56
|
+
status: { ...run.status, [line.provider]: line.status },
|
|
57
|
+
blocks: items.length ? [...run.blocks, { appId: line.provider, items }] : run.blocks,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The request failed or the stream broke off. Once apps were named, the rows that arrived stay and every app still
|
|
63
|
+
* searching has failed on its own, with its own retry; before that, the search failed as a whole.
|
|
64
|
+
*/
|
|
65
|
+
export const breakSearchRun = (run: SearchRun): SearchRun =>
|
|
66
|
+
run.providers.length
|
|
67
|
+
? {
|
|
68
|
+
...run,
|
|
69
|
+
done: true,
|
|
70
|
+
status: Object.fromEntries(Object.entries(run.status).map(([appId, status]) => [appId, status === "searching" ? "error" : status])),
|
|
71
|
+
}
|
|
72
|
+
: { ...run, failed: true };
|
|
73
|
+
|
|
74
|
+
/** Marks an app as searching again before its retry starts. */
|
|
75
|
+
export const retrySearchApp = (run: SearchRun, appId: string): SearchRun => ({
|
|
76
|
+
...run,
|
|
77
|
+
status: { ...run.status, [appId]: "searching" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const searchingApps = (run: SearchRun) => run.providers.filter((appId) => run.status[appId] === "searching");
|
|
81
|
+
export const failedSearchApps = (run: SearchRun) =>
|
|
82
|
+
run.providers.flatMap((appId) => {
|
|
83
|
+
const status = run.status[appId];
|
|
84
|
+
return status === "timeout" || status === "error" ? [{ appId, status }] : [];
|
|
85
|
+
});
|
|
86
|
+
/** Every app has answered: only now may an empty run say that nothing was found. */
|
|
87
|
+
export const searchFinished = (run: SearchRun) => (run.done || run.failed) && searchingApps(run).length === 0;
|
|
88
|
+
|
|
89
|
+
/** An older Core answers with merged JSON; it becomes the same lines, without per-app timing. */
|
|
90
|
+
const linesFromResponse = (body: SearchResponse): SearchStreamLine[] => {
|
|
91
|
+
const failed = body.failedApps ?? [];
|
|
92
|
+
const found = [...new Set(body.items.map((item) => item.appId))];
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
type: "start",
|
|
96
|
+
query: body.query,
|
|
97
|
+
apps: body.apps,
|
|
98
|
+
providers: [...new Set([...found, ...failed])],
|
|
99
|
+
...(body.unsupportedTags ? { unsupportedTags: body.unsupportedTags } : {}),
|
|
100
|
+
},
|
|
101
|
+
...found.map((appId): SearchStreamLine => {
|
|
102
|
+
const results = body.items.filter((item) => item.appId === appId);
|
|
103
|
+
return { type: "provider", provider: appId, status: failed.includes(appId) ? "error" : "ok", results, ms: 0 };
|
|
104
|
+
}),
|
|
105
|
+
...failed
|
|
106
|
+
.filter((appId) => !found.includes(appId))
|
|
107
|
+
.map((appId): SearchStreamLine => ({ type: "provider", provider: appId, status: "error", results: [], ms: 0 })),
|
|
108
|
+
{ type: "done", status: failed.length ? "partial" : "complete", count: body.items.length },
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs one search against `/api/search` and hands over each line as it arrives. Aborting `signal` ends the request,
|
|
114
|
+
* and with it every provider the server still waits for. Rejects when the request fails or the stream breaks off.
|
|
115
|
+
*/
|
|
116
|
+
export const streamCloudResourceSearch = async (
|
|
117
|
+
url: string,
|
|
118
|
+
options: { signal: AbortSignal; locale: string; onLine: (line: SearchStreamLine) => void },
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
signal: options.signal,
|
|
122
|
+
headers: { accept: STREAM_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) throw new Error(`Search failed with ${response.status}`);
|
|
125
|
+
if (!response.headers.get("content-type")?.startsWith(STREAM_CONTENT_TYPE)) {
|
|
126
|
+
const body: SearchResponse = await response.json();
|
|
127
|
+
for (const line of linesFromResponse(body)) options.onLine(line);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!response.body) throw new Error("Search returned no body");
|
|
131
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
132
|
+
let buffer = "";
|
|
133
|
+
let closed = false;
|
|
134
|
+
while (!closed) {
|
|
135
|
+
const chunk = await reader.read();
|
|
136
|
+
if (chunk.done) break;
|
|
137
|
+
buffer += chunk.value;
|
|
138
|
+
let end = buffer.indexOf("\n");
|
|
139
|
+
while (end >= 0) {
|
|
140
|
+
const text = buffer.slice(0, end).trim();
|
|
141
|
+
buffer = buffer.slice(end + 1);
|
|
142
|
+
if (text) {
|
|
143
|
+
const line: SearchStreamLine = JSON.parse(text);
|
|
144
|
+
options.onLine(line);
|
|
145
|
+
if (line.type === "done") closed = true;
|
|
146
|
+
}
|
|
147
|
+
end = buffer.indexOf("\n");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!closed) throw new Error("Search stream ended early");
|
|
151
|
+
};
|
|
@@ -24,7 +24,7 @@ export const accountAdministrationCommands = [
|
|
|
24
24
|
throw new Error(
|
|
25
25
|
cliText(ctx, {
|
|
26
26
|
en: "Account administration changes require --yes.",
|
|
27
|
-
de: "Änderungen an der Account-
|
|
27
|
+
de: "Änderungen an der Account-Administration erfordern --yes.",
|
|
28
28
|
}),
|
|
29
29
|
);
|
|
30
30
|
const input = await readCliInput(flags.config, { required: true, label: "Account administration configuration" });
|
|
@@ -39,7 +39,7 @@ export const accountAdministrationCommands = [
|
|
|
39
39
|
}),
|
|
40
40
|
);
|
|
41
41
|
if (!printStructured(ctx, { saved: true }))
|
|
42
|
-
ctx.print(cliText(ctx, { en: "Account administration saved.", de: "Account-
|
|
42
|
+
ctx.print(cliText(ctx, { en: "Account administration saved.", de: "Account-Administration gespeichert." }));
|
|
43
43
|
},
|
|
44
44
|
}),
|
|
45
45
|
];
|