@sparkelf/dsh-knowledge-base 0.1.0 → 0.1.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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure helpers for the reference picker and the batch menu.
3
+ *
4
+ * Kept out of the components because the parts worth testing are the decisions, not the
5
+ * rendering: how a selection becomes a target list, what the batch label says, and which
6
+ * entries a paste loop should attempt. The components are wired to these, so a change in
7
+ * behaviour here is what the tests below pin down; the real DOM interaction is covered by
8
+ * the browser walkthrough instead.
9
+ *
10
+ * @module @sparkelf/dsh-knowledge-base/client
11
+ */
12
+ /** One entry a reference will be inserted for, in absolute path form. */
13
+ export interface ReferenceTarget {
14
+ /** Absolute path as the tree and the routes speak it. */
15
+ path: string;
16
+ /** True for a directory (folder mention) rather than a document (file chip). */
17
+ isDir: boolean;
18
+ }
19
+ /** One row a batch action covers. */
20
+ export interface BatchRow {
21
+ /** Path relative to the knowledge root. */
22
+ relPath: string;
23
+ /** Display name, for menu copy. */
24
+ name: string;
25
+ /** Whether the row is a directory. */
26
+ isDir: boolean;
27
+ }
28
+ /**
29
+ * The entries a picker opened on `relPath` should insert.
30
+ *
31
+ * A multi-row selection wins when it contains the clicked row, because that is the set
32
+ * the reader could see highlighted. Otherwise the click is a single-row action on the row
33
+ * under the pointer -- which is also what happens when the clicked row sits outside the
34
+ * selection, since acting on a batch the reader did not point at would be a surprise.
35
+ * @param relPath - the row the `@` button was pressed on.
36
+ * @param selection - the rows the explorer reports as selected, if any.
37
+ * @returns One target, or every selected row when the click was inside the selection.
38
+ */
39
+ export declare function referenceTargetsFor(relPath: string, selection?: readonly BatchRow[]): ReferenceTarget[];
40
+ /**
41
+ * The rows a menu opened on one row should act on.
42
+ *
43
+ * Mirrors {@link referenceTargetsFor}: a right-click inside a multi-selection acts on the
44
+ * whole selection, and anywhere else acts on the one row. The menu and the picker must
45
+ * agree, or a "delete 3 items" dialog would reference four.
46
+ * @param target - the row under the pointer.
47
+ * @param selection - the rows marked selected, if any.
48
+ * @returns The rows the batch entries cover, or an empty list for a single-row menu.
49
+ */
50
+ export declare function batchRowsFor(target: BatchRow, selection?: readonly BatchRow[]): BatchRow[];
51
+ /**
52
+ * Fill a batch label's `{n}` placeholder.
53
+ *
54
+ * A separate function so the count is formatted in one place: a label that prints the
55
+ * rows it will touch must never print a different number from the rows it does touch.
56
+ * @param template - the locale string, containing `{n}`.
57
+ * @param count - how many rows.
58
+ * @returns The label with the count substituted.
59
+ */
60
+ export declare function batchLabel(template: string, count: number): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Turning grants into the two "share" groups the Home tab shows.
3
+ *
4
+ * The split the reader cares about is DIRECTION: what has been shared with me, and what
5
+ * I have shared out. The grant data has no grantor field (the backend's `toSummary`
6
+ * omits it and the table has no `created_by`), so "who gave me this" cannot be answered
7
+ * from it. What CAN be answered is the audience side: which principal a grant names.
8
+ *
9
+ * Everything here is a pure function of the grant list plus the caller, which is what
10
+ * makes the two-way split testable without a browser.
11
+ *
12
+ * @module @sparkelf/dsh-knowledge-base/client
13
+ */
14
+ /** One grant, in the shape the Host relays. */
15
+ export interface ShareGrant {
16
+ /** Stable row id. */
17
+ id: string;
18
+ /** Path relative to the knowledge mount point. */
19
+ relPath: string;
20
+ /** Whether the path is a file or a directory. */
21
+ kind: 'file' | 'directory';
22
+ /** Principal kind the grant names. */
23
+ principalType: 'user' | 'role' | 'department' | 'group';
24
+ /** Principal id within its kind. */
25
+ principalId: string;
26
+ /** Permission the grant confers. */
27
+ action: string;
28
+ }
29
+ /** A principal name lookup: id per kind, falling back to the id itself. */
30
+ export type PrincipalNameOf = (kind: ShareGrant['principalType'], id: string) => string | undefined;
31
+ /** Which group one grant belongs to. */
32
+ export type ShareDirection = 'received' | 'given';
33
+ /** One row of either group. */
34
+ export interface ShareRow {
35
+ /** Stable row id, taken from the grant. */
36
+ id: string;
37
+ /** Path relative to the knowledge mount point. */
38
+ relPath: string;
39
+ /** File name alone, for the row's title. */
40
+ name: string;
41
+ /** Directory the path sits in, for the location column. */
42
+ location: string;
43
+ /** Principal kind the grant names. */
44
+ principalType: ShareGrant['principalType'];
45
+ /** The audience this grant names, resolved to a display name when possible. */
46
+ audience: string;
47
+ /** The permission the grant confers. */
48
+ action: string;
49
+ }
50
+ /**
51
+ * Whether a grant names the caller directly.
52
+ *
53
+ * Only a `user` grant can: a role, department or group is an audience the caller
54
+ * belongs to rather than the caller themselves, and treating it as "me" would let a
55
+ * department-wide grant read as a personal share.
56
+ * @param grant - the grant to test.
57
+ * @param callerId - the caller's own user id, when known.
58
+ * @returns True when the grant names this exact user.
59
+ */
60
+ export declare function isOwnGrant(grant: ShareGrant, callerId: string | undefined): boolean;
61
+ /**
62
+ * Split grants into the two directions the Home tab shows.
63
+ *
64
+ * The rule is deliberately simple and says only what the data supports: a grant that
65
+ * names the caller is "shared with me"; every other grant is "shared out". It does NOT
66
+ * claim the caller created the latter -- the grantor is not in this data at all -- so
67
+ * callers must word that group as an audience ("I shared to X"), never as a fact about
68
+ * who granted it.
69
+ *
70
+ * A grant naming the caller is excluded from the outgoing group even when it also
71
+ * appears there, so one document cannot look like it was shared to the reader and by
72
+ * the reader at the same time.
73
+ * @param grants - every grant the Host relayed.
74
+ * @param callerId - the caller's own user id, when known.
75
+ * @param nameOf - the principal directory lookup.
76
+ * @returns Both groups, each in the order the grants arrived.
77
+ */
78
+ export declare function splitShareRelations(grants: readonly ShareGrant[], callerId: string | undefined, nameOf: PrincipalNameOf): {
79
+ received: ShareRow[];
80
+ given: ShareRow[];
81
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Drag-and-drop upload: the part that can be decided without a browser.
3
+ *
4
+ * A drop needs three things settled before anything is uploaded: which directory the
5
+ * pointer is over, whether that directory may receive a drop, and the list of files
6
+ * (with their paths inside a dropped folder) that the drop carried. All three are
7
+ * answers about *data*, not about React state, so they live here and are tested
8
+ * directly instead of through a rendered tree.
9
+ *
10
+ * The module deliberately does not touch `DataTransfer`, `FileList` or `document`.
11
+ * The test runner has no DOM (measured: `DataTransfer`, `FileList` and `document` are
12
+ * all undefined, only `File` exists), and a module that reached for them could only be
13
+ * tested by faking the entire browser. It describes the two shapes it needs instead,
14
+ * and the component adapts a real event to them at the boundary. That is also what
15
+ * keeps the traversal order and the collision rules honest: they are pure functions of
16
+ * their inputs, so a test can assert them without a browser.
17
+ *
18
+ * @module @sparkelf/dsh-knowledge-base/client
19
+ */
20
+ /** A file plus the path it should occupy under the drop target. */
21
+ export interface DroppedFile {
22
+ /** The browser's `File`, or anything carrying a name and a byte source. */
23
+ file: unknown;
24
+ /**
25
+ * Path relative to the directory the reader dropped onto.
26
+ *
27
+ * A single dropped file carries only its name. A file from a dropped folder carries
28
+ * the folder's segments too (`specs/2026/api.md`), which is what preserves the shape
29
+ * the reader saw in the file manager instead of flattening it.
30
+ */
31
+ relativePath: string;
32
+ }
33
+ /**
34
+ * What a directory row knows about itself, as far as a drop is concerned.
35
+ *
36
+ * The two flags are separate on purpose. `isDirectory` says what the row IS; `drops`
37
+ * says whether this surface accepts drops AT ALL right now (an upload already running,
38
+ * or a read-only caller). A file row is a directory the drop must ignore, while a
39
+ * disabled directory row is one that must also ignore it -- the reason differs but the
40
+ * answer does not, so `acceptsDrop()` is the single place that combines them.
41
+ */
42
+ export interface DropTargetLike {
43
+ /** The directory an accepted drop uploads into, relative to the knowledge root. */
44
+ relativePath: string;
45
+ /** Whether the row names a folder rather than a document. */
46
+ isDirectory: boolean;
47
+ /** Whether this surface accepts drops at the moment (false while busy/read-only). */
48
+ drops: boolean;
49
+ }
50
+ /** The minimum a drop event must expose for this module to read it. */
51
+ export interface DropEventLike {
52
+ /** Whether the event carries file payload; a text drag must not upload anything. */
53
+ hasFiles: boolean;
54
+ /** The pending file list, or undefined when the browser exposes none. */
55
+ files: readonly DroppedFile[] | undefined;
56
+ }
57
+ /** Why a drop produced no upload. Each case is a different thing to tell the reader. */
58
+ export type DropRefusal =
59
+ /** The pointer was over a document, or outside any drop target. */
60
+ 'not-a-directory'
61
+ /** A drop target was hit but it is not accepting drops right now. */
62
+ | 'disabled'
63
+ /** The target accepted, but the drop carried no files (a text or link drag). */
64
+ | 'no-files';
65
+ /** The verdict for one drop. */
66
+ export type DropPlan = {
67
+ kind: 'upload';
68
+ directory: string;
69
+ files: DroppedFile[];
70
+ } | {
71
+ kind: 'refuse';
72
+ reason: DropRefusal;
73
+ };
74
+ /**
75
+ * Whether this row may receive the drop that is being dragged over it.
76
+ *
77
+ * Used by `dragover` to decide `preventDefault` (which is what makes the browser show
78
+ * "copy" and allows the drop) and whether to paint the highlight. It answers `false`
79
+ * for a document row so a file dragged onto a file is refused rather than silently
80
+ * doing nothing, and `false` while the surface is busy so a second drop cannot start
81
+ * a competing upload.
82
+ * @param target - the row under the pointer, or undefined outside any row.
83
+ * @returns Whether the row would accept a drop.
84
+ */
85
+ export declare function acceptsDrop(target: DropTargetLike | undefined): boolean;
86
+ /**
87
+ * Decide what one drop should do.
88
+ *
89
+ * Kept separate from the event so the refusal cases stay distinguishable: "you dropped
90
+ * it on a document" and "you dragged text, not files" need different sentences, and a
91
+ * single `undefined` return would force the caller to guess which happened.
92
+ * @param target - the row under the pointer, or undefined outside any row.
93
+ * @param event - the drop's payload.
94
+ * @returns The upload to perform, or why it was refused.
95
+ */
96
+ export declare function planDrop(target: DropTargetLike | undefined, event: DropEventLike): DropPlan;
97
+ /**
98
+ * Normalise a path relative to the drop target.
99
+ *
100
+ * Browsers report a dropped folder's contents with either separator (the `File` API on
101
+ * Windows reports `\`, `webkitRelativePath` uses `/`), and a leading separator would
102
+ * make the joined path absolute and fail the server's containment check. Both are
103
+ * collapsed here so the server sees one form.
104
+ * @param raw - the path as the browser reported it.
105
+ * @returns The path with forward slashes and no leading separator.
106
+ */
107
+ export declare function normalizeDropPath(raw: string): string;
108
+ /**
109
+ * Join a drop target with one file's own path.
110
+ * @param directory - the target directory relative to the knowledge root, `''` for the root.
111
+ * @param relativePath - the file's path inside the drop, already normalised or not.
112
+ * @returns The target path relative to the knowledge root.
113
+ */
114
+ export declare function dropTargetPath(directory: string, relativePath: string): string;
115
+ /**
116
+ * Collapse a dropped list to one entry per destination.
117
+ *
118
+ * Dropping the same folder twice, or a folder plus one of its files, would otherwise
119
+ * send two writes to the same path: the first succeeds and the second is refused as
120
+ * "already exists", so the reader would be shown a failure for a file that is actually
121
+ * there. The FIRST occurrence wins because that is the one the browser enumerated
122
+ * first, and its bytes are the ones the reader saw at the top of the drag.
123
+ * @param files - the dropped files.
124
+ * @returns One entry per distinct destination path, in first-seen order.
125
+ */
126
+ export declare function dedupeDropped(files: readonly DroppedFile[]): DroppedFile[];
127
+ /**
128
+ * One line describing the outcome of a whole drop, or undefined when there is nothing
129
+ * worth saying (all files landed).
130
+ *
131
+ * The reader dropped N files and gets one message: naming each failure is unreadable,
132
+ * while "some failed" without a count is not actionable. Partial success therefore
133
+ * states both numbers and names the first few failures, because the next step (retry
134
+ * that one, rename it, ask for access) depends on WHICH file was refused.
135
+ * @param results - one outcome per attempted file, in order.
136
+ * @param maxNames - how many failing names to spell out before summarising the rest.
137
+ * @returns The sentence to show, or undefined when every file landed.
138
+ */
139
+ export declare function summarizeDrop(results: readonly {
140
+ relativePath: string;
141
+ ok: boolean;
142
+ }[], maxNames?: number): {
143
+ ok: number;
144
+ failed: string[];
145
+ } | undefined;
146
+ /**
147
+ * Whether a drag event carries files this surface could upload.
148
+ *
149
+ * `types` is the only part of a `DataTransfer` readable during `dragover`: the payload
150
+ * itself is protected until the drop, so this is what `dragover` must ask to decide
151
+ * whether to `preventDefault`. An internal row drag sets `text/plain` and the knowledge
152
+ * base's own MIME type but NOT `Files`, which is how an in-page move stays a move.
153
+ * @param types - `event.dataTransfer.types`.
154
+ * @returns Whether the drag is an external file payload.
155
+ */
156
+ export declare function isFileDrag(types: readonly string[] | undefined): boolean;
157
+ /**
158
+ * Read the files of a drop as the shape {@link planDrop} takes.
159
+ *
160
+ * `webkitGetAsEntry` is preferred because it is the only way a DROPPED FOLDER arrives:
161
+ * folders never appear in `dataTransfer.files`, so a plain file list silently drops them
162
+ * and the reader sees "nothing happened". The entry API must be read synchronously from
163
+ * the drop handler (the payload enters protected mode once the event dispatch ends), so
164
+ * entries are captured here first and traversed afterwards.
165
+ *
166
+ * Falls back to the flat file list when the entry API is missing or yields nothing
167
+ * usable, which is also the correct behaviour for a browser that only reports files.
168
+ * @param transfer - the drop's `dataTransfer`.
169
+ * @returns One entry per file, with paths relative to the drop target.
170
+ */
171
+ export declare function readDroppedFiles(transfer: DataTransfer | undefined | null): Promise<DroppedFile[]>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The React shell around the drag-and-drop decisions.
3
+ *
4
+ * Everything that can be decided without a browser lives in `useFileDrop.ts` next to
5
+ * this file -- the target rules, the path joining, the de-duplication, the summary.
6
+ * What is left is the one thing that genuinely needs a component: which row is
7
+ * currently painted as the drop target.
8
+ *
9
+ * Kept in its own module for two reasons. The pure module must stay importable from a
10
+ * test that has no DOM (measured: the runner has `File`, but no `DataTransfer`,
11
+ * `FileList` or `document`), and importing React into it would make that impossible.
12
+ * And the component wiring is the part that has to wait for the in-flight edits to
13
+ * `KnowledgeFileTree.tsx` / `KnowledgeHome.tsx` to land, so it is kept separate rather
14
+ * than half-merged.
15
+ *
16
+ * @module @sparkelf/dsh-knowledge-base/client
17
+ */
18
+ import type { DropEventLike, DropPlan, DropTargetLike } from './useFileDrop.ts';
19
+ /** What a component gets back from {@link useFileDrop}. */
20
+ export interface FileDropHandle {
21
+ /** The path of the row that should paint its drop highlight, or undefined. */
22
+ highlighted: string | undefined;
23
+ /**
24
+ * Call from a row's `onDragOver`.
25
+ *
26
+ * Returns whether the row accepts this drag, which the caller uses to decide
27
+ * `event.preventDefault()`: without it the browser refuses the drop outright.
28
+ * @param target - the row under the pointer.
29
+ * @returns Whether the row would accept the drop.
30
+ */
31
+ onDragOver: (target: DropTargetLike | undefined) => boolean;
32
+ /**
33
+ * Call from a row's `onDragEnter`.
34
+ *
35
+ * @param target - the row the pointer entered.
36
+ * @returns Whether the row would accept the drop.
37
+ */
38
+ onDragEnter: (target: DropTargetLike | undefined) => boolean;
39
+ /**
40
+ * Call from a row's `onDragLeave`, and from the surface's own `onDragLeave` for the
41
+ * case where the pointer left the panel entirely.
42
+ * @param target - the row the pointer left, if any.
43
+ */
44
+ onDragLeave: (target?: DropTargetLike | undefined) => void;
45
+ /**
46
+ * Call from a row's `onDrop`. Clears the highlight and returns what to upload.
47
+ * @param target - the row the pointer was over.
48
+ * @param event - the drop payload.
49
+ * @returns The upload to perform, or why nothing happens.
50
+ */
51
+ onDrop: (target: DropTargetLike | undefined, event: DropEventLike) => DropPlan;
52
+ /** Clear the highlight without a drop (Escape, or the drag leaving the window). */
53
+ reset: () => void;
54
+ }
55
+ /**
56
+ * Track the drag state for one drop surface.
57
+ *
58
+ * Note the enter/leave counter. A drag crossing from a row onto its own child fires
59
+ * `dragleave` on the parent BEFORE `dragenter` on the child, so clearing the highlight
60
+ * on every `dragleave` makes it flicker off and back on. Counting enter/leave pairs is
61
+ * what keeps it stable; the counter reaching zero is the real exit.
62
+ * @returns The handle a component wires into its rows.
63
+ */
64
+ export declare function useFileDrop(): FileDropHandle;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Background extraction of a document's Markdown sibling.
3
+ *
4
+ * Upload and extraction are two different promises. The bytes are the uploader's
5
+ * deliverable and must be visible the moment the write lands; the `.md` sibling is
6
+ * derived text that may take a minute (a scanned PDF goes through MinerU, a legacy
7
+ * `.doc` through LibreOffice). Doing both inside the upload request made a slow
8
+ * parse look like a slow upload -- and a failed parse look like a failed upload.
9
+ *
10
+ * This queue separates them. `enqueue` never blocks and never throws: a failed
11
+ * extraction is a status, not an upload error. The queue lives in this process only
12
+ * (see {@link MAX_FINISHED_TASKS}) because the file itself is already durable; a
13
+ * restart loses the bookkeeping, and the manual `extract.retry` route is the recovery
14
+ * path. Persisting a queue would add a second source of truth about files that the
15
+ * filesystem already answers for.
16
+ *
17
+ * @module @sparkelf/dsh-knowledge-base/extractionQueue
18
+ */
19
+ /** Lifecycle of one extraction task. */
20
+ export type ExtractionState = 'pending' | 'running' | 'succeeded' | 'failed' | 'unsupported';
21
+ /** One extraction task, as a reader observes it. */
22
+ export interface ExtractionTask {
23
+ /** Knowledge-relative path of the SOURCE document. */
24
+ relPath: string;
25
+ /** Where the task is in its lifecycle. */
26
+ state: ExtractionState;
27
+ /** When the task was accepted, ISO-8601. */
28
+ queuedAt: string;
29
+ /** When the run started, ISO-8601. */
30
+ startedAt?: string;
31
+ /** When the run reached a terminal state, ISO-8601. */
32
+ finishedAt?: string;
33
+ /** The Markdown sibling that was written, when one was. */
34
+ markdown?: string;
35
+ /** Which authority produced the text (`mineru`, `libreoffice`, `python`, `native`). */
36
+ via?: string;
37
+ /** Why the extraction produced nothing, verbatim from the extractor. */
38
+ reason?: string;
39
+ }
40
+ /** What one run reports back to the queue. */
41
+ export interface ExtractionOutcome {
42
+ /** Terminal state for this run. */
43
+ state: Exclude<ExtractionState, 'pending' | 'running'>;
44
+ /** The written sibling, when the run produced one. */
45
+ markdown?: string;
46
+ /** The authority that produced the text. */
47
+ via?: string;
48
+ /** Why nothing was produced. */
49
+ reason?: string;
50
+ }
51
+ /**
52
+ * How many extractions may run at once.
53
+ *
54
+ * One, deliberately. Every authority this drives is expensive in a way a second
55
+ * concurrent run makes worse: LibreOffice spawns a headless process per conversion and
56
+ * MinerU parses on the same small box the workspace already shares. Serializing also
57
+ * makes the observable order the submission order, which is what a reader retrying one
58
+ * file expects.
59
+ */
60
+ export declare const MAX_CONCURRENT_EXTRACTIONS = 1;
61
+ /**
62
+ * Most finished tasks remembered for status reads.
63
+ *
64
+ * The map is bookkeeping, not storage: an evicted entry loses only its status line,
65
+ * never a file. Bounded so a long-lived process does not accumulate one record per
66
+ * upload it ever saw. Running and pending tasks are never evicted.
67
+ */
68
+ export declare const MAX_FINISHED_TASKS = 200;
69
+ /** What the queue needs from the surrounding plugin, injected so it stays testable. */
70
+ export interface ExtractionQueueOptions {
71
+ /**
72
+ * Run one extraction.
73
+ *
74
+ * Supplied by the plugin so this module owns scheduling only, not the extraction
75
+ * itself: the caller keeps the permission-checked `extractBeside` path in one place.
76
+ * @param relPath - the source document.
77
+ * @param mineruBaseUrl - the caller's own parser address, when set.
78
+ * @returns the terminal state of the run.
79
+ */
80
+ run: (relPath: string, mineruBaseUrl: string | undefined) => Promise<ExtractionOutcome>;
81
+ /**
82
+ * Whether this path can be extracted at all.
83
+ *
84
+ * Asked before queueing so an unsupported file answers immediately rather than
85
+ * occupying the single worker slot to discover it has no reader.
86
+ */
87
+ isExtractable: (relPath: string) => boolean;
88
+ }
89
+ /** One extraction queue. One instance per plugin registration. */
90
+ export interface ExtractionQueue {
91
+ /**
92
+ * Accept a source document for background extraction.
93
+ *
94
+ * Never throws and never waits: the upload that calls this has already written its
95
+ * bytes, and nothing about a parse may change that answer. A repeat for the same
96
+ * path while one is still pending or running returns the existing task instead of
97
+ * queueing a second run.
98
+ * @param relPath - the source document, relative to the knowledge root.
99
+ * @param mineruBaseUrl - the caller's own parser address, when set.
100
+ * @returns the task as it stands right after the call.
101
+ */
102
+ enqueue: (relPath: string, mineruBaseUrl?: string) => ExtractionTask;
103
+ /**
104
+ * Read one task.
105
+ * @param relPath - the source document.
106
+ * @returns the task, or undefined when this process never saw it.
107
+ */
108
+ get: (relPath: string) => ExtractionTask | undefined;
109
+ /**
110
+ * Snapshot the queue for a status surface.
111
+ * @returns every remembered task plus what is running.
112
+ */
113
+ snapshot: () => {
114
+ running: string | undefined;
115
+ size: number;
116
+ tasks: ExtractionTask[];
117
+ };
118
+ /**
119
+ * Wait for one path's task to reach a terminal state.
120
+ *
121
+ * The manual-retry route waits on its own document, and it must wait on the TASK
122
+ * rather than on `drain()`. Two reasons, both about there being one truth:
123
+ *
124
+ * 1. `drain()` also waits for unrelated uploads queued ahead, so a retry would
125
+ * report a delay that has nothing to do with this file.
126
+ * 2. The retry must NOT run its own extraction and then enqueue a second copy to
127
+ * keep the status surface current. That is two runs of one document, and an
128
+ * answer built from one run while `extract.status` reports the other -- the
129
+ * exact split this method exists to remove.
130
+ *
131
+ * Resolves with the terminal task, or immediately with `undefined` when this
132
+ * process never queued that path (a restart, or a path it has not seen). Never
133
+ * rejects: the caller is a route, and the queue is not allowed to become an error
134
+ * path for an upload.
135
+ * @param relPath - the source document.
136
+ * @returns the terminal task, or undefined when the path was never queued.
137
+ */
138
+ settled: (relPath: string) => Promise<ExtractionTask | undefined>;
139
+ /**
140
+ * Wait for the queue to go idle.
141
+ *
142
+ * For tests and for a caller that asked to wait on one specific run; a reader never
143
+ * needs it. Resolves when nothing is pending or running.
144
+ * @returns when the worker has drained.
145
+ */
146
+ drain: () => Promise<void>;
147
+ }
148
+ /**
149
+ * Create an extraction queue.
150
+ *
151
+ * @param options - the runner and the predicate that decides whether a path has a reader.
152
+ * @returns a queue that runs at most {@link MAX_CONCURRENT_EXTRACTIONS} task at a time.
153
+ */
154
+ export declare function createExtractionQueue(options: ExtractionQueueOptions): ExtractionQueue;
@@ -24,6 +24,7 @@ import { type KnowledgeConfig, type KnowledgeGrant, type KnowledgeGrantRequest,
24
24
  import { type KnowledgeStorageSettings, type ViewDelta } from './views.ts';
25
25
  import { type DeployPlanContext, type MineruBackend, type MineruTier } from './deploy.ts';
26
26
  import { type RemoteHost, type RemoteRunner } from './remote.ts';
27
+ import { type ExtractionOutcome } from './extractionQueue.ts';
27
28
  /** Cordis plugin name. */
28
29
  export declare const name = "dsh-knowledge-base";
29
30
  /**
@@ -363,6 +364,21 @@ export interface KnowledgeEntry {
363
364
  readable?: boolean;
364
365
  /** Number of direct children, for a directory row. */
365
366
  childCount?: number;
367
+ /**
368
+ * Whether a reader exists for this file's format.
369
+ *
370
+ * Set on file rows only. The page uses it to decide whether "extract markdown" is a
371
+ * meaningful action for this row, so the menu does not have to know the extractor's
372
+ * extension list.
373
+ */
374
+ canExtract?: boolean;
375
+ /**
376
+ * Whether this file's `.md` sibling already exists.
377
+ *
378
+ * Set on file rows only, and only for formats that have an extractor. Lets the page
379
+ * say "already extracted" rather than offering a retry that would be refused.
380
+ */
381
+ hasMarkdown?: boolean;
366
382
  }
367
383
  /**
368
384
  * List one directory level with each child's modification time.
@@ -445,6 +461,18 @@ export interface PrincipalOption {
445
461
  id: string;
446
462
  /** Human-facing name, falling back to the id when the backend sends none. */
447
463
  label: string;
464
+ /** Login name, for users only: two accounts can share a display name. */
465
+ username?: string;
466
+ /**
467
+ * Role codes this principal belongs to.
468
+ *
469
+ * Users only, and only when the directory reported it. The permission overview needs
470
+ * this to answer "what does this PERSON have": a role grant confers access on every
471
+ * member, so without the membership a reader whose access is entirely role-derived
472
+ * would look like they hold nothing. Absent means the directory did not say -- which
473
+ * the page reports as unavailable inheritance rather than as no inheritance.
474
+ */
475
+ roleCodes?: string[];
448
476
  }
449
477
  /**
450
478
  * Read the principals of one kind from the backend.
@@ -666,6 +694,31 @@ export declare function extractBeside(config: Config, relPath: string, mineruUrl
666
694
  via?: string;
667
695
  reason?: string;
668
696
  } | undefined>;
697
+ /**
698
+ * Translate {@link extractBeside}'s answer into a queue terminal state.
699
+ *
700
+ * The extractor reports three different outcomes through two fields, and they mean
701
+ * different things to a reader deciding what to do next:
702
+ *
703
+ * - `markdown` set, no `reason` — the sibling was written (`succeeded`).
704
+ * - `markdown` set WITH a `reason` — the text was produced but writing the sibling was
705
+ * refused, typically because it already exists. The reason is kept so a retry can
706
+ * report why nothing changed (`failed`, with the refusal verbatim).
707
+ * - `markdown` empty, `reason` set — no authority could read the document; this is the
708
+ * `no-extractor-succeeded: …` case a reader retries after an upgrade.
709
+ *
710
+ * An `undefined` answer means the format has no extractor at all, which the queue
711
+ * normally prevents by asking `isExtractable` first; it is mapped for the caller that
712
+ * bypassed that gate rather than left to throw.
713
+ *
714
+ * @param outcome - what {@link extractBeside} returned.
715
+ * @returns the state the queue records.
716
+ */
717
+ export declare function extractionOutcomeOf(outcome: {
718
+ markdown: string;
719
+ via?: string;
720
+ reason?: string;
721
+ } | undefined): ExtractionOutcome;
669
722
  /**
670
723
  * MinerU's base URL for this deployment, or undefined when it is not configured.
671
724
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-knowledge-base",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DataOps-backed knowledge base for DeepSeek Harness: a left-sidebar destination, Settings configuration, and index tooling over a mounted NAS view.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",