dsh-plugin-office-markdown 1.2.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/LICENSE +21 -0
- package/README.en.md +156 -0
- package/README.md +144 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +589 -0
- package/lib/convert.js +796 -0
- package/lib/env.js +602 -0
- package/lib/fallback-node.js +334 -0
- package/lib/fallback.py +579 -0
- package/lib/index.js +1443 -0
- package/lib/paths.js +76 -0
- package/lib/removal-watchdog.js +359 -0
- package/lib/settings-api.js +381 -0
- package/package.json +55 -0
package/lib/env.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* dsh-plugin-office-markdown — Python environment manager (host half)
|
|
3
|
+
*
|
|
4
|
+
* Owns the "check / install / uninstall" lifecycle for MarkItDown, so the
|
|
5
|
+
* plugin never installs anything on its own:
|
|
6
|
+
*
|
|
7
|
+
* - `inspectEnvironments()` reports every Python candidate the plugin can
|
|
8
|
+
* see, whether it has pip, and whether it already carries markitdown.
|
|
9
|
+
* This is what the settings page renders.
|
|
10
|
+
* - `installMarkitdown()` runs `pip install "markitdown[all]"` into ONE
|
|
11
|
+
* chosen interpreter and writes a snapshot of the package set before and
|
|
12
|
+
* after, so the restore step touches only what this plugin added.
|
|
13
|
+
* - `uninstallMarkitdown()` uninstalls exactly the packages named in that
|
|
14
|
+
* snapshot and then deletes it. A machine where the user installed
|
|
15
|
+
* markitdown themselves has no snapshot, so nothing is ever removed.
|
|
16
|
+
*
|
|
17
|
+
* The snapshot lives at `~/.dsh/dsh-plugin-office-markdown.env.json` — a fixed
|
|
18
|
+
* location both this module and `install.ps1` agree on, independent of which
|
|
19
|
+
* profile is in use.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
import { pythonCandidates, pythonSourceLabel, run } from './convert.js'
|
|
25
|
+
import { dshHome } from './paths.js'
|
|
26
|
+
|
|
27
|
+
export const SNAPSHOT_BASENAME = 'dsh-plugin-office-markdown.env.json'
|
|
28
|
+
export const MARKITDOWN_REQUIREMENT = 'markitdown[all]'
|
|
29
|
+
|
|
30
|
+
/* `pip freeze` / `pip show` on a cold interpreter can genuinely take a while. */
|
|
31
|
+
const PROBE_TIMEOUT = 60000
|
|
32
|
+
const INSTALL_TIMEOUT = 900000
|
|
33
|
+
|
|
34
|
+
/* A bare `python --version` must not: if it has not answered in five seconds,
|
|
35
|
+
* something is very wrong (a broken shim, an antivirus scan, a Windows Store
|
|
36
|
+
* stub waiting on input) and waiting longer only freezes the settings page.
|
|
37
|
+
* Each candidate additionally gets a total budget, because five hangs of five
|
|
38
|
+
* seconds add up to twenty-five. */
|
|
39
|
+
const PROBE_SPAWN_TIMEOUT = 5000
|
|
40
|
+
const PROBE_CANDIDATE_BUDGET = 9000
|
|
41
|
+
const DEFAULT_PROBE_TTL_MS = 600000
|
|
42
|
+
|
|
43
|
+
/** Resolve `promise`, or give up after `ms` and use `onTimeout()`. */
|
|
44
|
+
function withBudget(promise, ms, onTimeout) {
|
|
45
|
+
let timer = null
|
|
46
|
+
const guard = new Promise((resolve) => {
|
|
47
|
+
timer = setTimeout(() => resolve(onTimeout()), ms)
|
|
48
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
49
|
+
})
|
|
50
|
+
return Promise.race([promise, guard]).finally(() => {
|
|
51
|
+
if (timer) clearTimeout(timer)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function firstLine(text) {
|
|
56
|
+
const s = String(text || '').replace(/\r/g, '')
|
|
57
|
+
const line = s.split('\n').find((l) => l.trim())
|
|
58
|
+
return line ? line.trim().slice(0, 300) : ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tailLines(text, n) {
|
|
62
|
+
const lines = String(text || '').replace(/\r/g, '').split('\n').filter((l) => l.trim())
|
|
63
|
+
return lines.slice(-n).join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Absolute path of the env snapshot shared with `install.ps1`. */
|
|
67
|
+
export function snapshotPath() {
|
|
68
|
+
return path.join(dshHome(), SNAPSHOT_BASENAME)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readSnapshot() {
|
|
72
|
+
try {
|
|
73
|
+
const p = snapshotPath()
|
|
74
|
+
if (!fs.existsSync(p)) return null
|
|
75
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
76
|
+
return obj && typeof obj === 'object' ? obj : null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeSnapshot(obj) {
|
|
83
|
+
const p = snapshotPath()
|
|
84
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
85
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8')
|
|
86
|
+
return p
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeSnapshot() {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(snapshotPath(), { force: true })
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function snapshotSummary() {
|
|
99
|
+
const snap = readSnapshot()
|
|
100
|
+
if (!snap) return { present: false }
|
|
101
|
+
return {
|
|
102
|
+
present: true,
|
|
103
|
+
adopted: snap.adopted === true,
|
|
104
|
+
path: snapshotPath(),
|
|
105
|
+
python: snap.python || snap.pythonCommand || '',
|
|
106
|
+
pythonSourceLabel: pythonSourceLabel(snap.pythonSource),
|
|
107
|
+
installedAt: snap.installedAt || '',
|
|
108
|
+
added: Array.isArray(snap.added) ? snap.added : [],
|
|
109
|
+
keep: Array.isArray(snap.keep) ? snap.keep : []
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Display form of a candidate, e.g. `py -3` — also its identity in the API. */
|
|
114
|
+
export function commandOf(cand) {
|
|
115
|
+
const prefix = cand && Array.isArray(cand.prefix) ? cand.prefix : []
|
|
116
|
+
return [cand.cmd, ...prefix].join(' ')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function pipFreeze(cand, options = {}) {
|
|
120
|
+
const r = await run(cand.cmd, [...(cand.prefix || []), '-m', 'pip', 'freeze'], {
|
|
121
|
+
timeoutMs: options.timeoutMs || PROBE_TIMEOUT,
|
|
122
|
+
signal: options.signal
|
|
123
|
+
})
|
|
124
|
+
if (!r.ok) return null
|
|
125
|
+
const names = []
|
|
126
|
+
for (const line of String(r.stdout || '').replace(/\r/g, '').split('\n')) {
|
|
127
|
+
const t = line.trim()
|
|
128
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue
|
|
129
|
+
const at = t.indexOf('==')
|
|
130
|
+
names.push(at > 0 ? t.slice(0, at) : t)
|
|
131
|
+
}
|
|
132
|
+
return names
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Inspect ONE Python candidate: does it run, does it have pip, does it already
|
|
137
|
+
* carry markitdown. Never throws — a broken candidate comes back with `error`.
|
|
138
|
+
*/
|
|
139
|
+
async function inspectCandidateSteps(cand, options = {}) {
|
|
140
|
+
const out = {
|
|
141
|
+
command: commandOf(cand),
|
|
142
|
+
source: cand.source,
|
|
143
|
+
sourceLabel: pythonSourceLabel(cand.source),
|
|
144
|
+
usable: false,
|
|
145
|
+
hasPip: false,
|
|
146
|
+
hasMarkitdown: false,
|
|
147
|
+
markitdownVersion: '',
|
|
148
|
+
pythonVersion: '',
|
|
149
|
+
executable: '',
|
|
150
|
+
pending: false,
|
|
151
|
+
error: ''
|
|
152
|
+
}
|
|
153
|
+
const prefix = cand.prefix || []
|
|
154
|
+
const timeoutMs = options.timeoutMs || PROBE_SPAWN_TIMEOUT
|
|
155
|
+
|
|
156
|
+
const ver = await run(cand.cmd, [...prefix, '--version'], { timeoutMs, signal: options.signal })
|
|
157
|
+
if (!ver.ok) {
|
|
158
|
+
out.error = firstLine(ver.stderr) || firstLine(ver.stdout) || '无法执行'
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
out.usable = true
|
|
162
|
+
out.pythonVersion = firstLine(ver.stdout) || firstLine(ver.stderr)
|
|
163
|
+
|
|
164
|
+
const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], { timeoutMs, signal: options.signal })
|
|
165
|
+
if (exe.ok) out.executable = firstLine(exe.stdout)
|
|
166
|
+
|
|
167
|
+
const pip = await run(cand.cmd, [...prefix, '-m', 'pip', '--version'], { timeoutMs, signal: options.signal })
|
|
168
|
+
out.hasPip = !!pip.ok
|
|
169
|
+
if (!out.hasPip) {
|
|
170
|
+
out.error = firstLine(pip.stderr) || '没有 pip'
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const mkd = await run(cand.cmd, [...prefix, '-m', 'markitdown', '--help'], { timeoutMs, signal: options.signal })
|
|
175
|
+
out.hasMarkitdown = !!mkd.ok
|
|
176
|
+
if (out.hasMarkitdown) {
|
|
177
|
+
const show = await run(cand.cmd, [...prefix, '-m', 'pip', 'show', 'markitdown'], { timeoutMs, signal: options.signal })
|
|
178
|
+
const m = String(show.stdout || '').match(/^Version:\s*(\S+)/m)
|
|
179
|
+
out.markitdownVersion = m ? m[1] : ''
|
|
180
|
+
} else {
|
|
181
|
+
out.error = firstLine(mkd.stderr) || '未安装 markitdown'
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A candidate that never answered, described well enough for the page. */
|
|
187
|
+
function pendingCandidate(cand, note) {
|
|
188
|
+
return {
|
|
189
|
+
command: commandOf(cand),
|
|
190
|
+
source: cand.source,
|
|
191
|
+
sourceLabel: pythonSourceLabel(cand.source),
|
|
192
|
+
usable: false,
|
|
193
|
+
hasPip: false,
|
|
194
|
+
hasMarkitdown: false,
|
|
195
|
+
markitdownVersion: '',
|
|
196
|
+
pythonVersion: '',
|
|
197
|
+
executable: '',
|
|
198
|
+
pending: true,
|
|
199
|
+
error: note
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Inspect ONE Python candidate: does it run, does it have pip, does it already
|
|
205
|
+
* carry markitdown. Never throws and never hangs: a broken candidate comes back
|
|
206
|
+
* with `error`, a stuck one with `pending` and `error`.
|
|
207
|
+
*/
|
|
208
|
+
export async function inspectCandidate(cand, options = {}) {
|
|
209
|
+
const budget = Number(options.budgetMs) || PROBE_CANDIDATE_BUDGET
|
|
210
|
+
const steps = inspectCandidateSteps(cand, options)
|
|
211
|
+
/* Keep the steps alive so an abandoned probe cannot surface as an unhandled
|
|
212
|
+
* rejection once the budget has already answered. */
|
|
213
|
+
steps.catch(() => {})
|
|
214
|
+
return withBudget(steps, budget, () => pendingCandidate(cand, '探测超时(' + Math.round(budget / 1000) + 's 未响应),可点「重新检查」再试。'))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Key that identifies "the same set of candidates in the same order". */
|
|
218
|
+
function probeCacheKey(cfg) {
|
|
219
|
+
const commands = pythonCandidates(cfg).map((cand) => commandOf(cand))
|
|
220
|
+
return String((cfg && cfg.pythonPrefer) || 'auto') + '|' + commands.join(',')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let probeCache = null
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Drop the cached probe result, so the next inspection really re-runs.
|
|
227
|
+
* Used when the interpreter list itself changed (install / uninstall).
|
|
228
|
+
*/
|
|
229
|
+
export function invalidateProbeCache() {
|
|
230
|
+
probeCache = null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Inspect every candidate.
|
|
235
|
+
*
|
|
236
|
+
* Candidates are probed in parallel — serially, one stuck interpreter used to
|
|
237
|
+
* hold the whole settings page hostage — while each candidate's own five
|
|
238
|
+
* process spawns stay sequential, because they depend on each other. The result
|
|
239
|
+
* is cached for `cfg.probeTtlMs`, so opening the settings page twice does not
|
|
240
|
+
* spawn twenty processes twice.
|
|
241
|
+
*/
|
|
242
|
+
export async function inspectEnvironments(cfg, options = {}) {
|
|
243
|
+
const ttl = Math.max(0, Number((cfg && cfg.probeTtlMs) || DEFAULT_PROBE_TTL_MS))
|
|
244
|
+
const key = probeCacheKey(cfg)
|
|
245
|
+
if (!options.force && ttl > 0 && probeCache && probeCache.key === key && Date.now() - probeCache.at < ttl) {
|
|
246
|
+
return { ...probeCache.value, cached: true, budgetMs: 0 }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const candidates = pythonCandidates(cfg)
|
|
250
|
+
const settled = await Promise.allSettled(
|
|
251
|
+
candidates.map((cand) => inspectCandidate(cand, options))
|
|
252
|
+
)
|
|
253
|
+
const list = settled.map((entry, i) => {
|
|
254
|
+
if (entry.status === 'fulfilled' && entry.value) return entry.value
|
|
255
|
+
const reason = entry.status === 'rejected' ? entry.reason : null
|
|
256
|
+
return pendingCandidate(
|
|
257
|
+
candidates[i],
|
|
258
|
+
'探测失败:' + (firstLine(String((reason && reason.message) || reason)) || '未知原因')
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const active = list.find((e) => e.hasMarkitdown) || null
|
|
263
|
+
const installable = list.find((e) => e.usable && e.hasPip) || null
|
|
264
|
+
const value = {
|
|
265
|
+
pythonPrefer: String((cfg && cfg.pythonPrefer) || 'auto'),
|
|
266
|
+
candidates: list,
|
|
267
|
+
active: active ? active.command : '',
|
|
268
|
+
installTarget: installable ? installable.command : '',
|
|
269
|
+
snapshot: snapshotSummary()
|
|
270
|
+
}
|
|
271
|
+
probeCache = { key, at: Date.now(), value }
|
|
272
|
+
return { ...value, cached: false, budgetMs: PROBE_CANDIDATE_BUDGET }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The interpreter a fresh install would go into (first usable one with pip). */
|
|
276
|
+
export function pickInstallTarget(cfg) {
|
|
277
|
+
for (const cand of pythonCandidates(cfg)) {
|
|
278
|
+
const command = commandOf(cand)
|
|
279
|
+
if (command) return cand
|
|
280
|
+
}
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ------------------------------------------------------------------ */
|
|
285
|
+
/* recording an environment that was already there */
|
|
286
|
+
/* ------------------------------------------------------------------ */
|
|
287
|
+
|
|
288
|
+
/** PEP 503 normalised name: lowercase, runs of `-_.` collapse into one `-`. */
|
|
289
|
+
export function normName(name) {
|
|
290
|
+
return String(name || '').trim().toLowerCase().replace(/[-_.]+/g, '-')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Packages that belong to the interpreter rather than to us.
|
|
295
|
+
*
|
|
296
|
+
* A DSH bundled runtime ships a `runtime.json` that lists exactly what the
|
|
297
|
+
* runtime is expected to contain (numpy, pandas, python-docx, Pillow, lxml …).
|
|
298
|
+
* Those are shared with other DSH features, so they must never end up in the
|
|
299
|
+
* removal list even when markitdown happens to depend on them.
|
|
300
|
+
*/
|
|
301
|
+
export function protectedPackages(cand, executable) {
|
|
302
|
+
const names = new Set(['pip', 'setuptools', 'wheel'])
|
|
303
|
+
const abs = String(executable || (cand && cand.cmd) || '')
|
|
304
|
+
if (abs) {
|
|
305
|
+
let dir = path.dirname(abs)
|
|
306
|
+
for (let i = 0; i < 4 && dir && dir !== path.dirname(dir); i += 1) {
|
|
307
|
+
const manifest = path.join(dir, 'runtime.json')
|
|
308
|
+
try {
|
|
309
|
+
if (fs.existsSync(manifest)) {
|
|
310
|
+
const obj = JSON.parse(fs.readFileSync(manifest, 'utf8').replace(/^\uFEFF/, ''))
|
|
311
|
+
const pkgs = obj && obj.pythonPackages
|
|
312
|
+
if (pkgs && typeof pkgs === 'object') {
|
|
313
|
+
for (const key of Object.keys(pkgs)) names.add(normName(key))
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch { /* unreadable manifest — keep walking up */ }
|
|
317
|
+
dir = path.dirname(dir)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return [...names]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Python program that answers: "if markitdown were removed, which installed
|
|
325
|
+
* distributions would nobody else still need?" It walks the dependency closure
|
|
326
|
+
* of markitdown, then keeps back anything the runtime manifest protects or
|
|
327
|
+
* anything an installed package outside the closure still requires.
|
|
328
|
+
*/
|
|
329
|
+
const REMOVABLE_SCRIPT = String.raw`
|
|
330
|
+
import json, re, sys
|
|
331
|
+
from importlib import metadata as md
|
|
332
|
+
|
|
333
|
+
payload = json.loads(sys.argv[1])
|
|
334
|
+
|
|
335
|
+
def norm(n):
|
|
336
|
+
return re.sub(r"[-_.]+", "-", str(n)).lower()
|
|
337
|
+
|
|
338
|
+
def rname(s):
|
|
339
|
+
m = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", str(s))
|
|
340
|
+
return norm(m.group(1)) if m else ""
|
|
341
|
+
|
|
342
|
+
roots = [norm(r) for r in payload.get("roots", ["markitdown"])]
|
|
343
|
+
protected = set(norm(p) for p in payload.get("protected", []))
|
|
344
|
+
|
|
345
|
+
dists = {}
|
|
346
|
+
for d in md.distributions():
|
|
347
|
+
try:
|
|
348
|
+
nm = d.metadata["Name"]
|
|
349
|
+
except Exception:
|
|
350
|
+
nm = None
|
|
351
|
+
if nm and norm(nm) not in dists:
|
|
352
|
+
dists[norm(nm)] = d
|
|
353
|
+
|
|
354
|
+
requires = {}
|
|
355
|
+
for n, d in dists.items():
|
|
356
|
+
kids = []
|
|
357
|
+
try:
|
|
358
|
+
raw = list(d.requires or [])
|
|
359
|
+
except Exception:
|
|
360
|
+
raw = []
|
|
361
|
+
for r in raw:
|
|
362
|
+
k = rname(r)
|
|
363
|
+
if k and k not in kids:
|
|
364
|
+
kids.append(k)
|
|
365
|
+
requires[n] = kids
|
|
366
|
+
|
|
367
|
+
required_by = {}
|
|
368
|
+
for n, kids in requires.items():
|
|
369
|
+
for k in kids:
|
|
370
|
+
required_by.setdefault(k, set()).add(n)
|
|
371
|
+
|
|
372
|
+
closure = set()
|
|
373
|
+
stack = [r for r in roots if r in dists]
|
|
374
|
+
while stack:
|
|
375
|
+
n = stack.pop()
|
|
376
|
+
if n in closure:
|
|
377
|
+
continue
|
|
378
|
+
closure.add(n)
|
|
379
|
+
for k in requires.get(n, []):
|
|
380
|
+
if k not in closure and k in dists:
|
|
381
|
+
stack.append(k)
|
|
382
|
+
|
|
383
|
+
removable, keep = [], []
|
|
384
|
+
for n in sorted(closure):
|
|
385
|
+
if n in protected:
|
|
386
|
+
keep.append({"name": n, "reason": "\u8fd0\u884c\u65f6\u81ea\u5e26\uff0c\u4e0d\u6e05\u7406"})
|
|
387
|
+
continue
|
|
388
|
+
outside = sorted(x for x in required_by.get(n, set()) if x not in closure)
|
|
389
|
+
if outside:
|
|
390
|
+
keep.append({"name": n, "reason": "\u88ab\u5176\u5b83\u7ec4\u4ef6\u4f9d\u8d56\uff1a" + ", ".join(outside[:4])})
|
|
391
|
+
else:
|
|
392
|
+
removable.append(n)
|
|
393
|
+
|
|
394
|
+
print(json.dumps({
|
|
395
|
+
"ok": True,
|
|
396
|
+
"roots": roots,
|
|
397
|
+
"missingRoots": [r for r in roots if r not in dists],
|
|
398
|
+
"closure": sorted(closure),
|
|
399
|
+
"removable": removable,
|
|
400
|
+
"keep": keep,
|
|
401
|
+
}))
|
|
402
|
+
`
|
|
403
|
+
|
|
404
|
+
/** Run {@link REMOVABLE_SCRIPT} against one interpreter. */
|
|
405
|
+
async function computeRemovableSet(cand, options = {}) {
|
|
406
|
+
const prefix = cand.prefix || []
|
|
407
|
+
const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], {
|
|
408
|
+
timeoutMs: PROBE_TIMEOUT,
|
|
409
|
+
signal: options.signal
|
|
410
|
+
})
|
|
411
|
+
const executable = firstLine(exe.stdout) || ''
|
|
412
|
+
|
|
413
|
+
const payload = JSON.stringify({
|
|
414
|
+
roots: ['markitdown'],
|
|
415
|
+
protected: protectedPackages(cand, executable)
|
|
416
|
+
})
|
|
417
|
+
const r = await run(cand.cmd, [...prefix, '-c', REMOVABLE_SCRIPT, payload], {
|
|
418
|
+
timeoutMs: options.timeoutMs || PROBE_TIMEOUT * 3,
|
|
419
|
+
signal: options.signal
|
|
420
|
+
})
|
|
421
|
+
if (!r.ok) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
executable,
|
|
425
|
+
error: firstLine(r.stderr) || '无法计算依赖关系(退出码 ' + r.code + ')'
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const line = String(r.stdout || '').trim().split('\n').pop()
|
|
430
|
+
const data = JSON.parse(line)
|
|
431
|
+
return { ok: true, executable, data }
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
executable,
|
|
436
|
+
error: '无法解析依赖计算结果:' + firstLine(String((error && error.message) || error))
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Record an interpreter that already carries markitdown but has no snapshot, so
|
|
443
|
+
* that removing the plugin can also take those packages back out.
|
|
444
|
+
*
|
|
445
|
+
* This is what the settings page does when the user presses "one-click setup"
|
|
446
|
+
* on an interpreter that already has markitdown: nothing is installed, only a
|
|
447
|
+
* removal manifest is written. `adopted: true` marks it as such.
|
|
448
|
+
*
|
|
449
|
+
* @returns {Promise<{ok: boolean, adopted?: boolean, target: string, added?: string[], keep?: Array<object>, error?: string}>}
|
|
450
|
+
*/
|
|
451
|
+
export async function adoptMarkitdown(cand, options = {}) {
|
|
452
|
+
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
|
|
453
|
+
const calc = await computeRemovableSet(cand, options)
|
|
454
|
+
if (!calc.ok) return { ok: false, target: commandOf(cand), error: calc.error }
|
|
455
|
+
|
|
456
|
+
const removable = calc.data.removable || []
|
|
457
|
+
const keep = calc.data.keep || []
|
|
458
|
+
if (!removable.length) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
target: commandOf(cand),
|
|
462
|
+
error: '没能算出可以安全卸载的包(可能 markitdown 的依赖全部由运行时提供),未写入环境记录。'
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const snapPath = writeSnapshot({
|
|
467
|
+
version: 1,
|
|
468
|
+
adopted: true,
|
|
469
|
+
packageName: 'dsh-plugin-office-markdown',
|
|
470
|
+
requirement: MARKITDOWN_REQUIREMENT,
|
|
471
|
+
python: calc.executable || commandOf(cand),
|
|
472
|
+
pythonCommand: commandOf(cand),
|
|
473
|
+
pythonSource: cand.source,
|
|
474
|
+
installedAt: new Date().toISOString(),
|
|
475
|
+
added: removable,
|
|
476
|
+
keep,
|
|
477
|
+
closure: calc.data.closure || [],
|
|
478
|
+
before: [],
|
|
479
|
+
after: []
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
onLog('已登记现有 MarkItDown 环境:' + removable.length + ' 个包可在卸载插件时一并移除,'
|
|
483
|
+
+ keep.length + ' 个包因被其它组件共用而保留。')
|
|
484
|
+
return {
|
|
485
|
+
ok: true,
|
|
486
|
+
adopted: true,
|
|
487
|
+
target: commandOf(cand),
|
|
488
|
+
executable: calc.executable,
|
|
489
|
+
added: removable,
|
|
490
|
+
keep,
|
|
491
|
+
snapshotPath: snapPath
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Install markitdown into `cand` and snapshot the delta.
|
|
497
|
+
* Returns `{ ok, added, target, output, error }`.
|
|
498
|
+
*/
|
|
499
|
+
export async function installMarkitdown(cfg, cand, options = {}) {
|
|
500
|
+
const prefix = cand.prefix || []
|
|
501
|
+
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
|
|
502
|
+
const timeoutMs = options.timeoutMs || INSTALL_TIMEOUT
|
|
503
|
+
|
|
504
|
+
onLog('检查 ' + commandOf(cand) + ' 的现有包清单…')
|
|
505
|
+
const before = await pipFreeze(cand, { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
|
|
506
|
+
if (before === null) {
|
|
507
|
+
return { ok: false, target: commandOf(cand), added: [], error: '无法执行 pip freeze(该解释器可能没有 pip)' }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
onLog('正在安装 ' + MARKITDOWN_REQUIREMENT + '(约 90 MB,需要联网,可能需要几分钟)…')
|
|
511
|
+
const r = await run(
|
|
512
|
+
cand.cmd,
|
|
513
|
+
[...prefix, '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--progress-bar', 'off', MARKITDOWN_REQUIREMENT],
|
|
514
|
+
{ timeoutMs, signal: options.signal }
|
|
515
|
+
)
|
|
516
|
+
if (!r.ok) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
target: commandOf(cand),
|
|
520
|
+
added: [],
|
|
521
|
+
error: (r.timedOut ? '安装超时。\n' : '') + (tailLines(r.stderr, 8) || tailLines(r.stdout, 8) || 'pip 退出码 ' + r.code)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const after = await pipFreeze(cand, { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
|
|
526
|
+
const beforeSet = new Set(before)
|
|
527
|
+
const added = (after || []).filter((n) => !beforeSet.has(n))
|
|
528
|
+
|
|
529
|
+
const exe = await run(cand.cmd, [...prefix, '-c', 'import sys; print(sys.executable)'], { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
|
|
530
|
+
const executable = firstLine(exe.stdout) || ''
|
|
531
|
+
|
|
532
|
+
const verified = await run(cand.cmd, [...prefix, '-m', 'markitdown', '--help'], { timeoutMs: PROBE_TIMEOUT, signal: options.signal })
|
|
533
|
+
if (!verified.ok) {
|
|
534
|
+
return { ok: false, target: commandOf(cand), added, error: '安装命令成功,但随后无法运行 markitdown:' + firstLine(verified.stderr) }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const snapPath = writeSnapshot({
|
|
538
|
+
version: 1,
|
|
539
|
+
adopted: false,
|
|
540
|
+
packageName: 'dsh-plugin-office-markdown',
|
|
541
|
+
requirement: MARKITDOWN_REQUIREMENT,
|
|
542
|
+
python: executable || commandOf(cand),
|
|
543
|
+
pythonCommand: commandOf(cand),
|
|
544
|
+
pythonSource: cand.source,
|
|
545
|
+
installedAt: new Date().toISOString(),
|
|
546
|
+
added,
|
|
547
|
+
keep: [],
|
|
548
|
+
before,
|
|
549
|
+
after: after || []
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
onLog('已安装 ' + added.length + ' 个新包,环境快照写入 ' + snapPath)
|
|
553
|
+
return { ok: true, target: commandOf(cand), executable, added, snapshotPath: snapPath, output: tailLines(r.stdout, 4) }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Uninstall exactly what the snapshot recorded. No snapshot (or an empty
|
|
558
|
+
* delta) means the user installed markitdown themselves — nothing is removed.
|
|
559
|
+
*/
|
|
560
|
+
export async function uninstallMarkitdown(options = {}) {
|
|
561
|
+
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}
|
|
562
|
+
const snap = readSnapshot()
|
|
563
|
+
if (!snap) {
|
|
564
|
+
return { ok: true, skipped: true, reason: '没有环境快照:markitdown 不是由本插件安装或登记的,不会卸载任何东西。' }
|
|
565
|
+
}
|
|
566
|
+
const adopted = snap.adopted === true
|
|
567
|
+
const added = (Array.isArray(snap.added) ? snap.added : []).filter((n) => typeof n === 'string' && n)
|
|
568
|
+
if (added.length === 0) {
|
|
569
|
+
removeSnapshot()
|
|
570
|
+
return { ok: true, skipped: true, reason: '快照里没有可移除的包,已删除快照。', snapshotPath: snapshotPath() }
|
|
571
|
+
}
|
|
572
|
+
const python = snap.python || snap.pythonCommand
|
|
573
|
+
if (!python) {
|
|
574
|
+
return { ok: false, adopted, error: '快照里没有记录 Python 解释器路径,无法自动卸载。', added }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
onLog((adopted ? '正在移除本插件登记过的 ' : '正在从 ') + python + ' 卸载 ' + added.length + ' 个包…')
|
|
578
|
+
const r = await run(python, ['-m', 'pip', 'uninstall', '-y', '--disable-pip-version-check', ...added], {
|
|
579
|
+
timeoutMs: options.timeoutMs || INSTALL_TIMEOUT,
|
|
580
|
+
signal: options.signal
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
const interesting = String((r.stdout || '') + '\n' + (r.stderr || ''))
|
|
584
|
+
.replace(/\r/g, '')
|
|
585
|
+
.split('\n')
|
|
586
|
+
.filter((l) => /Successfully uninstalled|not installed|^ERROR|Skipping/i.test(l))
|
|
587
|
+
.map((l) => l.trim())
|
|
588
|
+
.slice(0, 40)
|
|
589
|
+
|
|
590
|
+
if (r.ok) {
|
|
591
|
+
removeSnapshot()
|
|
592
|
+
return { ok: true, adopted, removed: added, python, output: interesting, snapshotPath: snapshotPath() }
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
adopted,
|
|
597
|
+
added,
|
|
598
|
+
python,
|
|
599
|
+
output: interesting,
|
|
600
|
+
error: (r.timedOut ? '卸载超时。\n' : '') + (tailLines(r.stderr, 6) || 'pip 退出码 ' + r.code)
|
|
601
|
+
}
|
|
602
|
+
}
|