@dreamlake/dreamlake-cli 0.2.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,256 +0,0 @@
1
- // `dreamlake update bindr|dataset <name> --project <target> [--add <glob>]
2
- // [--remove <glob>] [--description] [--tags]`.
3
- // Port of dreamlake-py's cli/commands/update.py.
4
- //
5
- // bindr — add/remove EPISODES (matched by node-path glob)
6
- // dataset — add/remove BINDRS (matched by name glob)
7
- import { HttpError, requestJson } from "../client.js";
8
- import { resolveNamespace, resolveRemote, resolveToken } from "../config.js";
9
- import { fail, ok, splitCsv } from "../helpers.js";
10
- import { fetchAllBindrs, fetchAllEpisodes, matchBindrs, matchEpisodes, resolveProjectCtx, } from "../resources.js";
11
- import { formatProject } from "../target.js";
12
- function nothingToDo(opts) {
13
- return !opts.add && !opts.remove && opts.description === undefined && opts.tags === undefined;
14
- }
15
- async function patchMetadata(remote, base, token, opts, kind, name, project) {
16
- if (opts.description === undefined && opts.tags === undefined)
17
- return true;
18
- const body = {};
19
- if (opts.description !== undefined)
20
- body.description = opts.description;
21
- if (opts.tags !== undefined)
22
- body.tags = splitCsv(opts.tags);
23
- try {
24
- await requestJson(remote, base, { method: "PATCH", token, json: body });
25
- ok(`Updated ${kind}: ${name}`);
26
- return true;
27
- }
28
- catch (err) {
29
- if (err instanceof HttpError && err.status === 404) {
30
- fail(`${kind} '${name}' not found in ${project}`);
31
- return false;
32
- }
33
- fail(err.message);
34
- return false;
35
- }
36
- }
37
- export async function runUpdateBindr(name, opts) {
38
- if (!opts.project) {
39
- fail("--project is required");
40
- return 1;
41
- }
42
- if (nothingToDo(opts)) {
43
- fail("nothing to update. use --add, --remove, --description, or --tags");
44
- return 1;
45
- }
46
- const ctx = await resolveProjectCtx(opts.project);
47
- if (!ctx)
48
- return 1;
49
- const { s, token, remote } = ctx;
50
- const proj = formatProject(s);
51
- const base = `/namespaces/${s.namespace}/projects/${s.project}/bindrs/${name}`;
52
- const episodes = opts.add || opts.remove ? await fetchAllEpisodes(remote, s.namespace, s.project, token) : [];
53
- try {
54
- if (opts.add) {
55
- const toAdd = matchEpisodes(episodes, opts.add);
56
- if (toAdd.length === 0) {
57
- fail(`no episodes match '${opts.add}'`);
58
- return 1;
59
- }
60
- process.stdout.write(`Adding ${toAdd.length} episode(s) matching '${opts.add}':\n`);
61
- for (const ep of toAdd)
62
- process.stdout.write(` ${ep.nodePath ?? ep.name ?? ""}\n`);
63
- const res = await requestJson(remote, `${base}/members`, {
64
- method: "POST",
65
- token,
66
- json: { add: toAdd.map((e) => e.id) },
67
- });
68
- ok(`Added ${toAdd.length} → ${res.total ?? "?"} total members`);
69
- }
70
- if (opts.remove) {
71
- const toRemove = matchEpisodes(episodes, opts.remove);
72
- if (toRemove.length === 0) {
73
- fail(`no episodes match '${opts.remove}'`);
74
- return 1;
75
- }
76
- process.stdout.write(`Removing ${toRemove.length} episode(s) matching '${opts.remove}':\n`);
77
- for (const ep of toRemove)
78
- process.stdout.write(` ${ep.nodePath ?? ep.name ?? ""}\n`);
79
- const res = await requestJson(remote, `${base}/members`, {
80
- method: "DELETE",
81
- token,
82
- json: { remove: toRemove.map((e) => e.id) },
83
- });
84
- ok(`Removed ${toRemove.length} → ${res.total ?? "?"} total members`);
85
- }
86
- }
87
- catch (err) {
88
- if (err instanceof HttpError && err.status === 404) {
89
- fail(`bindr '${name}' not found in ${proj}`);
90
- return 1;
91
- }
92
- fail(err.message);
93
- return 1;
94
- }
95
- return (await patchMetadata(remote, base, token, opts, "bindr", name, proj)) ? 0 : 1;
96
- }
97
- export async function runUpdateDataset(name, opts) {
98
- if (!opts.project) {
99
- fail("--project is required");
100
- return 1;
101
- }
102
- if (nothingToDo(opts)) {
103
- fail("nothing to update. use --add, --remove, --description, or --tags");
104
- return 1;
105
- }
106
- const ctx = await resolveProjectCtx(opts.project);
107
- if (!ctx)
108
- return 1;
109
- const { s, token, remote } = ctx;
110
- const proj = formatProject(s);
111
- const base = `/namespaces/${s.namespace}/projects/${s.project}/datasets/${name}`;
112
- const bindrs = opts.add || opts.remove ? await fetchAllBindrs(remote, s.namespace, s.project, token) : [];
113
- try {
114
- if (opts.add) {
115
- const toAdd = matchBindrs(bindrs, opts.add);
116
- if (toAdd.length === 0) {
117
- fail(`no bindrs match '${opts.add}'`);
118
- return 1;
119
- }
120
- process.stdout.write(`Adding ${toAdd.length} bindr(s) matching '${opts.add}':\n`);
121
- for (const b of toAdd)
122
- process.stdout.write(` ${b.name}\n`);
123
- const res = await requestJson(remote, `${base}/bindrs`, {
124
- method: "POST",
125
- token,
126
- json: { add: toAdd.map((b) => b.name) },
127
- });
128
- ok(`Added ${res.added ?? toAdd.length} → ${res.total ?? "?"} total bindrs`);
129
- }
130
- if (opts.remove) {
131
- const toRemove = matchBindrs(bindrs, opts.remove);
132
- if (toRemove.length === 0) {
133
- fail(`no bindrs match '${opts.remove}'`);
134
- return 1;
135
- }
136
- process.stdout.write(`Removing ${toRemove.length} bindr(s) matching '${opts.remove}':\n`);
137
- for (const b of toRemove)
138
- process.stdout.write(` ${b.name}\n`);
139
- const res = await requestJson(remote, `${base}/bindrs`, {
140
- method: "DELETE",
141
- token,
142
- json: { remove: toRemove.map((b) => b.name) },
143
- });
144
- ok(`Removed ${res.removed ?? toRemove.length} → ${res.total ?? "?"} total bindrs`);
145
- }
146
- }
147
- catch (err) {
148
- if (err instanceof HttpError && err.status === 404) {
149
- fail(`dataset '${name}' not found in ${proj}`);
150
- return 1;
151
- }
152
- fail(err.message);
153
- return 1;
154
- }
155
- return (await patchMetadata(remote, base, token, opts, "dataset", name, proj)) ? 0 : 1;
156
- }
157
- export async function runUpdateProject(slug, opts) {
158
- if (opts.visibility && opts.visibility !== "public" && opts.visibility !== "private") {
159
- fail(`--visibility must be 'public' or 'private', got '${opts.visibility}'`);
160
- return 1;
161
- }
162
- if (opts.name === undefined &&
163
- opts.description === undefined &&
164
- opts.visibility === undefined &&
165
- opts.tags === undefined) {
166
- fail("nothing to update. use --name, --description, --visibility, or --tags");
167
- return 1;
168
- }
169
- const remote = resolveRemote();
170
- const token = resolveToken();
171
- if (!token) {
172
- fail("not authenticated. run 'dreamlake login' first");
173
- return 1;
174
- }
175
- const namespace = await resolveNamespace(opts.namespace, { token, remote });
176
- if (!namespace) {
177
- fail("namespace not specified and no authenticated user found. run 'dreamlake login'");
178
- return 1;
179
- }
180
- const body = {};
181
- if (opts.name !== undefined)
182
- body.name = opts.name;
183
- if (opts.description !== undefined)
184
- body.description = opts.description;
185
- if (opts.visibility !== undefined)
186
- body.visibility = opts.visibility;
187
- if (opts.tags !== undefined)
188
- body.tags = splitCsv(opts.tags);
189
- let updated;
190
- try {
191
- updated = await requestJson(remote, `/namespaces/${namespace}/projects/${slug}`, {
192
- method: "PATCH",
193
- token,
194
- json: body,
195
- });
196
- }
197
- catch (err) {
198
- if (err instanceof HttpError && err.status === 404) {
199
- fail(`project '${slug}' not found in namespace ${namespace}`);
200
- return 1;
201
- }
202
- fail(err.message);
203
- return 1;
204
- }
205
- ok(`Updated project: ${slug}`);
206
- if (opts.visibility !== undefined) {
207
- process.stdout.write(` visibility: ${updated.visibility ?? opts.visibility}\n`);
208
- }
209
- return 0;
210
- }
211
- export function registerUpdateCommand(program) {
212
- const update = program.command("update").description("update a project, bindr, or dataset");
213
- update
214
- .command("project")
215
- .description("update a project's name / description / visibility / tags")
216
- .argument("<slug>", "project slug")
217
- .option("--namespace <slug>", "namespace (default: your own)")
218
- .option("--name <text>", "new display name")
219
- .option("--description <text>", "new description")
220
- .option("--visibility <vis>", "'public' or 'private'")
221
- .option("--public", "shortcut for --visibility public")
222
- .option("--private", "shortcut for --visibility private")
223
- .option("--tags <list>", "replace tags (comma-separated)")
224
- .action(async (slug, opts) => {
225
- const visibility = opts.public
226
- ? "public"
227
- : opts.private
228
- ? "private"
229
- : opts.visibility;
230
- process.exit(await runUpdateProject(slug, { ...opts, visibility }));
231
- });
232
- update
233
- .command("bindr")
234
- .description("add/remove episodes (by glob) or update metadata")
235
- .argument("<name>", "bindr name")
236
- .requiredOption("--project <target>", "project scope: space[@namespace]")
237
- .option("--add <glob>", "glob to add matching episodes")
238
- .option("--remove <glob>", "glob to remove matching episodes")
239
- .option("--description <text>", "update description")
240
- .option("--tags <list>", "replace tags (comma-separated)")
241
- .action(async (name, opts) => {
242
- process.exit(await runUpdateBindr(name, opts));
243
- });
244
- update
245
- .command("dataset")
246
- .description("add/remove bindrs (by glob) or update metadata")
247
- .argument("<name>", "dataset name")
248
- .requiredOption("--project <target>", "project scope: space[@namespace]")
249
- .option("--add <glob>", "glob to add matching bindrs")
250
- .option("--remove <glob>", "glob to remove matching bindrs")
251
- .option("--description <text>", "update description")
252
- .option("--tags <list>", "replace tags (comma-separated)")
253
- .action(async (name, opts) => {
254
- process.exit(await runUpdateDataset(name, opts));
255
- });
256
- }
@@ -1,263 +0,0 @@
1
- // `dreamlake upload <file|dir> --episode <target> --to <path>`.
2
- //
3
- // Single file → one multipart upload. Directory → flat (non-recursive)
4
- // folder upload with a resume manifest, matching dreamlake-py's
5
- // cli/commands/upload.py. `--bindr a,b` adds the uploaded node(s) to the
6
- // named bindrs (auto-created when missing).
7
- import { createHash, } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
9
- import { homedir } from "node:os";
10
- import path from "node:path";
11
- import { HttpError, requestJson } from "../client.js";
12
- import { resolveBss, resolveNamespace, resolveRemote, resolveToken } from "../config.js";
13
- import { fail, humanSize, ok, splitCsv } from "../helpers.js";
14
- import { confirm } from "../prompt.js";
15
- import { formatTarget, parseTarget } from "../target.js";
16
- import { detectKind } from "./kinds.js";
17
- import { uploadFile } from "./multipart.js";
18
- // Junk files skipped in folder mode (mirrors dl.upload_folder's defaults).
19
- const FOLDER_IGNORE = [/^\.DS_Store$/, /^Thumbs\.db$/, /\.tmp$/, /\.lock$/, /\.swp$/, /^\./];
20
- // ─── bindr membership ────────────────────────────────────────────────
21
- async function addToBindrs(bindrNames, nodeIds, t, token, remote) {
22
- if (bindrNames.length === 0 || nodeIds.length === 0)
23
- return;
24
- const base = `/namespaces/${t.namespace}/projects/${t.project}/bindrs`;
25
- for (const name of bindrNames) {
26
- try {
27
- const res = await requestJson(remote, `${base}/${name}/members`, {
28
- method: "POST",
29
- token,
30
- json: { add: nodeIds },
31
- });
32
- process.stdout.write(` bindr: added to '${name}' (total: ${res.total ?? "?"})\n`);
33
- }
34
- catch (err) {
35
- if (err instanceof HttpError && err.status === 404) {
36
- // Bindr doesn't exist — create it with these members.
37
- await requestJson(remote, base, {
38
- method: "POST",
39
- token,
40
- json: { name, members: nodeIds },
41
- });
42
- process.stdout.write(` bindr: created '${name}' (${nodeIds.length} files)\n`);
43
- }
44
- else {
45
- fail(`bindr '${name}': ${err.message}`);
46
- }
47
- }
48
- }
49
- }
50
- // ─── shared setup: resolve target + namespace + token ────────────────
51
- async function resolveContext(episode) {
52
- let t;
53
- try {
54
- t = parseTarget(episode);
55
- }
56
- catch (err) {
57
- fail(err.message);
58
- return null;
59
- }
60
- const remote = resolveRemote();
61
- const bss = resolveBss();
62
- const token = resolveToken();
63
- if (!token) {
64
- fail("not authenticated. run 'dreamlake login' first");
65
- return null;
66
- }
67
- const namespace = await resolveNamespace(t.namespace, { token, remote });
68
- if (!namespace) {
69
- fail("namespace not specified and no authenticated user found. run 'dreamlake login'");
70
- return null;
71
- }
72
- t.namespace = namespace;
73
- return {
74
- t,
75
- ut: { namespace, project: t.project, episode: t.episode },
76
- token,
77
- remote,
78
- bss,
79
- };
80
- }
81
- // ─── single file ─────────────────────────────────────────────────────
82
- async function uploadSingle(file, opts) {
83
- // Unknown extensions upload as kind="file" — the unified /files route
84
- // accepts any kind.
85
- const kind = detectKind(file, opts.type);
86
- const ctx = await resolveContext(opts.episode);
87
- if (!ctx)
88
- return 1;
89
- const destPath = opts.to.replace(/^\/+/, "");
90
- process.stdout.write(`Uploading ${path.basename(file)} (${kind})\n`);
91
- process.stdout.write(` episode: ${formatTarget(ctx.t)}\n`);
92
- process.stdout.write(` path: /${destPath}\n`);
93
- try {
94
- const result = await uploadFile(file, ctx.ut, destPath, kind, ctx.token, ctx.bss, ctx.remote);
95
- ok(`Uploaded: ${destPath ? `/${destPath}` : ""}/${path.basename(file)}`);
96
- process.stdout.write(` bss id: ${result.bssId ?? "?"}\n`);
97
- process.stdout.write(` node id: ${result.nodeId ?? "?"}\n`);
98
- const bindrNames = splitCsv(opts.bindr);
99
- if (bindrNames.length > 0 && result.nodeId) {
100
- await addToBindrs(bindrNames, [result.nodeId], ctx.ut, ctx.token, ctx.remote);
101
- }
102
- return 0;
103
- }
104
- catch (err) {
105
- fail(err.message);
106
- return 1;
107
- }
108
- }
109
- function manifestPath(dir, episode, to) {
110
- const stateDir = path.join(homedir(), ".dreamlake", "uploads");
111
- mkdirSync(stateDir, { recursive: true });
112
- const h = createHash("sha256")
113
- .update(`${path.resolve(dir)}:${episode}:${to}`)
114
- .digest("hex")
115
- .slice(0, 16);
116
- return path.join(stateDir, `folder-${h}.json`);
117
- }
118
- async function uploadFolder(dir, opts) {
119
- const ctx = await resolveContext(opts.episode);
120
- if (!ctx)
121
- return 1;
122
- const destPath = opts.to.replace(/^\/+/, "");
123
- process.stdout.write(`\nScanning ${dir} ...\n`);
124
- const allEntries = readdirSync(dir)
125
- .map((name) => path.join(dir, name))
126
- .filter((p) => statSync(p).isFile())
127
- .sort();
128
- const classified = {}; // filename → kind
129
- const skipped = [];
130
- for (const p of allEntries) {
131
- const name = path.basename(p);
132
- if (FOLDER_IGNORE.some((re) => re.test(name)))
133
- skipped.push(name);
134
- else
135
- classified[name] = detectKind(name);
136
- }
137
- const totalUploadable = Object.keys(classified).length;
138
- const mPath = manifestPath(dir, opts.episode, opts.to);
139
- let manifest;
140
- if (existsSync(mPath)) {
141
- try {
142
- manifest = JSON.parse(readFileSync(mPath, "utf8"));
143
- }
144
- catch {
145
- manifest = { sourceDir: path.resolve(dir), target: opts.episode, to: opts.to, files: {}, skipped: [] };
146
- }
147
- }
148
- else {
149
- manifest = { sourceDir: path.resolve(dir), target: opts.episode, to: opts.to, files: {}, skipped: [] };
150
- }
151
- for (const [name, cat] of Object.entries(classified)) {
152
- if (!manifest.files[name])
153
- manifest.files[name] = { status: "pending", category: cat };
154
- }
155
- manifest.skipped = skipped;
156
- // Summary.
157
- process.stdout.write(`\n Total ${allEntries.length} files\n`);
158
- const byCat = {};
159
- for (const cat of Object.values(classified))
160
- byCat[cat] = (byCat[cat] ?? 0) + 1;
161
- for (const cat of Object.keys(byCat).sort()) {
162
- process.stdout.write(` ${cat.padEnd(11)} ${byCat[cat]}\n`);
163
- }
164
- if (skipped.length > 0) {
165
- const preview = skipped.slice(0, 10).join(", ");
166
- const more = skipped.length > 10 ? ` ...and ${skipped.length - 10} more` : "";
167
- process.stdout.write(` Skipped ${skipped.length} — ignored (junk/hidden): ${preview}${more}\n`);
168
- }
169
- if (totalUploadable === 0) {
170
- process.stdout.write("\n No files to upload.\n");
171
- return 0;
172
- }
173
- if (!opts.yes) {
174
- const proceed = await confirm(" Continue?", true);
175
- if (!proceed)
176
- return 0;
177
- }
178
- writeFileSync(mPath, JSON.stringify(manifest, null, 2));
179
- // Upload pending/failed.
180
- const toUpload = Object.entries(manifest.files).filter(([, info]) => info.status === "pending" || info.status === "failed");
181
- let uploaded = 0;
182
- let failed = 0;
183
- for (const [name, info] of toUpload) {
184
- const filePath = path.join(dir, name);
185
- if (!existsSync(filePath)) {
186
- info.status = "failed";
187
- info.error = "file not found";
188
- failed++;
189
- continue;
190
- }
191
- const sizeStr = humanSize(statSync(filePath).size);
192
- process.stdout.write(`\n→ ${name} (${info.category}, ${sizeStr})\n`);
193
- try {
194
- const result = await uploadFile(filePath, ctx.ut, destPath, info.category, ctx.token, ctx.bss, ctx.remote);
195
- info.status = "done";
196
- if (result.nodeId)
197
- info.nodeId = result.nodeId;
198
- uploaded++;
199
- }
200
- catch (err) {
201
- info.status = "failed";
202
- info.error = err.message;
203
- failed++;
204
- fail(`${name}: ${err.message}`);
205
- }
206
- writeFileSync(mPath, JSON.stringify(manifest, null, 2));
207
- }
208
- // Summary.
209
- const totalDone = Object.values(manifest.files).filter((f) => f.status === "done").length;
210
- process.stdout.write("\n");
211
- if (failed === 0) {
212
- ok(`${totalDone}/${totalUploadable} uploaded, ${skipped.length} skipped`);
213
- }
214
- else {
215
- process.stdout.write(`⚠ ${totalDone}/${totalUploadable} uploaded, ${skipped.length} skipped, ${failed} failed\n`);
216
- process.stdout.write(" Re-run to retry failed files.\n");
217
- }
218
- // Bindr membership.
219
- const bindrNames = splitCsv(opts.bindr);
220
- if (bindrNames.length > 0) {
221
- const nodeIds = Object.values(manifest.files)
222
- .map((f) => f.nodeId)
223
- .filter((id) => Boolean(id));
224
- if (nodeIds.length > 0) {
225
- await addToBindrs(bindrNames, nodeIds, ctx.ut, ctx.token, ctx.remote);
226
- }
227
- }
228
- if (failed === 0 && existsSync(mPath))
229
- rmSync(mPath);
230
- return failed === 0 ? 0 : 1;
231
- }
232
- // ─── entry ───────────────────────────────────────────────────────────
233
- export async function runUpload(file, opts) {
234
- if (!opts.episode) {
235
- fail("--episode is required");
236
- return 1;
237
- }
238
- if (!opts.to) {
239
- fail("--to is required");
240
- return 1;
241
- }
242
- if (!existsSync(file)) {
243
- fail(`file not found: ${file}`);
244
- return 1;
245
- }
246
- if (statSync(file).isDirectory())
247
- return uploadFolder(file, opts);
248
- return uploadSingle(file, opts);
249
- }
250
- export function registerUploadCommand(program) {
251
- program
252
- .command("upload")
253
- .description("upload a file (or a flat directory) to DreamLake")
254
- .argument("<path>", "local file or directory")
255
- .requiredOption("--episode <target>", "episode scope: space[@namespace][:episode]")
256
- .requiredOption("--to <path>", "destination path within the episode")
257
- .option("--type <category>", "override auto-detected category")
258
- .option("--bindr <names>", "comma-separated bindr names to add the upload to")
259
- .option("--yes", "skip the folder-upload confirmation prompt")
260
- .action(async (file, opts) => {
261
- process.exit(await runUpload(file, opts));
262
- });
263
- }
@@ -1,85 +0,0 @@
1
- // Extension → kind / MIME maps for the unified BSS `/files` route.
2
- // Ported from dreamlake-py's __init__.py (dl.upload) — the per-category
3
- // BSS routes used by the older Python CLI no longer exist server-side;
4
- // `kind` is free-form and anything not in this table uploads as "file".
5
- export const EXTENSION_TO_KIND = {
6
- ".mp4": "video",
7
- ".mkv": "video",
8
- ".mov": "video",
9
- ".webm": "video",
10
- ".wav": "audio",
11
- ".mp3": "audio",
12
- ".aac": "audio",
13
- ".opus": "audio",
14
- ".flac": "audio",
15
- ".jpg": "image",
16
- ".jpeg": "image",
17
- ".png": "image",
18
- ".gif": "image",
19
- ".webp": "image",
20
- ".bmp": "image",
21
- ".tiff": "image",
22
- ".tif": "image",
23
- ".jsonl": "label-track",
24
- ".vtt": "text-track",
25
- ".srt": "text-track",
26
- ".parquet": "parquet",
27
- ".csv": "csv",
28
- ".npy": "npy",
29
- ".npz": "npy",
30
- ".pkl": "pickle",
31
- ".pickle": "pickle",
32
- ".txt": "text",
33
- ".md": "text",
34
- ".log": "text",
35
- ".json": "json",
36
- };
37
- export const MIME_MAP = {
38
- ".mp4": "video/mp4",
39
- ".mkv": "video/x-matroska",
40
- ".mov": "video/quicktime",
41
- ".webm": "video/webm",
42
- ".wav": "audio/wav",
43
- ".mp3": "audio/mpeg",
44
- ".aac": "audio/aac",
45
- ".opus": "audio/ogg",
46
- ".flac": "audio/flac",
47
- ".jpg": "image/jpeg",
48
- ".jpeg": "image/jpeg",
49
- ".png": "image/png",
50
- ".gif": "image/gif",
51
- ".webp": "image/webp",
52
- ".bmp": "image/bmp",
53
- ".tiff": "image/tiff",
54
- ".tif": "image/tiff",
55
- ".vtt": "text/vtt",
56
- ".srt": "text/plain",
57
- ".jsonl": "application/x-jsonlines",
58
- ".parquet": "application/vnd.apache.parquet",
59
- ".csv": "text/csv",
60
- ".npy": "application/octet-stream",
61
- ".npz": "application/octet-stream",
62
- ".pkl": "application/octet-stream",
63
- ".pickle": "application/octet-stream",
64
- ".txt": "text/plain",
65
- ".md": "text/markdown",
66
- ".log": "text/plain",
67
- ".json": "application/json",
68
- };
69
- /** Lowercase file extension including the dot, e.g. "/a/b.MP4" → ".mp4". */
70
- export function extOf(filename) {
71
- const dot = filename.lastIndexOf(".");
72
- if (dot < 0)
73
- return "";
74
- return filename.slice(dot).toLowerCase();
75
- }
76
- /**
77
- * Detect the asset kind from the extension, or use the override.
78
- * Unknown extensions fall through to "file" (still uploads — the server
79
- * treats kind as free-form).
80
- */
81
- export function detectKind(filename, typeOverride) {
82
- if (typeOverride)
83
- return typeOverride;
84
- return EXTENSION_TO_KIND[extOf(filename)] ?? "file";
85
- }