@sparkelf/dsh-knowledge-base 0.1.0 → 0.1.1

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/lib/index.js CHANGED
@@ -2130,6 +2130,144 @@ function sshManagerRunner(options) {
2130
2130
  }
2131
2131
  };
2132
2132
  }
2133
+ /**
2134
+ * Create an extraction queue.
2135
+ *
2136
+ * @param options - the runner and the predicate that decides whether a path has a reader.
2137
+ * @returns a queue that runs at most {@link MAX_CONCURRENT_EXTRACTIONS} task at a time.
2138
+ */
2139
+ function createExtractionQueue(options) {
2140
+ /** Tasks by source path, in submission order. */
2141
+ const tasks = /* @__PURE__ */ new Map();
2142
+ /** Work waiting for the single worker slot. */
2143
+ const waiting = [];
2144
+ /** Resolvers waiting for the queue to drain. */
2145
+ let idleWaiters = [];
2146
+ /**
2147
+ * Resolvers waiting for ONE path's task to become terminal, keyed by that path.
2148
+ *
2149
+ * Per-task rather than a single queue-wide signal: a retry must not be woken by an
2150
+ * unrelated upload finishing, and a caller that registered a waiter must receive the
2151
+ * terminal state of its own task even if the bound evicts the record afterwards.
2152
+ */
2153
+ const taskWaiters = /* @__PURE__ */ new Map();
2154
+ let running;
2155
+ let started = false;
2156
+ /** Forget the oldest finished task once the bound is exceeded. */
2157
+ function evictFinished() {
2158
+ if (tasks.size <= 200) return;
2159
+ for (const [relPath, task] of tasks) {
2160
+ if (task.state === "pending" || task.state === "running") continue;
2161
+ tasks.delete(relPath);
2162
+ if (tasks.size <= 200) return;
2163
+ }
2164
+ }
2165
+ /** Resolve everyone waiting on an idle queue. */
2166
+ function settleIdle() {
2167
+ if (running !== void 0 || waiting.length > 0) return;
2168
+ const waiters = idleWaiters;
2169
+ idleWaiters = [];
2170
+ for (const resolve of waiters) resolve();
2171
+ }
2172
+ /** Run one task, recording its terminal state. */
2173
+ async function runOne(item) {
2174
+ const task = tasks.get(item.relPath);
2175
+ if (task === void 0) return;
2176
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2177
+ task.state = "running";
2178
+ task.startedAt = startedAt;
2179
+ running = item.relPath;
2180
+ try {
2181
+ const outcome = await options.run(item.relPath, item.mineruBaseUrl);
2182
+ task.state = outcome.state;
2183
+ if (outcome.markdown !== void 0) task.markdown = outcome.markdown;
2184
+ if (outcome.via !== void 0) task.via = outcome.via;
2185
+ if (outcome.reason !== void 0) task.reason = outcome.reason;
2186
+ } catch (error) {
2187
+ task.state = "failed";
2188
+ task.reason = error instanceof Error ? error.message : String(error);
2189
+ } finally {
2190
+ task.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2191
+ running = void 0;
2192
+ const waiters = taskWaiters.get(item.relPath);
2193
+ if (waiters !== void 0) {
2194
+ taskWaiters.delete(item.relPath);
2195
+ const terminal = { ...task };
2196
+ for (const resolve of waiters) resolve(terminal);
2197
+ }
2198
+ evictFinished();
2199
+ }
2200
+ }
2201
+ /** Pull work until the queue is empty, then let idle waiters go. */
2202
+ async function work() {
2203
+ if (started) return;
2204
+ started = true;
2205
+ try {
2206
+ while (waiting.length > 0) {
2207
+ const item = waiting.shift();
2208
+ if (item === void 0) break;
2209
+ if (!tasks.has(item.relPath)) continue;
2210
+ await runOne(item);
2211
+ }
2212
+ } finally {
2213
+ started = false;
2214
+ running = void 0;
2215
+ settleIdle();
2216
+ }
2217
+ }
2218
+ return {
2219
+ enqueue(relPath, mineruBaseUrl) {
2220
+ const existing = tasks.get(relPath);
2221
+ if (existing !== void 0 && (existing.state === "pending" || existing.state === "running")) return { ...existing };
2222
+ const task = {
2223
+ relPath,
2224
+ state: options.isExtractable(relPath) ? "pending" : "unsupported",
2225
+ queuedAt: (/* @__PURE__ */ new Date()).toISOString()
2226
+ };
2227
+ if (task.state === "pending") waiting.push({
2228
+ relPath,
2229
+ ...mineruBaseUrl === void 0 ? {} : { mineruBaseUrl }
2230
+ });
2231
+ else {
2232
+ task.reason = "this file type has no extractor";
2233
+ task.finishedAt = task.queuedAt;
2234
+ }
2235
+ tasks.set(relPath, task);
2236
+ evictFinished();
2237
+ if (task.state === "pending") Promise.resolve().then(() => work());
2238
+ return { ...task };
2239
+ },
2240
+ get(relPath) {
2241
+ const task = tasks.get(relPath);
2242
+ return task === void 0 ? void 0 : { ...task };
2243
+ },
2244
+ snapshot() {
2245
+ const visible = [];
2246
+ for (const task of tasks.values()) visible.push({ ...task });
2247
+ return {
2248
+ running,
2249
+ size: waiting.length + (running === void 0 ? 0 : 1),
2250
+ tasks: visible
2251
+ };
2252
+ },
2253
+ settled(relPath) {
2254
+ const task = tasks.get(relPath);
2255
+ if (task === void 0) return Promise.resolve(void 0);
2256
+ if (task.state !== "pending" && task.state !== "running") return Promise.resolve({ ...task });
2257
+ return new Promise((resolve) => {
2258
+ const pending = taskWaiters.get(relPath) ?? [];
2259
+ pending.push((value) => resolve(value));
2260
+ taskWaiters.set(relPath, pending);
2261
+ });
2262
+ },
2263
+ drain() {
2264
+ if (running === void 0 && waiting.length === 0) return Promise.resolve();
2265
+ return new Promise((resolve) => {
2266
+ idleWaiters.push(resolve);
2267
+ });
2268
+ }
2269
+ };
2270
+ }
2133
2271
  //#endregion
2134
2272
  //#region src/extract.ts
2135
2273
  /**
@@ -3573,9 +3711,13 @@ async function fetchPrincipals(ctx, config, kind) {
3573
3711
  const uuid = typeof row.id === "string" ? row.id : void 0;
3574
3712
  const id = kind === "user" ? uuid : code;
3575
3713
  if (id === void 0) return void 0;
3714
+ const name = typeof row.displayName === "string" ? row.displayName : typeof row.name === "string" ? row.name : typeof row.username === "string" ? row.username : id;
3715
+ const roleCodes = kind === "user" && Array.isArray(row.roles) ? row.roles.map((role) => role.code).filter((code) => typeof code === "string" && code !== "") : void 0;
3576
3716
  return {
3577
3717
  id,
3578
- label: typeof row.displayName === "string" ? row.displayName : typeof row.name === "string" ? row.name : typeof row.username === "string" ? row.username : id
3718
+ label: name,
3719
+ ...kind !== "user" || typeof row.username !== "string" ? {} : { username: row.username },
3720
+ ...roleCodes === void 0 ? {} : { roleCodes }
3579
3721
  };
3580
3722
  }).filter((row) => row !== void 0);
3581
3723
  }
@@ -3950,6 +4092,42 @@ async function extractBeside(config, relPath, mineruUrl) {
3950
4092
  };
3951
4093
  }
3952
4094
  /**
4095
+ * Translate {@link extractBeside}'s answer into a queue terminal state.
4096
+ *
4097
+ * The extractor reports three different outcomes through two fields, and they mean
4098
+ * different things to a reader deciding what to do next:
4099
+ *
4100
+ * - `markdown` set, no `reason` — the sibling was written (`succeeded`).
4101
+ * - `markdown` set WITH a `reason` — the text was produced but writing the sibling was
4102
+ * refused, typically because it already exists. The reason is kept so a retry can
4103
+ * report why nothing changed (`failed`, with the refusal verbatim).
4104
+ * - `markdown` empty, `reason` set — no authority could read the document; this is the
4105
+ * `no-extractor-succeeded: …` case a reader retries after an upgrade.
4106
+ *
4107
+ * An `undefined` answer means the format has no extractor at all, which the queue
4108
+ * normally prevents by asking `isExtractable` first; it is mapped for the caller that
4109
+ * bypassed that gate rather than left to throw.
4110
+ *
4111
+ * @param outcome - what {@link extractBeside} returned.
4112
+ * @returns the state the queue records.
4113
+ */
4114
+ function extractionOutcomeOf(outcome) {
4115
+ if (outcome === void 0) return {
4116
+ state: "unsupported",
4117
+ reason: "this file type has no extractor"
4118
+ };
4119
+ if (outcome.markdown !== "" && outcome.reason === void 0) return {
4120
+ state: "succeeded",
4121
+ markdown: outcome.markdown,
4122
+ ...outcome.via === void 0 ? {} : { via: outcome.via }
4123
+ };
4124
+ return {
4125
+ state: "failed",
4126
+ ...outcome.markdown === "" ? {} : { markdown: outcome.markdown },
4127
+ ...outcome.reason === void 0 ? {} : { reason: outcome.reason }
4128
+ };
4129
+ }
4130
+ /**
3953
4131
  * MinerU's base URL for this deployment, or undefined when it is not configured.
3954
4132
  *
3955
4133
  * @param config - resolved plugin config.
@@ -4313,6 +4491,22 @@ function apply(ctx, deploymentConfig) {
4313
4491
  /** Completed and in-flight step runs, newest last. */
4314
4492
  const runs = [];
4315
4493
  /**
4494
+ * Background extraction, one worker for this deployment.
4495
+ *
4496
+ * Upload and extraction are separate promises (see `extractionQueue.ts`): the bytes
4497
+ * must be visible when the upload answers, while the `.md` sibling may take a minute
4498
+ * on a scanned PDF. `run` reuses the same permission-checked `extractBeside` path the
4499
+ * upload used, so a background run and a manual retry cannot diverge. One queue per
4500
+ * registration means one worker per process, which is what keeps two parses from
4501
+ * competing for the same small box.
4502
+ */
4503
+ const extractionQueue = createExtractionQueue({
4504
+ isExtractable: (relPath) => isExtractable(relPath),
4505
+ run: async (relPath, mineruBaseUrl) => {
4506
+ return extractionOutcomeOf(await extractBeside(config, relPath, mineruBaseUrl));
4507
+ }
4508
+ });
4509
+ /**
4316
4510
  * Callers whose missing pointer has already been repaired once this process.
4317
4511
  *
4318
4512
  * A caller with nothing granted never gains a pointer from a sync, so without
@@ -4972,7 +5166,6 @@ function apply(ctx, deploymentConfig) {
4972
5166
  content: encoding === "base64" ? void 0 : content,
4973
5167
  ...encoding === "base64" ? { bytes: payloadBytes } : {}
4974
5168
  });
4975
- const extraction = await extractBeside(config, relPath, uploadSettings.mineruBaseUrl);
4976
5169
  await appendAuditEntry(uploadViewDir, {
4977
5170
  at: (/* @__PURE__ */ new Date()).toISOString(),
4978
5171
  actor: caller.userId,
@@ -4984,7 +5177,7 @@ function apply(ctx, deploymentConfig) {
4984
5177
  const afterWrite = await refreshViewIfStale(true, created.relPath);
4985
5178
  const reply = writeReplyAfterSync({
4986
5179
  created,
4987
- ...extraction === void 0 ? {} : { extraction }
5180
+ extraction: extractionQueue.enqueue(relPath, uploadSettings.mineruBaseUrl)
4988
5181
  }, afterWrite);
4989
5182
  return writeJson(res, reply.status, reply.body);
4990
5183
  } catch (error) {
@@ -5034,11 +5227,91 @@ function apply(ctx, deploymentConfig) {
5034
5227
  error: "the permission service did not identify this caller",
5035
5228
  detail: callerFailure
5036
5229
  });
5230
+ const visible = await listDirectoryWithPermissions(host, config, root, target, callerId);
5231
+ const present = new Set(visible.map((entry) => entry.name));
5232
+ const entries = visible.map((entry) => {
5233
+ if (entry.isDir) return entry;
5234
+ const extractable = isExtractable(entry.relPath);
5235
+ return {
5236
+ ...entry,
5237
+ canExtract: extractable,
5238
+ hasMarkdown: extractable ? present.has(entry.name + ".md") : false
5239
+ };
5240
+ });
5037
5241
  return writeJson(res, 200, {
5038
- entries: await listDirectoryWithPermissions(host, config, root, target, callerId),
5242
+ entries,
5243
+ total: entries.length,
5039
5244
  permissionsResolved: true
5040
5245
  });
5041
5246
  }
5247
+ case "extract.status": return writeJson(res, 200, extractionQueue.snapshot());
5248
+ case "extract.retry": {
5249
+ if (config.baseUrl.trim() === "") return writeJson(res, 409, { error: "DataOps permission API is not configured" });
5250
+ const body = await readJsonBody(req);
5251
+ const relPath = String(body.relPath ?? "");
5252
+ if (relPath.trim() === "") return writeJson(res, 400, { error: "relPath is required" });
5253
+ if (!isContainedRelPath(relPath)) return writeJson(res, 400, {
5254
+ error: "path escapes the knowledge root",
5255
+ refused: [relPath]
5256
+ });
5257
+ if (!isExtractable(relPath)) return writeJson(res, 200, {
5258
+ outcome: "unsupported",
5259
+ relPath,
5260
+ message: "this file type has no extractor"
5261
+ });
5262
+ const localRoot = localConfig(config);
5263
+ const target = join(localRoot.actualRoot, relPath);
5264
+ const source = await stat(target).catch(() => void 0);
5265
+ if (source === void 0 || !source.isFile()) return writeJson(res, 404, { error: "no such file" });
5266
+ if (!await canWritePath(host, config, relPath)) return writeJson(res, 403, {
5267
+ error: "write access is required to extract here",
5268
+ refused: [relPath]
5269
+ });
5270
+ const sibling = markdownSiblingOf(relPath);
5271
+ const existing = await stat(join(localRoot.actualRoot, sibling)).catch(() => void 0);
5272
+ if (existing !== void 0 && existing.isFile()) return writeJson(res, 200, {
5273
+ outcome: "exists",
5274
+ relPath,
5275
+ markdown: sibling
5276
+ });
5277
+ const settings = await readSettings(viewPathFor({
5278
+ actualRoot: localRoot.actualRoot,
5279
+ viewRoot: localRoot.viewRoot,
5280
+ userId: (await fetchCaller(host, config).catch(() => void 0))?.userId ?? ""
5281
+ }));
5282
+ const accepted = extractionQueue.enqueue(relPath, settings.mineruBaseUrl);
5283
+ if (body.wait === false) return writeJson(res, 202, {
5284
+ outcome: "queued",
5285
+ relPath,
5286
+ extraction: accepted
5287
+ });
5288
+ const settled = await extractionQueue.settled(relPath);
5289
+ const outcome = settled === void 0 ? {
5290
+ state: "failed",
5291
+ reason: "the extraction task was not recorded"
5292
+ } : {
5293
+ state: settled.state === "succeeded" ? "succeeded" : settled.state === "unsupported" ? "unsupported" : "failed",
5294
+ ...settled.markdown === void 0 ? {} : { markdown: settled.markdown },
5295
+ ...settled.via === void 0 ? {} : { via: settled.via },
5296
+ ...settled.reason === void 0 ? {} : { reason: settled.reason }
5297
+ };
5298
+ if (outcome.state === "succeeded") {
5299
+ const afterWrite = await refreshViewIfStale(true, sibling);
5300
+ const reply = writeReplyAfterSync({
5301
+ outcome: "extracted",
5302
+ relPath,
5303
+ markdown: outcome.markdown,
5304
+ ...outcome.via === void 0 ? {} : { via: outcome.via }
5305
+ }, afterWrite);
5306
+ return writeJson(res, reply.status, reply.body);
5307
+ }
5308
+ return writeJson(res, 200, {
5309
+ outcome: outcome.state === "unsupported" ? "unsupported" : "failed",
5310
+ relPath,
5311
+ ...outcome.reason === void 0 ? {} : { reason: outcome.reason },
5312
+ ...outcome.markdown === void 0 ? {} : { markdown: outcome.markdown }
5313
+ });
5314
+ }
5042
5315
  case "search": {
5043
5316
  const body = await readJsonBody(req);
5044
5317
  const term = String(body.term ?? "");
@@ -5172,13 +5445,17 @@ function apply(ctx, deploymentConfig) {
5172
5445
  const plan = localConfig(config);
5173
5446
  const caller = await fetchCaller(host, config);
5174
5447
  if (!isSafeUserId(caller.userId)) return writeJson(res, 400, { error: "unsafe caller id; cannot key a view by it" });
5448
+ const settings = await readSettings(viewPathFor({
5449
+ actualRoot: plan.actualRoot,
5450
+ viewRoot: plan.viewRoot,
5451
+ userId: caller.userId
5452
+ }));
5453
+ const canEdit = await canManageGrants(host, config);
5454
+ const mineruUrl = mineruUrlFor(config);
5175
5455
  return writeJson(res, 200, {
5176
- settings: await readSettings(viewPathFor({
5177
- actualRoot: plan.actualRoot,
5178
- viewRoot: plan.viewRoot,
5179
- userId: caller.userId
5180
- })),
5181
- canEdit: await canManageGrants(host, config)
5456
+ settings,
5457
+ canEdit,
5458
+ ...mineruUrl === void 0 ? {} : { mineruUrl }
5182
5459
  });
5183
5460
  }
5184
5461
  case "settings.set": {
@@ -5280,7 +5557,11 @@ function apply(ctx, deploymentConfig) {
5280
5557
  outcome: "applied"
5281
5558
  }).catch(() => void 0);
5282
5559
  const afterWrite = await refreshViewIfStale(true, created.relPath);
5283
- const reply = writeReplyAfterSync({ approved: created }, afterWrite);
5560
+ const approveSettings = await readSettings(viewDir);
5561
+ const reply = writeReplyAfterSync({
5562
+ approved: created,
5563
+ extraction: extractionQueue.enqueue(created.relPath, approveSettings.mineruBaseUrl)
5564
+ }, afterWrite);
5284
5565
  return writeJson(res, reply.status, reply.body);
5285
5566
  } catch (error) {
5286
5567
  if (error instanceof KnowledgeWriteError) return writeJson(res, writeStatusFor(error.code), {
@@ -5721,4 +6002,4 @@ async function executeStep(runner, step, hostId, record) {
5721
6002
  }
5722
6003
  }
5723
6004
  //#endregion
5724
- export { Config, KB_ROOT_RESOURCE_ID, KNOWLEDGE_SKILL_DESCRIPTION, KNOWLEDGE_SKILL_INVOCATION, KNOWLEDGE_SKILL_NAME, KNOWLEDGE_SKILL_PROVIDER, KNOWLEDGE_SKILL_RANK, KnowledgeError, KnowledgeWriteError, MAX_KNOWLEDGE_WRITE_BYTES, TEMPLATE_READ_TIMEOUT_MS, apply, canManageGrants, canReadPath, canWritePath, classifyMount, createGrant, createKnowledgeEntry, extractBeside, fetchCaller, fetchGrants, fetchPrincipals, inject, knowledgeAncestorsFor, knowledgeBaseSkillProvider, knowledgeSkillPath, listDirectory, listDirectoryShapes, listDirectoryWithPermissions, listRelativeFiles, localConfig, localStatus, mineruUrlFor, name, planContext, readDepartmentOf, readKnowledgeFile, readKnowledgeFileForDownload, revokeGrant, searchKnowledge, syncCallerView, withStoredStorage };
6005
+ export { Config, KB_ROOT_RESOURCE_ID, KNOWLEDGE_SKILL_DESCRIPTION, KNOWLEDGE_SKILL_INVOCATION, KNOWLEDGE_SKILL_NAME, KNOWLEDGE_SKILL_PROVIDER, KNOWLEDGE_SKILL_RANK, KnowledgeError, KnowledgeWriteError, MAX_KNOWLEDGE_WRITE_BYTES, TEMPLATE_READ_TIMEOUT_MS, apply, canManageGrants, canReadPath, canWritePath, classifyMount, createGrant, createKnowledgeEntry, extractBeside, extractionOutcomeOf, fetchCaller, fetchGrants, fetchPrincipals, inject, knowledgeAncestorsFor, knowledgeBaseSkillProvider, knowledgeSkillPath, listDirectory, listDirectoryShapes, listDirectoryWithPermissions, listRelativeFiles, localConfig, localStatus, mineruUrlFor, name, planContext, readDepartmentOf, readKnowledgeFile, readKnowledgeFileForDownload, revokeGrant, searchKnowledge, syncCallerView, withStoredStorage };
@@ -108,6 +108,16 @@ export declare const KB_API: {
108
108
  readonly templateSave: "template.save";
109
109
  /** Remove one template: its document and its metadata record. */
110
110
  readonly templateRemove: "template.remove";
111
+ /**
112
+ * Read what this process knows about extraction.
113
+ *
114
+ * The queue lives in memory, so this reports the tasks of the running process only;
115
+ * a file that is neither queued nor beside a `.md` sibling reads as never attempted,
116
+ * which is a fact about this process rather than a claim about the document.
117
+ */
118
+ readonly extractStatus: "extract.status";
119
+ /** Re-run extraction for one document that could not finish. */
120
+ readonly extractRetry: "extract.retry";
111
121
  /** Read the caller's pinned paths. */
112
122
  readonly favoritesGet: "favorites.get";
113
123
  readonly favoritesSet: "favorites.set";
@@ -572,3 +582,39 @@ export interface KnowledgeTemplateSaveInput {
572
582
  /** Set when editing an existing template, so its document is replaced in place. */
573
583
  id?: string;
574
584
  }
585
+ /** Where one extraction task is in its lifecycle, as the Host reports it. */
586
+ export type ExtractionStateView = 'pending' | 'running' | 'succeeded' | 'failed' | 'unsupported';
587
+ /**
588
+ * One extraction task.
589
+ *
590
+ * Mirrors the Host's own record: the source path, the state, and whatever it managed to
591
+ * learn about the outcome. `reason` is the extractor's verbatim text, which is the only
592
+ * thing that tells a reader whether to retry or give up.
593
+ */
594
+ export interface ExtractionTaskView {
595
+ /** Knowledge-relative path of the source document. */
596
+ relPath: string;
597
+ /** Where the task is in its lifecycle. */
598
+ state: ExtractionStateView;
599
+ /** When the task was accepted, ISO-8601. */
600
+ queuedAt: string;
601
+ /** When the run started, ISO-8601. */
602
+ startedAt?: string;
603
+ /** When the run reached a terminal state, ISO-8601. */
604
+ finishedAt?: string;
605
+ /** The Markdown sibling that was written, when one was. */
606
+ markdown?: string;
607
+ /** Which authority produced the text. */
608
+ via?: string;
609
+ /** Why the extraction produced nothing. */
610
+ reason?: string;
611
+ }
612
+ /** The extraction queue's snapshot. */
613
+ export interface ExtractionStatusView {
614
+ /** The path of the task running right now, when one is. */
615
+ running?: string;
616
+ /** How many tasks are queued or running. */
617
+ size: number;
618
+ /** Every task this process knows about. */
619
+ tasks: ExtractionTaskView[];
620
+ }
@@ -34,10 +34,18 @@ export interface ContextTarget {
34
34
  }
35
35
  /** The clipboard the copy/cut entries fill. */
36
36
  export interface TreeClipboard {
37
- /** The path that was copied or cut. */
38
- relPath: string;
39
- /** Its display name, so the paste label can name the entry. */
40
- name: string;
37
+ /**
38
+ * Every entry taken, in the order they were selected.
39
+ *
40
+ * A list rather than one path because a multi-row copy is a normal gesture and the
41
+ * paste has to put all of them somewhere. A single-row copy is a one-element list,
42
+ * which is why there is no separate single-row clipboard: one shape means the paste
43
+ * path cannot be right for one row and wrong for five.
44
+ */
45
+ entries: readonly {
46
+ relPath: string;
47
+ name: string;
48
+ }[];
41
49
  /** True when the source is removed on paste (cut); false for a copy request. */
42
50
  cut: boolean;
43
51
  }
@@ -45,6 +53,18 @@ export interface TreeClipboard {
45
53
  export interface KnowledgeContextMenuProps {
46
54
  /** The row the menu is open on, or null when closed. */
47
55
  target: ContextTarget | null;
56
+ /**
57
+ * Every row the menu acts on, when the reader right-clicked inside a multi-selection.
58
+ *
59
+ * Absent or shorter than two means single-row behaviour, which is why the component
60
+ * stays one menu rather than gaining a batch twin: the entries, the handlers and the
61
+ * clipboard are identical, and only the LABELS change to say how many rows they will
62
+ * touch. A second menu is how the two surfaces drifted apart before.
63
+ *
64
+ * `target` is still the row under the pointer -- it anchors the menu and supplies the
65
+ * name used by rename/download, which have no batch meaning.
66
+ */
67
+ selection?: readonly ContextTarget[];
48
68
  /** Where the pointer was, in viewport coordinates. */
49
69
  at: {
50
70
  x: number;
@@ -66,8 +86,13 @@ export interface KnowledgeContextMenuProps {
66
86
  onFavorite: (path: string, favorite: boolean) => void;
67
87
  /** Whether this path is currently favourited. */
68
88
  favorite: boolean;
69
- /** Insert a reference to this path into the conversation. */
70
- onReference: (path: string, isDir: boolean) => void;
89
+ /**
90
+ * Insert references into a conversation, via the picker.
91
+ *
92
+ * `targets` is present only for a multi-row selection; the single-row case passes the
93
+ * one path. The picker renders one dialog either way.
94
+ */
95
+ onReference: (path: string, isDir: boolean, targets?: readonly ContextTarget[]) => void;
71
96
  /** Called after a rename succeeds, so the page reloads the tree. */
72
97
  onRenamed: () => void;
73
98
  /** Called after a delete-to-trash succeeds, so the page reloads the tree. */
@@ -79,6 +79,35 @@ export interface KnowledgeFileTreeProps {
79
79
  * worse than the code they can at least match against their own department.
80
80
  */
81
81
  departmentPath?: DepartmentPath;
82
+ /**
83
+ * Whether one path is currently favourited, so its row can draw the star.
84
+ *
85
+ * The borrowed explorer has no row-decoration hook, so the star is applied by this
86
+ * wrapper observing the rows it renders (see `useRowFavorites`). Absent means the
87
+ * caller has no favourites store, which keeps the wrapper from touching the DOM at all.
88
+ */
89
+ isFavorite?: (relPath: string) => boolean;
90
+ /**
91
+ * Open the row context menu for one row.
92
+ *
93
+ * The explorer builds its own menu inside itself and marks the event handled, so the
94
+ * only place to intercept is a capture-phase listener above it. This wrapper does
95
+ * that and forwards the row here; the page then renders the SAME menu component the
96
+ * home table uses, so both surfaces offer one action set.
97
+ *
98
+ * Absent means "leave the explorer's own menu alone", which is what a read-only
99
+ * surface embedding this tree would want.
100
+ */
101
+ onRowContextMenu?: (relPath: string, isDir: boolean, at: {
102
+ x: number;
103
+ y: number;
104
+ }, selection?: readonly {
105
+ relPath: string;
106
+ name: string;
107
+ isDir: boolean;
108
+ }[]) => void;
109
+ /** The favourites star's accessible name. */
110
+ favoriteMarkLabel?: string;
82
111
  }
83
112
  /**
84
113
  * The knowledge tree, rendered by the product's file explorer.
@@ -70,6 +70,29 @@ export interface KnowledgeHomeProps {
70
70
  * re-read; the page owns the tick that triggers it.
71
71
  */
72
72
  onTreeChanged?: () => void;
73
+ /**
74
+ * Upload files dropped onto the table area.
75
+ *
76
+ * The table lists documents only -- there is no directory row here to aim at -- so
77
+ * the whole surface is one target and the destination has to be decided rather than
78
+ * read off the row under the pointer. `dropDirectory` carries that decision.
79
+ * Omitted by a read-only caller, which keeps the surface from accepting a drop that
80
+ * would then be refused on the Host.
81
+ * @param directory - the tree-relative directory the files land in.
82
+ * @param files - the dropped files with paths relative to that directory.
83
+ */
84
+ onUpload?: (directory: string, files: readonly {
85
+ file: unknown;
86
+ relativePath: string;
87
+ }[]) => void;
88
+ /**
89
+ * Where a drop on this table lands, tree-relative.
90
+ *
91
+ * The reader's own folder is the right default: `global` is the bucket everyone
92
+ * reads, so defaulting there would publish a private draft without asking. Empty
93
+ * means the mount root, which is what a caller with no personal bucket gets.
94
+ */
95
+ dropDirectory?: string;
73
96
  }
74
97
  /**
75
98
  * Render the Home surface.
@@ -73,6 +73,20 @@ export interface KnowledgeListingProps {
73
73
  * row is the tree's root, which has no parent to paste beside.
74
74
  */
75
75
  pasteTargetFor?: (relPath: string, isDir: boolean) => string | null;
76
+ /**
77
+ * Upload external files dropped onto a directory row.
78
+ *
79
+ * Distinct from the row-to-row move handled above: a file dragged in from the
80
+ * desktop carries no `KB_DRAG_MIME`, so it is the only case this callback serves.
81
+ * Omitted by a read-only caller, which keeps the rows from accepting a drop that
82
+ * would then be refused on the Host.
83
+ * @param relPath - the directory the files were dropped onto.
84
+ * @param files - the dropped files with paths relative to that directory.
85
+ */
86
+ onUpload?: (relPath: string, files: readonly {
87
+ file: unknown;
88
+ relativePath: string;
89
+ }[]) => void;
76
90
  /**
77
91
  * Called after a menu-driven delete or paste succeeded.
78
92
  *
@@ -21,12 +21,37 @@
21
21
  import type { GrantView, KnowledgeGrantSubjectType } from '../api.ts';
22
22
  import { type RoleSummary } from './roleSummary.ts';
23
23
  /** Which reading of the grant table the tabs are showing. */
24
- export type OverviewTab = 'person' | 'resource';
24
+ export type OverviewTab = 'person' | 'role' | 'resource';
25
25
  /** Copy for the overview, supplied by the caller's locale record. */
26
26
  export interface OverviewCopy {
27
27
  tabByPerson: string;
28
+ tabByRole: string;
28
29
  tabByResource: string;
29
30
  empty: string;
31
+ /** Empty state per view, so "nobody is directly granted" is not shown for a role. */
32
+ emptyByPerson: string;
33
+ emptyByRole: string;
34
+ emptyByResource: string;
35
+ /** Where a principal's access comes from. */
36
+ sourceDirect: string;
37
+ /** Format "inherited via role X": `{role}` is replaced by the role code. */
38
+ sourceViaRole: (role: string) => string;
39
+ /** Shown when the user directory could not be read, so inheritance is unknowable. */
40
+ sourceUnavailable: string;
41
+ /** Word for a permission set that could not be fully determined. */
42
+ actionsUnknown: string;
43
+ actionsUnknownHint: string;
44
+ /** Column headers unique to the three views. */
45
+ colAccount: string;
46
+ colSource: string;
47
+ /** "{count} member(s)". */
48
+ roleMemberCount: (count: number) => string;
49
+ /** Pager copy, shared with the audit table's wording. */
50
+ pagerPrev: string;
51
+ pagerNext: string;
52
+ pagerStatus: (page: number, pageCount: number, total: number) => string;
53
+ pageSizeLabel: string;
54
+ pageSizeOption: (size: number) => string;
30
55
  pathsHeld: (count: number) => string;
31
56
  rolesHeld: string;
32
57
  actionsHeld: string;
@@ -75,6 +100,24 @@ export interface KnowledgePermissionOverviewProps {
75
100
  grants: readonly GrantView[];
76
101
  /** Display names by `principalType:principalId`. */
77
102
  labels?: Readonly<Record<string, string>>;
103
+ /**
104
+ * The DataOps account directory, when the caller could read it.
105
+ *
106
+ * Absent means the same as an unavailable read: inheritance cannot be attributed, and
107
+ * the page says so instead of listing accounts that look like they hold nothing.
108
+ */
109
+ directory?: {
110
+ kind: 'available';
111
+ users: ReadonlyArray<{
112
+ id: string;
113
+ label: string;
114
+ username?: string;
115
+ roleCodes?: string[];
116
+ }>;
117
+ } | {
118
+ kind: 'unavailable';
119
+ reason: string;
120
+ };
78
121
  /** Built-in role summaries, so the person view can explain what a role means. */
79
122
  roles?: readonly RoleSummary[];
80
123
  /** Locale copy. */
@@ -92,8 +135,13 @@ export interface KnowledgePermissionOverviewProps {
92
135
  busy?: boolean;
93
136
  }
94
137
  /**
95
- * Render the permission overview as stat cards over a two-tab table.
138
+ * Render the permission overview as stat cards over a three-tab table.
139
+ *
140
+ * THREE views, not two, because the old pair answered different questions under one
141
+ * heading: "by person" listed ROLE grants, so the only rows were 管理员 and 超级管理员
142
+ * and a reader concluding "these are the people with access" was reading the page
143
+ * correctly and being told something false. A role is not a person.
96
144
  *
97
- * @param props - grants, labels, role summaries, locale copy and the revoke hook.
145
+ * @param props - grants, labels, directory, role summaries, copy and the revoke hook.
98
146
  */
99
147
  export declare function KnowledgePermissionOverview(props: KnowledgePermissionOverviewProps): import("react").JSX.Element;