@k2b/cloud 0.28.0 → 0.29.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 +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +97 -17
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +15 -7
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +56 -20
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +1 -0
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +23 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { text } from "@k2b/stdlib";
|
|
2
|
+
import { Button, createCollectionSelection, FileGrid, Format, PanelDialog, Placeholder, ProgressBar, TextInput, useLocale } from "@k2b/ui";
|
|
3
|
+
import { batch, createEffect, createMemo, createSignal, createUniqueId, For, type JSX, Match, on, onCleanup, Show, Switch } from "solid-js";
|
|
4
|
+
import { createStore } from "solid-js/store";
|
|
5
|
+
import { FILE_PROVIDER_NAME_CONFLICT } from "../contracts/file-provider";
|
|
6
|
+
import { entryIcon, type FileProviderList, localTimeZone } from "./FileChooser";
|
|
7
|
+
import { fileChooserMessages } from "./file-chooser-messages";
|
|
8
|
+
import {
|
|
9
|
+
eachLimited,
|
|
10
|
+
type FileProviderCaller,
|
|
11
|
+
FileProviderError,
|
|
12
|
+
type FileProviderSource,
|
|
13
|
+
isSaveableName,
|
|
14
|
+
nextFreeName,
|
|
15
|
+
readSaveSource,
|
|
16
|
+
type SavedProviderFile,
|
|
17
|
+
type SaveFileSource,
|
|
18
|
+
saveableName,
|
|
19
|
+
saveProviderFile,
|
|
20
|
+
} from "./file-providers";
|
|
21
|
+
import { createProviderFolder, FolderContents, FolderToolbar } from "./provider-folder";
|
|
22
|
+
|
|
23
|
+
/** At most this many files are read and saved at the same time. */
|
|
24
|
+
const PARALLEL_SAVES = 2;
|
|
25
|
+
|
|
26
|
+
/** A file the person saved, with the app it went to and the provider's link to it. */
|
|
27
|
+
export type SavedFile = { name: string; app: string; href?: string };
|
|
28
|
+
|
|
29
|
+
type Problem = "size" | "source" | "access" | "offline" | "failed";
|
|
30
|
+
type Item = {
|
|
31
|
+
source: SaveFileSource;
|
|
32
|
+
/** The name it is saved under; a conflict asks for another. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** Idempotency key for this name in this folder: a retry never creates a second file, a new name gets a new key. */
|
|
35
|
+
key: string;
|
|
36
|
+
/** The provider opened a write stream under `key`, so bytes may have arrived and a retry must keep the key. */
|
|
37
|
+
streamed: boolean;
|
|
38
|
+
state: "waiting" | "loading" | "done" | "conflict" | "error";
|
|
39
|
+
progress: number;
|
|
40
|
+
draft: string;
|
|
41
|
+
problem?: Problem;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const problemOf = (cause: unknown): Problem => {
|
|
45
|
+
if (!(cause instanceof FileProviderError)) return "failed";
|
|
46
|
+
if (cause.code === "FILE_TOO_LARGE" || cause.status === 413) return "size";
|
|
47
|
+
if (cause.code === "SOURCE_UNAVAILABLE") return cause.status === 0 ? "offline" : "source";
|
|
48
|
+
if (cause.status === 0) return "offline";
|
|
49
|
+
if (cause.status === 403 || cause.status === 404) return "access";
|
|
50
|
+
return "failed";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The shared save dialog: the person picks a provider that stores files and one of its folders, then every file is
|
|
55
|
+
* read from the consumer and created there through the provider's `save`. Saving never replaces: a taken name asks
|
|
56
|
+
* for another, prefilled as `Name (2).ext`. `saved` collects every created file, however the dialog closes, and
|
|
57
|
+
* `running` holds each round of transfers, which settles soon after the dialog closed and stopped them.
|
|
58
|
+
*/
|
|
59
|
+
export function FileSaver(props: {
|
|
60
|
+
files: readonly SaveFileSource[];
|
|
61
|
+
providers: () => FileProviderList;
|
|
62
|
+
retryProviders: () => void;
|
|
63
|
+
caller: () => FileProviderCaller;
|
|
64
|
+
saved: SavedFile[];
|
|
65
|
+
running: Set<Promise<void>>;
|
|
66
|
+
close: () => void;
|
|
67
|
+
}): JSX.Element {
|
|
68
|
+
const locale = useLocale();
|
|
69
|
+
const t = () => fileChooserMessages.resolve([locale()]).t;
|
|
70
|
+
let root: HTMLDivElement | undefined;
|
|
71
|
+
|
|
72
|
+
const savers = createMemo(() => {
|
|
73
|
+
const list = props.providers();
|
|
74
|
+
return list.state === "ready" ? list.providers.filter((provider) => provider.save) : [];
|
|
75
|
+
});
|
|
76
|
+
/** With one provider, the dialog opens straight into it and the path starts there. */
|
|
77
|
+
const single = () => props.providers().state === "ready" && savers().length === 1;
|
|
78
|
+
|
|
79
|
+
const refocus = () => {
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
const active = document.activeElement;
|
|
82
|
+
if (active && root?.contains(active) && active.isConnected && active.tagName !== "DIALOG") return;
|
|
83
|
+
const first = folder.location() ? folder.items()[0]?.id : sourceRows()[0]?.appId;
|
|
84
|
+
if (first) (folder.location() ? selection : sourceSelection).focus(first);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const folder = createProviderFolder({ caller: props.caller, t, settled: refocus });
|
|
88
|
+
const sourceRows = createMemo(() => {
|
|
89
|
+
const needle = folder.filter().trim().toLocaleLowerCase();
|
|
90
|
+
return needle ? savers().filter((provider) => provider.name.toLocaleLowerCase().includes(needle)) : savers();
|
|
91
|
+
});
|
|
92
|
+
createEffect(
|
|
93
|
+
on(single, (only) => {
|
|
94
|
+
if (only && !folder.location()) folder.openProvider(savers()[0]!);
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const sourceSelection = createCollectionSelection({
|
|
99
|
+
ids: () => sourceRows().map((provider) => provider.appId),
|
|
100
|
+
multiple: false,
|
|
101
|
+
checklist: true,
|
|
102
|
+
});
|
|
103
|
+
// Files stay visible, so the person sees which names are taken, but only folders open.
|
|
104
|
+
const selection = createCollectionSelection({
|
|
105
|
+
ids: () => folder.items().map((entry) => entry.id),
|
|
106
|
+
isDisabled: (id) => folder.items().some((entry) => entry.id === id && entry.kind === "file"),
|
|
107
|
+
multiple: false,
|
|
108
|
+
checklist: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const [items, setItems] = createStore<Item[]>(
|
|
112
|
+
props.files.map((source) => ({
|
|
113
|
+
source,
|
|
114
|
+
name: saveableName(source.name),
|
|
115
|
+
key: "",
|
|
116
|
+
streamed: false,
|
|
117
|
+
state: "waiting",
|
|
118
|
+
progress: 0,
|
|
119
|
+
draft: "",
|
|
120
|
+
})),
|
|
121
|
+
);
|
|
122
|
+
const [target, setTarget] = createSignal<{ provider: FileProviderSource; parent: string } | null>(null);
|
|
123
|
+
const [running, setRunning] = createSignal(0);
|
|
124
|
+
let transfer: AbortController | undefined;
|
|
125
|
+
onCleanup(() => transfer?.abort());
|
|
126
|
+
|
|
127
|
+
const parent = () => folder.location()?.trail.at(-1)?.id;
|
|
128
|
+
const canSave = () => Boolean(folder.location() && parent() && folder.writable() && folder.status() === "ready");
|
|
129
|
+
const limit = () => target()?.provider.save?.maxBytes ?? folder.location()?.provider.save?.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
130
|
+
const done = () => items.filter((item) => item.state === "done").length;
|
|
131
|
+
const attention = () => items.filter((item) => item.state === "error" || item.state === "conflict").length;
|
|
132
|
+
|
|
133
|
+
const saveItems = async (indexes: number[]) => {
|
|
134
|
+
const where = target();
|
|
135
|
+
if (!where || indexes.length === 0) return;
|
|
136
|
+
transfer ??= new AbortController();
|
|
137
|
+
const signal = transfer.signal;
|
|
138
|
+
for (const index of indexes) setItems(index, { state: "waiting", progress: 0, problem: undefined });
|
|
139
|
+
setRunning((count) => count + indexes.length);
|
|
140
|
+
const round = eachLimited(indexes, PARALLEL_SAVES, async (index) => {
|
|
141
|
+
const item = items[index]!;
|
|
142
|
+
// A URL is read first and then sent, so each half of the bar is one direction; a Blob is only sent.
|
|
143
|
+
const share = typeof item.source.content === "string" ? 0.5 : 0;
|
|
144
|
+
try {
|
|
145
|
+
if (signal.aborted) return;
|
|
146
|
+
setItems(index, { state: "loading", progress: 0 });
|
|
147
|
+
const body = await readSaveSource(item.source, {
|
|
148
|
+
maxBytes: limit(),
|
|
149
|
+
signal,
|
|
150
|
+
fetch: props.caller().fetch,
|
|
151
|
+
onProgress: (loaded, total) => setItems(index, "progress", total ? (loaded / total) * share : 0),
|
|
152
|
+
});
|
|
153
|
+
const file: SavedProviderFile = await saveProviderFile(
|
|
154
|
+
where.provider,
|
|
155
|
+
{ parent: where.parent, name: item.name, body, idempotencyKey: item.key },
|
|
156
|
+
{
|
|
157
|
+
...props.caller(),
|
|
158
|
+
signal,
|
|
159
|
+
onStream: () => setItems(index, "streamed", true),
|
|
160
|
+
onProgress: (loaded, total) => setItems(index, "progress", share + (total ? loaded / total : 1) * (1 - share)),
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
// The file exists even when Cancel came with its receipt.
|
|
164
|
+
props.saved.push({ name: file.name, app: where.provider.name, ...(file.href ? { href: file.href } : {}) });
|
|
165
|
+
if (signal.aborted) return;
|
|
166
|
+
setItems(index, { state: "done", progress: 1 });
|
|
167
|
+
} catch (cause) {
|
|
168
|
+
if (signal.aborted) return;
|
|
169
|
+
if (cause instanceof FileProviderError && cause.code === FILE_PROVIDER_NAME_CONFLICT)
|
|
170
|
+
setItems(index, { state: "conflict", draft: nextFreeName(item.name) });
|
|
171
|
+
else setItems(index, { state: "error", problem: problemOf(cause) });
|
|
172
|
+
} finally {
|
|
173
|
+
setRunning((count) => count - 1);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
props.running.add(round);
|
|
177
|
+
await round;
|
|
178
|
+
if (signal.aborted) return;
|
|
179
|
+
if (items.every((item) => item.state === "done")) props.close();
|
|
180
|
+
else if (running() === 0) {
|
|
181
|
+
// The first row that needs the person takes focus: a name to change, or Save to try failed files again.
|
|
182
|
+
queueMicrotask(() =>
|
|
183
|
+
(
|
|
184
|
+
root?.querySelector<HTMLElement>(".cloud-file-saver__rename input") ?? root?.querySelector<HTMLElement>("[data-file-saver-save]")
|
|
185
|
+
)?.focus(),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const start = () => {
|
|
191
|
+
const current = folder.location();
|
|
192
|
+
const into = parent();
|
|
193
|
+
if (!current || !into || !canSave()) return;
|
|
194
|
+
batch(() => {
|
|
195
|
+
setTarget({ provider: current.provider, parent: into });
|
|
196
|
+
items.forEach((_, index) => setItems(index, { key: crypto.randomUUID(), streamed: false }));
|
|
197
|
+
});
|
|
198
|
+
queueMicrotask(() => root?.querySelector<HTMLButtonElement>("[data-file-saver-cancel]")?.focus());
|
|
199
|
+
void saveItems(items.map((_, index) => index));
|
|
200
|
+
};
|
|
201
|
+
const retry = () => {
|
|
202
|
+
const failed = items.flatMap((item, index) => (item.state === "error" ? [index] : []));
|
|
203
|
+
// Without a stream no byte was sent, so a new key cannot create a second file. Core may have frozen the old one
|
|
204
|
+
// after an Action whose outcome it does not know. With a stream, the same key replays it and finds a committed file.
|
|
205
|
+
for (const index of failed) if (!items[index]!.streamed) setItems(index, "key", crypto.randomUUID());
|
|
206
|
+
void saveItems(failed);
|
|
207
|
+
};
|
|
208
|
+
/** A new name waits until the files still running are done, so no more than two ever transfer at once. */
|
|
209
|
+
const canRename = (item: Item) => running() === 0 && isSaveableName(item.draft) && item.draft !== item.name;
|
|
210
|
+
const rename = (index: number) => {
|
|
211
|
+
const item = items[index]!;
|
|
212
|
+
if (!canRename(item)) return;
|
|
213
|
+
setItems(index, { name: item.draft, key: crypto.randomUUID(), streamed: false });
|
|
214
|
+
void saveItems([index]);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const problemText = (problem: Problem | undefined) => {
|
|
218
|
+
if (problem === "size") return t().tooLarge({ limit: text.pprintBytes(limit(), { locale: locale() }) });
|
|
219
|
+
if (problem === "source") return t().sourceFailed;
|
|
220
|
+
if (problem === "access") return t().saveForbidden;
|
|
221
|
+
if (problem === "offline") return t().offlineShort;
|
|
222
|
+
return t().saveFailed;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const title = () => t().saveTitle({ name: items[0]?.name ?? "", count: items.length });
|
|
226
|
+
const saving = () => target() !== null;
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<PanelDialog>
|
|
230
|
+
{/* `display: contents`, so header, body, and footer stay the panel's own rows. */}
|
|
231
|
+
<div ref={root} class="cloud-file-chooser">
|
|
232
|
+
<PanelDialog.Header title={title()} close={props.close} />
|
|
233
|
+
<FolderToolbar folder={folder} t={t} disabled={saving()} sources={!single()} />
|
|
234
|
+
<PanelDialog.Body>
|
|
235
|
+
<Switch>
|
|
236
|
+
<Match when={saving()}>
|
|
237
|
+
<ul class="cloud-file-chooser__downloads" aria-label={t().saving({ done: done(), total: items.length, failed: attention() })}>
|
|
238
|
+
<For each={items}>
|
|
239
|
+
{(item, index) => {
|
|
240
|
+
const stateId = createUniqueId();
|
|
241
|
+
return (
|
|
242
|
+
<li>
|
|
243
|
+
<div class="cloud-file-chooser__download-line">
|
|
244
|
+
<span class="cloud-file-chooser__download-name" title={item.name}>
|
|
245
|
+
{item.name}
|
|
246
|
+
</span>
|
|
247
|
+
<span
|
|
248
|
+
id={stateId}
|
|
249
|
+
class="cloud-file-chooser__download-state"
|
|
250
|
+
data-state={item.state === "conflict" ? "error" : item.state}
|
|
251
|
+
>
|
|
252
|
+
<Switch fallback={<Format.Percent value={item.progress} clamp />}>
|
|
253
|
+
<Match when={item.state === "waiting"}>{t().waiting}</Match>
|
|
254
|
+
<Match when={item.state === "done"}>{t().saved}</Match>
|
|
255
|
+
<Match when={item.state === "conflict" && !isSaveableName(item.draft)}>{t().invalidName}</Match>
|
|
256
|
+
<Match when={item.state === "conflict"}>{t().nameTaken}</Match>
|
|
257
|
+
<Match when={item.state === "error"}>{problemText(item.problem)}</Match>
|
|
258
|
+
</Switch>
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
{/* Every row keeps room for a name field, so a taken name never moves the rows below it. */}
|
|
262
|
+
<div class="cloud-file-saver__slot">
|
|
263
|
+
<Show
|
|
264
|
+
when={item.state === "conflict"}
|
|
265
|
+
fallback={
|
|
266
|
+
<ProgressBar
|
|
267
|
+
size="xs"
|
|
268
|
+
tone={item.state === "error" ? "danger" : item.state === "done" ? "success" : "info"}
|
|
269
|
+
value={item.progress * 100}
|
|
270
|
+
label={t().saveProgress({ name: item.name })}
|
|
271
|
+
/>
|
|
272
|
+
}
|
|
273
|
+
>
|
|
274
|
+
<div class="cloud-file-saver__rename">
|
|
275
|
+
<TextInput
|
|
276
|
+
aria-label={t().newName({ name: item.name })}
|
|
277
|
+
maxLength={255}
|
|
278
|
+
value={() => item.draft}
|
|
279
|
+
onValueChange={(value) => setItems(index(), "draft", value)}
|
|
280
|
+
onSubmit={() => rename(index())}
|
|
281
|
+
aria-describedby={stateId}
|
|
282
|
+
/>
|
|
283
|
+
<Button variant="secondary" disabled={!canRename(item)} onClick={() => rename(index())}>
|
|
284
|
+
{t().save}
|
|
285
|
+
</Button>
|
|
286
|
+
</div>
|
|
287
|
+
</Show>
|
|
288
|
+
</div>
|
|
289
|
+
</li>
|
|
290
|
+
);
|
|
291
|
+
}}
|
|
292
|
+
</For>
|
|
293
|
+
</ul>
|
|
294
|
+
</Match>
|
|
295
|
+
<Match when={!folder.location()}>
|
|
296
|
+
<Switch>
|
|
297
|
+
<Match when={props.providers().state === "loading"}>
|
|
298
|
+
<Placeholder state="loading" variant="panel" class="cloud-file-chooser__state" description={t().saveProvidersLoading} />
|
|
299
|
+
</Match>
|
|
300
|
+
<Match when={props.providers().state === "error"}>
|
|
301
|
+
<Placeholder
|
|
302
|
+
state="error"
|
|
303
|
+
variant="panel"
|
|
304
|
+
class="cloud-file-chooser__state"
|
|
305
|
+
description={t().saveProvidersFailed}
|
|
306
|
+
action={
|
|
307
|
+
<Button size="sm" variant="secondary" onClick={props.retryProviders}>
|
|
308
|
+
{t().retry}
|
|
309
|
+
</Button>
|
|
310
|
+
}
|
|
311
|
+
/>
|
|
312
|
+
</Match>
|
|
313
|
+
<Match when={savers().length === 0}>
|
|
314
|
+
<Placeholder
|
|
315
|
+
variant="panel"
|
|
316
|
+
class="cloud-file-chooser__state"
|
|
317
|
+
icon="ti ti-folder-off"
|
|
318
|
+
title={t().noSaveProviders}
|
|
319
|
+
description={t().noSaveProvidersHint}
|
|
320
|
+
/>
|
|
321
|
+
</Match>
|
|
322
|
+
<Match when={true}>
|
|
323
|
+
<FileGrid
|
|
324
|
+
rows={sourceRows()}
|
|
325
|
+
getRowId={(provider) => provider.appId}
|
|
326
|
+
selection={sourceSelection}
|
|
327
|
+
label={t().saveSourceList}
|
|
328
|
+
layout="list"
|
|
329
|
+
renderPreview={(provider) => <i class={`${provider.icon} cloud-file-chooser__icon`} aria-hidden="true" />}
|
|
330
|
+
renderLabel={(provider) => provider.name}
|
|
331
|
+
onRowClick={(provider) => {
|
|
332
|
+
sourceSelection.clear();
|
|
333
|
+
folder.openProvider(provider);
|
|
334
|
+
}}
|
|
335
|
+
onOpen={(provider) => {
|
|
336
|
+
sourceSelection.clear();
|
|
337
|
+
folder.openProvider(provider);
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
</Match>
|
|
341
|
+
</Switch>
|
|
342
|
+
</Match>
|
|
343
|
+
<Match when={true}>
|
|
344
|
+
<FolderContents folder={folder} t={t}>
|
|
345
|
+
<FileGrid
|
|
346
|
+
rows={folder.items()}
|
|
347
|
+
getRowId={(entry) => entry.id}
|
|
348
|
+
selection={selection}
|
|
349
|
+
label={t().entries({ folder: folder.folderName() })}
|
|
350
|
+
layout="list"
|
|
351
|
+
renderPreview={(entry) => <i class={`${entryIcon(entry)} cloud-file-chooser__icon`} aria-hidden="true" />}
|
|
352
|
+
renderLabel={(entry) => <span title={entry.name}>{entry.name}</span>}
|
|
353
|
+
renderMeta={(entry) =>
|
|
354
|
+
entry.kind === "file" ? (
|
|
355
|
+
<span class="cloud-file-chooser__meta">
|
|
356
|
+
<Format.Bytes value={entry.size} />
|
|
357
|
+
<Show when={entry.updatedAt}>
|
|
358
|
+
{(updated) => (
|
|
359
|
+
<>
|
|
360
|
+
<span aria-hidden="true">·</span>
|
|
361
|
+
<Format.Date value={updated()} timeZone={localTimeZone()} />
|
|
362
|
+
</>
|
|
363
|
+
)}
|
|
364
|
+
</Show>
|
|
365
|
+
</span>
|
|
366
|
+
) : null
|
|
367
|
+
}
|
|
368
|
+
onRowClick={(entry) => {
|
|
369
|
+
if (entry.kind === "folder") folder.openFolder(entry);
|
|
370
|
+
}}
|
|
371
|
+
onOpen={(entry) => {
|
|
372
|
+
if (entry.kind === "folder") folder.openFolder(entry);
|
|
373
|
+
}}
|
|
374
|
+
/>
|
|
375
|
+
</FolderContents>
|
|
376
|
+
</Match>
|
|
377
|
+
</Switch>
|
|
378
|
+
</PanelDialog.Body>
|
|
379
|
+
<PanelDialog.Footer>
|
|
380
|
+
<span class="cloud-file-chooser__status" role="status">
|
|
381
|
+
<Switch>
|
|
382
|
+
<Match when={saving()}>{t().saving({ done: done(), total: items.length, failed: attention() })}</Match>
|
|
383
|
+
<Match when={!folder.location() || folder.status() !== "ready"}>{null}</Match>
|
|
384
|
+
<Match when={!parent()}>{t().chooseFolder}</Match>
|
|
385
|
+
<Match when={!folder.writable()}>{t().notWritable}</Match>
|
|
386
|
+
<Match when={true}>{t().savesInto({ folder: folder.folderName() })}</Match>
|
|
387
|
+
</Switch>
|
|
388
|
+
</span>
|
|
389
|
+
{/* Labels never change, so the buttons keep their place. Cancel stops what is still running; Save tries failed files again. */}
|
|
390
|
+
<div class="cloud-file-chooser__actions">
|
|
391
|
+
<Button variant="ghost" data-file-saver-cancel onClick={props.close}>
|
|
392
|
+
{t().cancel}
|
|
393
|
+
</Button>
|
|
394
|
+
<Button
|
|
395
|
+
variant="primary"
|
|
396
|
+
data-file-saver-save
|
|
397
|
+
disabled={saving() ? running() > 0 || !items.some((item) => item.state === "error") : !canSave()}
|
|
398
|
+
onClick={() => (saving() ? retry() : start())}
|
|
399
|
+
>
|
|
400
|
+
{t().save}
|
|
401
|
+
</Button>
|
|
402
|
+
</div>
|
|
403
|
+
</PanelDialog.Footer>
|
|
404
|
+
</div>
|
|
405
|
+
</PanelDialog>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Window event the Cloud layout listens to; dispatched by `refreshAppBadges()`. */
|
|
2
|
+
export const APP_BADGES_REFRESH_EVENT = "cloud:app-badges-refresh";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads every app's `nav.badge` route again now, instead of at the next
|
|
6
|
+
* scheduled read. Call it after the person changed what your badge counts,
|
|
7
|
+
* for example after reading a conversation, so the rail and the app grid
|
|
8
|
+
* agree with the page. Calls while a read is still running wait for it and
|
|
9
|
+
* then share one more read, so calling it often never stacks up requests.
|
|
10
|
+
* Does nothing outside the Cloud layout, in a hidden tab, or when no running
|
|
11
|
+
* app declares a badge.
|
|
12
|
+
*/
|
|
13
|
+
export const refreshAppBadges = (): void => {
|
|
14
|
+
if (typeof window === "undefined") return;
|
|
15
|
+
window.dispatchEvent(new Event(APP_BADGES_REFRESH_EVENT));
|
|
16
|
+
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { FileDropzone, prompts } from "@k2b/ui";
|
|
1
2
|
import { chooseFiles } from "./files";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One "Attach" button that calls the real `chooseFiles` for a browser test, which answers the catalog, the
|
|
5
6
|
* provider's Queries, and its read streams. `?single` chooses one file, `?accept=` and `?max=` pass through.
|
|
6
7
|
* Each call gets a fresh signal that `window.abortChoosing()` aborts. Each result lands in `window.chosen` as plain
|
|
7
|
-
* facts.
|
|
8
|
+
* facts. `?dropzone` opens, like Notebooks, a dialog whose `FileDropzone` chooses with
|
|
9
|
+
* `chooseFiles`; what it hands over lands in `window.chosen` the same way.
|
|
8
10
|
*/
|
|
9
11
|
const params = new URLSearchParams(location.search);
|
|
10
12
|
type Chosen = { name: string; type: string; size: number; text: string }[];
|
|
@@ -12,21 +14,35 @@ const results: Chosen[] = [];
|
|
|
12
14
|
let choosing = new AbortController();
|
|
13
15
|
Object.assign(window, { chosen: results, abortChoosing: () => choosing.abort() });
|
|
14
16
|
|
|
17
|
+
const facts = (files: readonly File[]) =>
|
|
18
|
+
Promise.all(files.map(async (file) => ({ name: file.name, type: file.type, size: file.size, text: await file.text() })));
|
|
19
|
+
|
|
15
20
|
const button = document.createElement("button");
|
|
16
21
|
button.type = "button";
|
|
17
22
|
button.id = "attach";
|
|
18
23
|
button.textContent = "Attach";
|
|
19
24
|
document.body.append(button);
|
|
20
25
|
button.addEventListener("click", () => {
|
|
26
|
+
if (params.has("dropzone")) {
|
|
27
|
+
void prompts.dialog<void>(
|
|
28
|
+
() => (
|
|
29
|
+
<div class="k2b-dialog__body">
|
|
30
|
+
<FileDropzone
|
|
31
|
+
title="Attach to the note"
|
|
32
|
+
choose={() => chooseFiles({ multiple: true })}
|
|
33
|
+
onDrop={async (files) => void results.push(await facts(files))}
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
{ title: "Attach" },
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
21
41
|
choosing = new AbortController();
|
|
22
42
|
void chooseFiles({
|
|
23
43
|
multiple: !params.has("single"),
|
|
24
44
|
accept: params.get("accept") ?? undefined,
|
|
25
45
|
maxBytes: params.has("max") ? Number(params.get("max")) : undefined,
|
|
26
46
|
signal: choosing.signal,
|
|
27
|
-
}).then(async (files) =>
|
|
28
|
-
results.push(
|
|
29
|
-
await Promise.all(files.map(async (file) => ({ name: file.name, type: file.type, size: file.size, text: await file.text() }))),
|
|
30
|
-
);
|
|
31
|
-
});
|
|
47
|
+
}).then(async (files) => void results.push(await facts(files)));
|
|
32
48
|
});
|
|
@@ -2,40 +2,27 @@ import { showFileDialog } from "@k2b/stdlib/browser";
|
|
|
2
2
|
import { dialogCore, panelDialogWorkspaceOptions } from "@k2b/ui";
|
|
3
3
|
import { createSignal } from "solid-js";
|
|
4
4
|
import { type ChooseFilesOptions, FileChooser, type FileProviderList } from "./FileChooser";
|
|
5
|
-
import type { FileProviderCaller
|
|
5
|
+
import type { FileProviderCaller } from "./file-providers";
|
|
6
|
+
import type { ProviderList } from "./provider-list";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* One page's file choosing
|
|
9
|
-
* request. `caller` carries the locale and, in tests, the transport.
|
|
9
|
+
* One page's file choosing over its provider list. `caller` carries the locale and, in tests, the transport.
|
|
10
10
|
*/
|
|
11
11
|
export const createFileChoosing = (
|
|
12
|
-
|
|
12
|
+
list: ProviderList,
|
|
13
13
|
caller: () => FileProviderCaller = () => ({ locale: document.documentElement.lang || "en" }),
|
|
14
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
15
|
const chooseFiles = (options: ChooseFilesOptions = {}): Promise<File[]> => {
|
|
32
16
|
const { signal } = options;
|
|
33
17
|
if (signal?.aborted) return Promise.resolve([]);
|
|
18
|
+
const known = list.known();
|
|
34
19
|
if (known?.length === 0) {
|
|
35
20
|
// Synchronous on purpose: the native dialog needs the caller's user activation.
|
|
36
21
|
const picked = options.multiple
|
|
37
22
|
? showFileDialog({ accept: options.accept, multiple: true })
|
|
38
23
|
: showFileDialog({ accept: options.accept }).then((file) => [file]);
|
|
24
|
+
// Only a refusal leaves no request behind; asking again now lets the next choice find a renewed session's apps.
|
|
25
|
+
if (!list.requested()) list.prefetch();
|
|
39
26
|
// The page cannot close a native dialog; an abort resolves `[]` at once and a later pick is dropped.
|
|
40
27
|
return new Promise((resolve) => {
|
|
41
28
|
const abort = () => resolve([]);
|
|
@@ -43,28 +30,28 @@ export const createFileChoosing = (
|
|
|
43
30
|
picked.then(resolve, () => resolve([])).finally(() => signal?.removeEventListener("abort", abort));
|
|
44
31
|
});
|
|
45
32
|
}
|
|
46
|
-
const [
|
|
33
|
+
const [providers, setProviders] = createSignal<FileProviderList>(known ? { state: "ready", providers: known } : { state: "loading" });
|
|
47
34
|
const load = () => {
|
|
48
|
-
|
|
49
|
-
providers().then(
|
|
50
|
-
(value) =>
|
|
51
|
-
() =>
|
|
35
|
+
setProviders({ state: "loading" });
|
|
36
|
+
list.providers().then(
|
|
37
|
+
(value) => setProviders({ state: "ready", providers: value }),
|
|
38
|
+
() => setProviders({ state: "error" }),
|
|
52
39
|
);
|
|
53
40
|
};
|
|
54
41
|
if (!known) load();
|
|
55
42
|
return dialogCore
|
|
56
|
-
.open<File[]>(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
43
|
+
.open<File[]>(
|
|
44
|
+
(close) => <FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
|
|
45
|
+
{
|
|
46
|
+
...panelDialogWorkspaceOptions,
|
|
47
|
+
panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
|
|
48
|
+
initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
|
|
49
|
+
signal: options.signal,
|
|
50
|
+
history: true,
|
|
51
|
+
},
|
|
52
|
+
)
|
|
63
53
|
.then((files) => files ?? []);
|
|
64
54
|
};
|
|
65
55
|
|
|
66
|
-
return
|
|
67
|
-
chooseFiles,
|
|
68
|
-
prefetch: () => void providers().catch(() => undefined),
|
|
69
|
-
};
|
|
56
|
+
return chooseFiles;
|
|
70
57
|
};
|
|
@@ -43,6 +43,38 @@ export const fileChooserMessages = i18n.define({
|
|
|
43
43
|
downloadProgress: ({ name }: { name: string }) => `Downloading ${name}`,
|
|
44
44
|
waiting: "Waiting",
|
|
45
45
|
readFailed: "Could not be downloaded",
|
|
46
|
+
saveTo: ({ app }: { app: string }) => `Save to ${app}`,
|
|
47
|
+
saveToAny: "Save to…",
|
|
48
|
+
saveNamedTo: ({ name, app }: { name: string; app: string }) => `Save ${name} to ${app}`,
|
|
49
|
+
saveNamedToAny: ({ name }: { name: string }) => `Save ${name} to…`,
|
|
50
|
+
saveAllTo: ({ app }: { app: string }) => `Save all to ${app}`,
|
|
51
|
+
saveAllToAny: "Save all to…",
|
|
52
|
+
saveTitle: ({ name, count }: { name: string; count: number }) =>
|
|
53
|
+
count === 1 ? `Save “${name}”` : i18n.plural(count, "en", { one: "Save 1 file", other: `Save ${count} files` }),
|
|
54
|
+
saveSourceList: "Where to save",
|
|
55
|
+
saveProvidersLoading: "Looking for apps that store files…",
|
|
56
|
+
saveProvidersFailed: "Apps that store files could not be loaded.",
|
|
57
|
+
noSaveProviders: "No app can store files for you",
|
|
58
|
+
noSaveProvidersHint: "Download the file instead.",
|
|
59
|
+
chooseFolder: "Choose a folder",
|
|
60
|
+
notWritable: "You cannot save in this folder",
|
|
61
|
+
savesInto: ({ folder }: { folder: string }) => `Saves to ${folder}`,
|
|
62
|
+
save: "Save",
|
|
63
|
+
saving: ({ done, total, failed }: { done: number; total: number; failed: number }) =>
|
|
64
|
+
`${done} of ${total} saved${failed > 0 ? `, ${i18n.plural(failed, "en", { one: "1 needs attention", other: `${failed} need attention` })}` : ""}`,
|
|
65
|
+
saveProgress: ({ name }: { name: string }) => `Saving ${name}`,
|
|
66
|
+
saved: "Saved",
|
|
67
|
+
nameTaken: "This name is taken",
|
|
68
|
+
newName: ({ name }: { name: string }) => `New name for ${name}`,
|
|
69
|
+
invalidName: "Use a name without / or \\",
|
|
70
|
+
saveFailed: "Could not be saved",
|
|
71
|
+
saveForbidden: "No permission to save here",
|
|
72
|
+
sourceFailed: "Could not be read",
|
|
73
|
+
offlineShort: "You are offline",
|
|
74
|
+
savedOne: ({ name, app }: { name: string; app: string }) => `Saved ${name} to ${app}`,
|
|
75
|
+
savedMany: ({ count, total, app }: { count: number; total: number; app: string }) =>
|
|
76
|
+
count === total ? `Saved ${count} files to ${app}` : `Saved ${count} of ${total} files to ${app}`,
|
|
77
|
+
showIn: ({ app }: { app: string }) => `Show in ${app}`,
|
|
46
78
|
},
|
|
47
79
|
de: {
|
|
48
80
|
title: ({ multiple }) => (multiple ? "Dateien hinzufügen" : "Datei hinzufügen"),
|
|
@@ -83,6 +115,38 @@ export const fileChooserMessages = i18n.define({
|
|
|
83
115
|
downloadProgress: ({ name }) => `${name} wird heruntergeladen`,
|
|
84
116
|
waiting: "Wartet",
|
|
85
117
|
readFailed: "Konnte nicht heruntergeladen werden",
|
|
118
|
+
saveTo: ({ app }) => `In ${app} speichern`,
|
|
119
|
+
saveToAny: "Speichern in …",
|
|
120
|
+
saveNamedTo: ({ name, app }) => `${name} in ${app} speichern`,
|
|
121
|
+
saveNamedToAny: ({ name }) => `${name} speichern in …`,
|
|
122
|
+
saveAllTo: ({ app }) => `Alle in ${app} speichern`,
|
|
123
|
+
saveAllToAny: "Alle speichern in …",
|
|
124
|
+
saveTitle: ({ name, count }) =>
|
|
125
|
+
count === 1 ? `„${name}“ speichern` : i18n.plural(count, "de", { one: "1 Datei speichern", other: `${count} Dateien speichern` }),
|
|
126
|
+
saveSourceList: "Wo gespeichert wird",
|
|
127
|
+
saveProvidersLoading: "Apps, die Dateien speichern, werden gesucht…",
|
|
128
|
+
saveProvidersFailed: "Apps, die Dateien speichern, konnten nicht geladen werden.",
|
|
129
|
+
noSaveProviders: "Keine App kann Dateien für dich speichern",
|
|
130
|
+
noSaveProvidersHint: "Lade die Datei stattdessen herunter.",
|
|
131
|
+
chooseFolder: "Wähle einen Ordner",
|
|
132
|
+
notWritable: "In diesem Ordner kannst du nicht speichern",
|
|
133
|
+
savesInto: ({ folder }) => `Wird in ${folder} gespeichert`,
|
|
134
|
+
save: "Speichern",
|
|
135
|
+
saving: ({ done, total, failed }) =>
|
|
136
|
+
`${done} von ${total} gespeichert${failed > 0 ? `, ${i18n.plural(failed, "de", { one: "1 braucht dich", other: `${failed} brauchen dich` })}` : ""}`,
|
|
137
|
+
saveProgress: ({ name }) => `${name} wird gespeichert`,
|
|
138
|
+
saved: "Gespeichert",
|
|
139
|
+
nameTaken: "Dieser Name ist vergeben",
|
|
140
|
+
newName: ({ name }) => `Neuer Name für ${name}`,
|
|
141
|
+
invalidName: "Verwende einen Namen ohne / und \\",
|
|
142
|
+
saveFailed: "Konnte nicht gespeichert werden",
|
|
143
|
+
saveForbidden: "Keine Berechtigung, hier zu speichern",
|
|
144
|
+
sourceFailed: "Konnte nicht gelesen werden",
|
|
145
|
+
offlineShort: "Du bist offline",
|
|
146
|
+
savedOne: ({ name, app }) => `${name} in ${app} gespeichert`,
|
|
147
|
+
savedMany: ({ count, total, app }) =>
|
|
148
|
+
count === total ? `${count} Dateien in ${app} gespeichert` : `${count} von ${total} Dateien in ${app} gespeichert`,
|
|
149
|
+
showIn: ({ app }) => `In ${app} zeigen`,
|
|
86
150
|
},
|
|
87
151
|
},
|
|
88
152
|
});
|