dsh-linked-workspace 0.5.13 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-11-05
4
+
5
+ - **Document editing in the content panel** for Markdown, PDF, DOCX, XLSX and PPTX, on both local and remote files.
6
+ - Markdown: raw editing, rendered preview, export to PDF.
7
+ - DOCX: paragraph and table text editing (styles are preserved at paragraph level; rich inline formatting may be simplified).
8
+ - XLSX: sheet tabs and editable cell grid; formulas and untouched styles are preserved.
9
+ - PPTX: slide navigation and text-frame editing.
10
+ - PDF: page navigation, rotate/delete page, title/author metadata; page images are rendered by PyMuPDF.
11
+ - New document files can be created from the directory context menu (`.md`, `.pdf`, `.docx`, `.xlsx`, `.pptx`).
12
+ - A small Python bridge (`scripts/doc_tool.py`) implements the format-specific read/write/create operations. Python 3 with `python-docx`, `openpyxl`, `python-pptx` and `PyMuPDF` is required for Office/PDF operations; Markdown works without them.
13
+ - Runs on both local folders and remote linked/directories (remote files are downloaded, processed and uploaded atomically).
14
+
3
15
  ## 0.5.13 — 2026-11-05
4
16
 
5
17
  - The linked-workspace directory panel can now be resized by dragging the divider; double-click the divider to reset to 300 px. The width is persisted.
package/README.en.md CHANGED
@@ -28,6 +28,18 @@ The plugin takes over the directory-selection hole used by the DSH sidebar and e
28
28
  1. **Local workspace**: enter or browse for a local directory (C:/D:/E: drive chips on Windows), set a workspace name, then create a normal DSH workspace.
29
29
  2. **Linked workspace**: SSH host + remote folder + local folder + optional mirror. Creating it also registers the local folder as a DSH workspace. The local directory is **not** pre-populated with `.local`/`.remote`; those are created lazily when mirroring or transfers need them.
30
30
 
31
+ ### Document editing
32
+
33
+ The content panel now edits **Markdown / PDF / DOCX / XLSX / PPTX** files on both local and remote sides:
34
+
35
+ - **Markdown**: raw editing, rendered preview, export to PDF.
36
+ - **DOCX**: edit paragraph and table text; paragraph styles are kept, complex inline formatting may be simplified.
37
+ - **XLSX**: switch sheets and edit a cell grid; untouched formulas and styles are preserved.
38
+ - **PPTX**: navigate slides and edit text frames.
39
+ - **PDF**: page navigation, rotate/delete pages, and title/author metadata; pages are rendered by PyMuPDF.
40
+ - Create blank documents from the directory context menu by using one of these extensions.
41
+ - Office/PDF operations need Python 3 with `python-docx`, `openpyxl`, `python-pptx` and `PyMuPDF`; Markdown does not. Set `DSH_OFFICE_PYTHON` to choose an interpreter.
42
+
31
43
  ### Right-sidebar entries
32
44
 
33
45
  - **Linked workspace directory**: appears only in sessions of a linked workspace, and opens the linked workspace bound to that session. Remote and local files are editable.
@@ -82,7 +94,13 @@ The remote side is not modified by mirroring. The `linked_workspace` tool suppor
82
94
 
83
95
  ## Installation
84
96
 
97
+ ```bash
98
+ dsh plugin --profile web add dsh-linked-workspace
85
99
  ```
100
+
101
+ Install from source (development):
102
+
103
+ ```bash
86
104
  dsh plugin --profile "$DSH_PROFILE" add "file:$PWD/dsh-linked-workspace"
87
105
  ```
88
106
 
package/README.md CHANGED
@@ -29,6 +29,18 @@
29
29
 
30
30
  > 左侧工作区行不再带 `⇄` 标题前缀:插件用一份按 `data-row-key` 定位的样式表,把联动工作区行的宿主文件夹图标替换成**链环图标**(普通本地工作区不受影响)。这是对宿主行结构的唯一 DOM 级适配;如果 DSH 以后更改行结构,选择器会自然失配并回退到原来的文件夹图标,不破坏其他功能。
31
31
 
32
+ ### 文档创建与编辑
33
+
34
+ 文件内容面板现在支持 **Markdown / PDF / DOCX / XLSX / PPTX**(本地与远程文件均可):
35
+
36
+ - **Markdown**:原始文本编辑、渲染预览、导出 PDF。
37
+ - **DOCX**:按段落与表格编辑文字;保留段落样式,复杂行内格式可能被简化。
38
+ - **XLSX**:工作表切换与单元格网格编辑;未修改的公式与样式保留。
39
+ - **PPTX**:幻灯片切换与文本框编辑。
40
+ - **PDF**:翻页、旋转/删除页面、编辑标题/作者元数据;页面由 PyMuPDF 渲染。
41
+ - 在目录右键「新建文件」中输入支持的扩展名即可创建对应空白文档。
42
+ - Office/PDF 操作需要 Python 3 及 `python-docx`、`openpyxl`、`python-pptx`、`PyMuPDF`;Markdown 不依赖 Python。可通过 `DSH_OFFICE_PYTHON` 指定解释器。
43
+
32
44
  ### 右侧栏入口
33
45
 
34
46
  - **联动工作区目录**:只在联动工作区的会话中出现;点开后绑定该会话对应的联动工作区(不会打开另一个联动工作区),可编辑远程与本地文件。
package/docs/API.md CHANGED
@@ -16,6 +16,16 @@ Codes: `BAD_REQUEST`, `NOT_FOUND`, `SSH_CONNECT`, `AUTH_FAILED`,
16
16
  `HOST_KEY_MISMATCH`, `TIMEOUT`, `FS_NOT_FOUND`, `FS_PERMISSION`, `FS_IS_DIR`,
17
17
  `FS_NOT_DIR`, `FS_TOO_LARGE`, `CONFLICT`, `INTERNAL`.
18
18
 
19
+ ## Document APIs
20
+
21
+ | Method | Params | Result |
22
+ | --- | --- | --- |
23
+ | `docs.read` | `{ path, linkedId?, workspaceId? }` | kind-specific document model |
24
+ | `docs.write` | `{ path, data, expectedMtime?, linkedId?, workspaceId? }` | `{ kind, path, size, mtime }` |
25
+ | `docs.create` | `{ path, kind?, linkedId?, workspaceId? }` | `{ kind, path, size, mtime }` |
26
+ | `docs.mdPreview` | `{ path, text?, linkedId?, workspaceId? }` | `{ kind, html }` |
27
+ | `docs.mdPdf` | `{ path, text?, linkedId?, workspaceId? }` | `{ kind: "pdf", path, size, mtime }` |
28
+
19
29
  ## Records
20
30
 
21
31
  ```ts
package/lib/client.js CHANGED
@@ -2058,6 +2058,21 @@ function fileIconSpec(name, isDir) {
2058
2058
  toggleDirectory: '开启/关闭联动工作区目录面板',
2059
2059
  closeFile: '关闭文件标签',
2060
2060
  resizeDirectory: '拖动调整目录面板宽度;双击恢复默认宽度',
2061
+ docPreview: '预览',
2062
+ docEdit: '编辑',
2063
+ exportPdf: '导出 PDF',
2064
+ docPages: '页',
2065
+ docRotate: '旋转 90°',
2066
+ docDeletePage: '删除当前页',
2067
+ docParagraph: '段落',
2068
+ docTable: '表格',
2069
+ docSheet: '工作表',
2070
+ docSlide: '幻灯片',
2071
+ docPdfTitle: '标题',
2072
+ docPdfAuthor: '作者',
2073
+ docPageOf: '第 {page} / {total} 页',
2074
+ docExportDone: '已导出 PDF:{path}',
2075
+ docPreviewFailed: '预览失败',
2061
2076
  remoteTabTitle: '远程目录',
2062
2077
  remoteTabDesc: '只读查看已添加的远程目录及其文件,不允许编辑。',
2063
2078
  remoteDirsTitle: '远程目录',
@@ -2333,6 +2348,21 @@ function fileIconSpec(name, isDir) {
2333
2348
  toggleDirectory: 'Show or hide the linked workspace directory panel',
2334
2349
  closeFile: 'Close file tab',
2335
2350
  resizeDirectory: 'Drag to resize the directory panel; double-click to reset',
2351
+ docPreview: 'Preview',
2352
+ docEdit: 'Edit',
2353
+ exportPdf: 'Export PDF',
2354
+ docPages: 'pages',
2355
+ docRotate: 'Rotate 90°',
2356
+ docDeletePage: 'Delete current page',
2357
+ docParagraph: 'Paragraph',
2358
+ docTable: 'Table',
2359
+ docSheet: 'Sheet',
2360
+ docSlide: 'Slide',
2361
+ docPdfTitle: 'Title',
2362
+ docPdfAuthor: 'Author',
2363
+ docPageOf: 'Page {page} / {total}',
2364
+ docExportDone: 'Exported PDF: {path}',
2365
+ docPreviewFailed: 'Preview failed',
2336
2366
  remoteTabTitle: 'Remote directories',
2337
2367
  remoteTabDesc: 'Read-only view of configured remote directories and files; editing is disabled.',
2338
2368
  remoteDirsTitle: 'Remote directories',
@@ -2589,6 +2619,35 @@ function fileIconSpec(name, isDir) {
2589
2619
  return error.message || String(error);
2590
2620
  }
2591
2621
 
2622
+ function documentKindOf(path) {
2623
+ var ext = String(path || '').toLowerCase().match(/\.[^./\\]+$/);
2624
+ if (!ext) return '';
2625
+ var value = ext[0];
2626
+ if (value === '.md' || value === '.markdown') return 'markdown';
2627
+ if (value === '.pdf') return 'pdf';
2628
+ if (value === '.docx') return 'docx';
2629
+ if (value === '.xlsx') return 'xlsx';
2630
+ if (value === '.pptx') return 'pptx';
2631
+ return '';
2632
+ }
2633
+
2634
+ function sanitizeDocumentHtml(html) {
2635
+ try {
2636
+ var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
2637
+ parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
2638
+ parsed.querySelectorAll('*').forEach(function (element) {
2639
+ Array.from(element.attributes).forEach(function (attribute) {
2640
+ if (/^on/i.test(attribute.name) || /^javascript:/i.test(attribute.value) || /^data:text\/html/i.test(attribute.value)) {
2641
+ element.removeAttribute(attribute.name);
2642
+ }
2643
+ });
2644
+ });
2645
+ return parsed.body.innerHTML;
2646
+ } catch (error) {
2647
+ return '';
2648
+ }
2649
+ }
2650
+
2592
2651
  function basename(path) {
2593
2652
  if (!path) return '';
2594
2653
  var clean = path.replace(/[\\/]+$/, '');
@@ -2938,6 +2997,35 @@ function fileIconSpec(name, isDir) {
2938
2997
  '.rw-content-pane{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0}',
2939
2998
  '.rw-directory-pane{display:flex;flex-direction:column;flex:0 0 auto;max-width:60%;min-width:240px;min-height:0;border-left:1px solid var(--dsw-alias-border-l1)}',
2940
2999
  '.rw-pane-resizer{flex:0 0 6px;align-self:stretch;cursor:col-resize;background:transparent;position:relative;z-index:2;touch-action:none}',
3000
+ '.rw-doc{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;min-width:0;background:var(--dsw-alias-bg-base)}',
3001
+ '.rw-doc-toolbar{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);flex:none;flex-wrap:wrap}',
3002
+ '.rw-doc-body{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow:auto;padding:10px}',
3003
+ '.rw-doc-textarea{width:100%;min-height:260px;flex:1 1 auto;resize:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:12.5px/18px ui-monospace,"Cascadia Code",Consolas,monospace;outline:none;box-sizing:border-box}',
3004
+ '.rw-doc-preview{max-width:920px;margin:0 auto;color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.65;overflow-wrap:anywhere}',
3005
+ '.rw-doc-preview h1,.rw-doc-preview h2,.rw-doc-preview h3{line-height:1.3;margin:1em 0 .5em}',
3006
+ '.rw-doc-preview pre{background:var(--dsw-alias-bg-layer-2);padding:10px;border-radius:8px;overflow:auto}',
3007
+ '.rw-doc-preview table{border-collapse:collapse;margin:.8em 0}',
3008
+ '.rw-doc-preview th,.rw-doc-preview td{border:1px solid var(--dsw-alias-border-l2);padding:5px 8px}',
3009
+ '.rw-doc-preview img{max-width:100%}',
3010
+ '.rw-doc-preview blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:0;padding-left:10px;color:var(--dsw-alias-label-secondary)}',
3011
+ '.rw-doc-pdf-page{display:block;max-width:100%;margin:0 auto 10px;border:1px solid var(--dsw-alias-border-l2);box-shadow:0 2px 10px rgba(0,0,0,.08);background:#fff}',
3012
+ '.rw-doc-field{display:flex;align-items:center;gap:8px;margin:6px 0}',
3013
+ '.rw-doc-field label{font-size:11.5px;color:var(--dsw-alias-label-tertiary);min-width:38px}',
3014
+ '.rw-doc-input{height:28px;padding:0 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;box-sizing:border-box}',
3015
+ '.rw-doc-block{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px;margin:0 0 8px;background:var(--dsw-alias-bg-layer-1)}',
3016
+ '.rw-doc-block-label{font-size:10px;line-height:15px;color:var(--dsw-alias-label-tertiary);margin-bottom:3px}',
3017
+ '.rw-doc-block textarea{width:100%;min-height:52px;resize:vertical;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;outline:none;box-sizing:border-box}',
3018
+ '.rw-doc-table{border-collapse:collapse;width:100%;margin:0 0 10px;font-size:12px}',
3019
+ '.rw-doc-table td{border:1px solid var(--dsw-alias-border-l2);padding:0}',
3020
+ '.rw-doc-table input{width:100%;border:0;background:transparent;padding:4px 5px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;box-sizing:border-box}',
3021
+ '.rw-doc-tabs{display:flex;gap:3px;padding:5px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow:auto;flex:none}',
3022
+ '.rw-doc-tab{padding:2px 8px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;cursor:pointer;white-space:nowrap}',
3023
+ '.rw-doc-tab.is-on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}',
3024
+ '.rw-doc-grid{width:100%;border-collapse:collapse;font-size:11.5px}',
3025
+ '.rw-doc-grid th{position:sticky;top:0;background:var(--dsw-alias-bg-layer-2);font-weight:500;color:var(--dsw-alias-label-tertiary);padding:3px 4px;border:1px solid var(--dsw-alias-border-l1);z-index:1}',
3026
+ '.rw-doc-grid td{border:1px solid var(--dsw-alias-border-l1);padding:0;min-width:60px}',
3027
+ '.rw-doc-grid input{width:100%;border:0;background:transparent;padding:3px 4px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;box-sizing:border-box}',
3028
+ '.rw-doc-status{display:flex;align-items:center;gap:8px;padding:4px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:10.5px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-1);flex:none}',
2941
3029
  '.rw-pane-resizer:hover,.rw-pane-resizer:active{background:var(--dsw-alias-brand-primary)}',
2942
3030
  '.rw-directory-head{display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:10.5px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l1);flex:none}',
2943
3031
  '.rw-directory-body{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}',
@@ -4319,6 +4407,243 @@ function fileIconSpec(name, isDir) {
4319
4407
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('close'))));
4320
4408
  }
4321
4409
 
4410
+ function DocumentPane(props) {
4411
+ var ctx = props.ctx;
4412
+ var t = props.t;
4413
+ var locale = props.locale;
4414
+ var scope = props.scope;
4415
+ var path = props.path;
4416
+ var local = props.local === true;
4417
+ var onSaved = props.onSaved;
4418
+ var kind = documentKindOf(path);
4419
+ var stateRef = useState({
4420
+ loading: true, error: '', data: null, text: '', dirty: false, saving: false,
4421
+ preview: false, html: '', previewLoading: false, page: 0, sheet: 0, slide: 0,
4422
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, notice: '',
4423
+ });
4424
+ var state = stateRef[0];
4425
+ var setState = stateRef[1];
4426
+
4427
+ function patch(next) {
4428
+ setState(function (previous) { return Object.assign({}, previous, next); });
4429
+ }
4430
+
4431
+ function params(extra) {
4432
+ var base = local ? { path: path } : Object.assign({ path: path }, scopeParams(scope));
4433
+ return Object.assign(base, extra || {});
4434
+ }
4435
+
4436
+ function load() {
4437
+ patch({ loading: true, error: '', notice: '' });
4438
+ apiCall('docs.read', params()).then(function (result) {
4439
+ patch({
4440
+ loading: false, error: '', data: result,
4441
+ text: result.kind === 'markdown' ? String(result.text || '') : '',
4442
+ preview: false, html: '', page: 0, sheet: 0, slide: 0,
4443
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, dirty: false,
4444
+ });
4445
+ }).catch(function (error) {
4446
+ patch({ loading: false, error: messageOf(error, t, locale) });
4447
+ });
4448
+ }
4449
+
4450
+ useEffect(function () { load(); }, [path, local, scope && scope.kind, scope && scope.id]);
4451
+
4452
+ useEffect(function () {
4453
+ if (typeof props.onDirtyChange === 'function') props.onDirtyChange(state.dirty);
4454
+ }, [state.dirty]);
4455
+
4456
+ function save() {
4457
+ if (state.saving) return;
4458
+ patch({ saving: true, error: '', notice: '' });
4459
+ var data = {};
4460
+ if (kind === 'markdown') data = { text: state.text };
4461
+ if (kind === 'docx' && state.data) data = { paragraphs: state.data.paragraphs || [], tables: state.data.tables || [] };
4462
+ if (kind === 'xlsx') data = { updates: Object.keys(state.updates).map(function (key) { return state.updates[key]; }) };
4463
+ if (kind === 'pptx') data = { updates: Object.keys(state.pptxUpdates).map(function (key) { return state.pptxUpdates[key]; }) };
4464
+ if (kind === 'pdf' && state.data) {
4465
+ data = {
4466
+ metadata: state.data.metadata || {},
4467
+ rotations: Object.keys(state.rotations).map(function (index) { return { index: Number(index), degrees: state.rotations[index] }; }),
4468
+ deleted: Object.keys(state.deleted).filter(function (index) { return state.deleted[index]; }).map(Number),
4469
+ };
4470
+ }
4471
+ apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function () {
4472
+ patch({ saving: false, dirty: false, notice: t('saved') });
4473
+ if (onSaved) onSaved();
4474
+ load();
4475
+ }).catch(function (error) {
4476
+ patch({ saving: false, error: messageOf(error, t, locale) });
4477
+ });
4478
+ }
4479
+
4480
+ function showPreview() {
4481
+ var next = !state.preview;
4482
+ patch({ preview: next, previewLoading: next && !state.html });
4483
+ if (!next || state.html) return;
4484
+ apiCall('docs.mdPreview', params({ text: state.text })).then(function (result) {
4485
+ patch({ previewLoading: false, html: sanitizeDocumentHtml(result.html) });
4486
+ }).catch(function (error) {
4487
+ patch({ previewLoading: false, error: messageOf(error, t, locale) });
4488
+ });
4489
+ }
4490
+
4491
+ function exportPdf() {
4492
+ patch({ saving: true, error: '', notice: '' });
4493
+ apiCall('docs.mdPdf', params({ text: state.text })).then(function (result) {
4494
+ patch({ saving: false, notice: t('docExportDone', { path: result.path }) });
4495
+ if (onSaved) onSaved();
4496
+ }).catch(function (error) {
4497
+ patch({ saving: false, error: messageOf(error, t, locale) });
4498
+ });
4499
+ }
4500
+
4501
+ function updateParagraph(index, value) {
4502
+ var data = state.data || {};
4503
+ var paragraphs = (data.paragraphs || []).slice();
4504
+ paragraphs[index] = Object.assign({}, paragraphs[index], { text: value });
4505
+ patch({ data: Object.assign({}, data, { paragraphs: paragraphs }), dirty: true });
4506
+ }
4507
+
4508
+ function updateTableCell(tableIndex, rowIndex, cellIndex, value) {
4509
+ var data = state.data || {};
4510
+ var tables = (data.tables || []).map(function (rows) { return rows.map(function (row) { return row.slice(); }); });
4511
+ if (!tables[tableIndex] || !tables[tableIndex][rowIndex]) return;
4512
+ tables[tableIndex][rowIndex][cellIndex] = value;
4513
+ patch({ data: Object.assign({}, data, { tables: tables }), dirty: true });
4514
+ }
4515
+
4516
+ function updateCell(sheetName, cell, value) {
4517
+ var key = sheetName + '!' + cell;
4518
+ var updates = Object.assign({}, state.updates);
4519
+ updates[key] = { sheet: sheetName, cell: cell, value: value };
4520
+ patch({ updates: updates, dirty: true });
4521
+ }
4522
+
4523
+ function updateSlideText(slideIndex, shapeIndex, value) {
4524
+ var key = slideIndex + ':' + shapeIndex;
4525
+ var updates = Object.assign({}, state.pptxUpdates);
4526
+ updates[key] = { slide: slideIndex, shape: shapeIndex, text: value };
4527
+ patch({ pptxUpdates: updates, dirty: true });
4528
+ }
4529
+
4530
+ var toolbar = h('div', { className: 'rw-doc-toolbar' },
4531
+ h('span', { className: 'rw-chip' }, kind.toUpperCase()),
4532
+ h('span', { className: 'rw-hint', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: path }, path),
4533
+ kind === 'markdown' ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: showPreview }, state.preview ? t('docEdit') : t('docPreview')) : null,
4534
+ kind === 'markdown' ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: exportPdf, disabled: state.saving }, IconDownload(12), t('exportPdf')) : null,
4535
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: save, disabled: state.saving || !state.dirty }, state.saving ? Spinner(12) : IconSave(12), state.saving ? t('saving') : t('save')));
4536
+
4537
+ if (state.loading) {
4538
+ return h('div', { className: 'rw-doc' }, toolbar, h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
4539
+ }
4540
+ if (state.error && !state.data) {
4541
+ return h('div', { className: 'rw-doc' }, toolbar,
4542
+ h('div', { className: 'rw-editor-message' },
4543
+ h(Banner, { kind: 'error' }, state.error),
4544
+ h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('reload'))));
4545
+ }
4546
+
4547
+ var body = null;
4548
+ if (kind === 'markdown') {
4549
+ body = state.preview
4550
+ ? h('div', { className: 'rw-doc-body' },
4551
+ state.previewLoading ? h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')) : null,
4552
+ state.html ? h('div', { className: 'rw-doc-preview', dangerouslySetInnerHTML: { __html: state.html } }) : null)
4553
+ : h('div', { className: 'rw-doc-body' },
4554
+ h('textarea', {
4555
+ className: 'rw-doc-textarea', value: state.text, spellCheck: false,
4556
+ onChange: function (event) { patch({ text: event.currentTarget.value, dirty: true, html: '' }); },
4557
+ }));
4558
+ } else if (kind === 'pdf' && state.data) {
4559
+ var pages = state.data.pages || [];
4560
+ var visible = pages.filter(function (page) { return !state.deleted[page.index]; });
4561
+ var current = visible[Math.min(state.page, Math.max(visible.length - 1, 0))] || null;
4562
+ body = h('div', { className: 'rw-doc-body' },
4563
+ h('div', { className: 'rw-doc-field' },
4564
+ h('label', null, t('docPdfTitle')),
4565
+ h('input', { className: 'rw-doc-input', style: { flex: '1 1 auto' }, value: (state.data.metadata && state.data.metadata.title) || '', onChange: function (event) { patch({ data: Object.assign({}, state.data, { metadata: Object.assign({}, state.data.metadata, { title: event.currentTarget.value }) }), dirty: true }); } }),
4566
+ h('label', null, t('docPdfAuthor')),
4567
+ h('input', { className: 'rw-doc-input', style: { flex: '1 1 auto' }, value: (state.data.metadata && state.data.metadata.author) || '', onChange: function (event) { patch({ data: Object.assign({}, state.data, { metadata: Object.assign({}, state.data.metadata, { author: event.currentTarget.value }) }), dirty: true }); } })),
4568
+ h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', margin: '6px 0' } },
4569
+ h('div', { className: 'rw-inline' },
4570
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { patch({ page: state.page - 1 }); } }, '‹'),
4571
+ h('span', { className: 'rw-hint' }, current ? t('docPageOf', { page: current.index + 1, total: state.data.pageCount }) : ''),
4572
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= visible.length - 1, onClick: function () { patch({ page: state.page + 1 }); } }, '›')),
4573
+ h('div', { className: 'rw-inline' },
4574
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: !current, onClick: function () { var rotations = Object.assign({}, state.rotations); rotations[current.index] = ((rotations[current.index] || current.rotation || 0) + 90) % 360; patch({ rotations: rotations, dirty: true }); } }, t('docRotate')),
4575
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled: !current || visible.length <= 1, onClick: function () { var deleted = Object.assign({}, state.deleted); deleted[current.index] = true; patch({ deleted: deleted, page: Math.max(0, state.page - 1), dirty: true }); } }, t('docDeletePage')))),
4576
+ current ? h('img', { className: 'rw-doc-pdf-page', src: current.png, style: { transform: 'rotate(' + ((state.rotations[current.index] || current.rotation || 0) % 360) + 'deg)' } }) : h('div', { className: 'rw-empty' }, t('noEntries')));
4577
+ } else if (kind === 'docx' && state.data) {
4578
+ var paragraphs = state.data.paragraphs || [];
4579
+ var tables = state.data.tables || [];
4580
+ body = h('div', { className: 'rw-doc-body' },
4581
+ paragraphs.map(function (paragraph, index) {
4582
+ return h('div', { key: 'p' + index, className: 'rw-doc-block' },
4583
+ h('div', { className: 'rw-doc-block-label' }, (paragraph.style || t('docParagraph')) + ' · ' + (index + 1)),
4584
+ h('textarea', { value: paragraph.text || '', onChange: function (event) { updateParagraph(index, event.currentTarget.value); } }));
4585
+ }),
4586
+ tables.map(function (rows, tableIndex) {
4587
+ return h('div', { key: 't' + tableIndex },
4588
+ h('div', { className: 'rw-doc-block-label' }, t('docTable') + ' ' + (tableIndex + 1)),
4589
+ h('table', { className: 'rw-doc-table' }, h('tbody', null,
4590
+ rows.map(function (row, rowIndex) {
4591
+ return h('tr', { key: rowIndex }, row.map(function (cell, cellIndex) {
4592
+ return h('td', { key: cellIndex }, h('input', { value: cell || '', onChange: function (event) { updateTableCell(tableIndex, rowIndex, cellIndex, event.currentTarget.value); } }));
4593
+ }));
4594
+ }))));
4595
+ }));
4596
+ } else if (kind === 'xlsx' && state.data) {
4597
+ var sheets = state.data.sheets || [];
4598
+ var active = sheets[Math.min(state.sheet, Math.max(sheets.length - 1, 0))] || { name: '', rows: [] };
4599
+ var columns = Math.max(active.maxCol || 0, 8);
4600
+ var header = h('tr', null, h('th', null, ''), Array.from({ length: columns }, function (_, index) { return h('th', { key: index }, String.fromCharCode(65 + index)); }));
4601
+ var gridRows = (active.rows || []).map(function (row, rowIndex) {
4602
+ return h('tr', { key: rowIndex },
4603
+ h('th', null, String(rowIndex + 1)),
4604
+ Array.from({ length: columns }, function (_, colIndex) {
4605
+ var cell = String.fromCharCode(65 + colIndex) + (rowIndex + 1);
4606
+ var value = row[colIndex];
4607
+ return h('td', { key: colIndex }, h('input', {
4608
+ value: value === null || value === undefined ? '' : String(value),
4609
+ onChange: function (event) { updateCell(active.name, cell, event.currentTarget.value); },
4610
+ }));
4611
+ }));
4612
+ });
4613
+ body = h('div', { className: 'rw-doc-body', style: { padding: 0, overflow: 'hidden' } },
4614
+ h('div', { className: 'rw-doc-tabs' }, sheets.map(function (sheet, index) {
4615
+ return h('button', { key: sheet.name, type: 'button', className: 'rw-doc-tab' + (index === state.sheet ? ' is-on' : ''), onClick: function () { patch({ sheet: index }); } }, sheet.name);
4616
+ })),
4617
+ h('div', { style: { flex: '1 1 auto', overflow: 'auto', padding: 8 } },
4618
+ h('table', { className: 'rw-doc-grid' },
4619
+ h('thead', null, header),
4620
+ h('tbody', null, gridRows))));
4621
+ } else if (kind === 'pptx' && state.data) {
4622
+ var slides = state.data.slides || [];
4623
+ var slide = slides[Math.min(state.slide, Math.max(slides.length - 1, 0))] || { texts: [] };
4624
+ body = h('div', { className: 'rw-doc-body' },
4625
+ h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', marginBottom: 6 } },
4626
+ h('div', { className: 'rw-inline' },
4627
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.slide <= 0, onClick: function () { patch({ slide: state.slide - 1 }); } }, '‹'),
4628
+ h('span', { className: 'rw-hint' }, (state.slide + 1) + ' / ' + slides.length),
4629
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.slide >= slides.length - 1, onClick: function () { patch({ slide: state.slide + 1 }); } }, '›'))),
4630
+ (slide.texts || []).map(function (item) {
4631
+ return h('div', { key: item.shape, className: 'rw-doc-block' },
4632
+ h('div', { className: 'rw-doc-block-label' }, (item.name || t('docSlide')) + ' · ' + item.shape),
4633
+ h('textarea', { value: (state.pptxUpdates[state.slide + ':' + item.shape] || item).text || '', onChange: function (event) { updateSlideText(state.slide, item.shape, event.currentTarget.value); } }));
4634
+ }));
4635
+ }
4636
+
4637
+ return h('div', { className: 'rw-doc' }, toolbar,
4638
+ state.notice ? h('div', { style: { padding: '6px 10px 0' } }, h(Banner, { kind: 'success' }, state.notice)) : null,
4639
+ state.error ? h('div', { style: { padding: '6px 10px 0' } }, h(Banner, { kind: 'error' }, state.error)) : null,
4640
+ body,
4641
+ h('div', { className: 'rw-doc-status' },
4642
+ h('span', null, kind.toUpperCase()),
4643
+ h('span', null, path),
4644
+ state.dirty ? h('span', { className: 'rw-status-chip rw-status-warn' }, t('dirty')) : null));
4645
+ }
4646
+
4322
4647
  function RemoteDirectoryTab(props) {
4323
4648
  var ctx = props.ctx;
4324
4649
  var t = props.t;
@@ -4734,19 +5059,25 @@ function fileIconSpec(name, isDir) {
4734
5059
 
4735
5060
  function submitNew(side, dirPath, name, isDir) {
4736
5061
  var target = joinChildPath(dirPath, name, side);
5062
+ var docKind = documentKindOf(name);
4737
5063
  var promise;
4738
- if (side === 'remote') {
4739
- promise = isDir
5064
+ if (isDir) {
5065
+ promise = side === 'remote'
4740
5066
  ? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
4741
- : apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)));
5067
+ : apiCall('local.mkdir', { path: dirPath, name: name });
5068
+ } else if (docKind) {
5069
+ promise = side === 'remote'
5070
+ ? apiCall('docs.create', Object.assign({ path: target, kind: docKind }, scopeParams(scopeRef)))
5071
+ : apiCall('docs.create', { path: target, kind: docKind });
4742
5072
  } else {
4743
- promise = isDir
4744
- ? apiCall('local.mkdir', { path: dirPath, name: name })
5073
+ promise = side === 'remote'
5074
+ ? apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)))
4745
5075
  : apiCall('local.newFile', { path: target });
4746
5076
  }
4747
5077
  promise.then(function () {
4748
5078
  showNotice('success', t('newDone'));
4749
5079
  refreshTree();
5080
+ if (!isDir && docKind) openInternalFile(side, target);
4750
5081
  }).catch(noticeError);
4751
5082
  }
4752
5083
 
@@ -4997,8 +5328,25 @@ function fileIconSpec(name, isDir) {
4997
5328
  }
4998
5329
 
4999
5330
  var activeFile = openFiles.filter(function (file) { return file.key === activeFileKey; })[0] || null;
5331
+ var activeDocKind = activeFile ? documentKindOf(activeFile.name) : '';
5000
5332
  var contentArea = activeFile
5001
- ? h(RemoteFileEditor, {
5333
+ ? (activeDocKind
5334
+ ? h(DocumentPane, {
5335
+ ctx: ctx, t: t, locale: locale,
5336
+ scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
5337
+ path: activeFile.path,
5338
+ local: activeFile.side === 'local',
5339
+ onSaved: refreshTree,
5340
+ onDirtyChange: function (dirty) {
5341
+ setFileDirty(function (previous) {
5342
+ if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
5343
+ var next = Object.assign({}, previous);
5344
+ next[activeFile.key] = Boolean(dirty);
5345
+ return next;
5346
+ });
5347
+ },
5348
+ })
5349
+ : h(RemoteFileEditor, {
5002
5350
  ctx: ctx, t: t, locale: locale,
5003
5351
  scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
5004
5352
  path: activeFile.path,
@@ -5013,7 +5361,7 @@ function fileIconSpec(name, isDir) {
5013
5361
  return next;
5014
5362
  });
5015
5363
  },
5016
- })
5364
+ }))
5017
5365
  : h('div', { className: 'rw-placeholder' },
5018
5366
  h('span', { style: { display: 'inline-flex', opacity: 0.7 } }, IconFolder(30, false)),
5019
5367
  h('div', { className: 'rw-placeholder-title' }, t('openFileTitle')),
package/lib/index.js CHANGED
@@ -16,11 +16,12 @@
16
16
  * addresses and workspace paths.
17
17
  */
18
18
  import { randomUUID } from 'node:crypto'
19
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
20
20
  import { spawn } from 'node:child_process'
21
21
  import { mkdir, readdir, stat as fsStat } from 'node:fs/promises'
22
22
  import { homedir, tmpdir } from 'node:os'
23
- import { basename, dirname, isAbsolute, join, posix, resolve, sep } from 'node:path'
23
+ import { basename, dirname, extname, isAbsolute, join, posix, resolve, sep } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
24
25
  import { listHosts, RemoteHost, SshError } from './ssh.js'
25
26
  import * as linkedFs from './linked.js'
26
27
  import * as git from './git.js'
@@ -1423,6 +1424,129 @@ export function apply(ctx) {
1423
1424
  }
1424
1425
  }
1425
1426
 
1427
+ // ── document bridge (Markdown/PDF/DOCX/XLSX/PPTX) ──────────────────────
1428
+
1429
+ const DOC_MAX_BYTES = 80 * 1024 * 1024
1430
+ const DOC_TOOL = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc_tool.py')
1431
+ const DOC_KIND_BY_EXT = new Map([
1432
+ ['.md', 'markdown'], ['.markdown', 'markdown'],
1433
+ ['.pdf', 'pdf'], ['.docx', 'docx'], ['.xlsx', 'xlsx'], ['.pptx', 'pptx'],
1434
+ ])
1435
+
1436
+ function docKind(path) {
1437
+ return DOC_KIND_BY_EXT.get(extname(String(path || '')).toLowerCase()) || ''
1438
+ }
1439
+
1440
+ function docPython() {
1441
+ return process.env.DSH_OFFICE_PYTHON || process.env.DSH_PYTHON || (process.platform === 'win32' ? 'python' : 'python3')
1442
+ }
1443
+
1444
+ function runDocTool(args, timeoutMs = 120000) {
1445
+ return new Promise((resolvePromise, rejectPromise) => {
1446
+ let child
1447
+ try {
1448
+ child = spawn(docPython(), [DOC_TOOL, ...args], { windowsHide: true })
1449
+ } catch (error) {
1450
+ rejectPromise(new ApiError('DOC_RUNTIME', '需要 Python 3 与文档库:' + (error && error.message ? error.message : error)))
1451
+ return
1452
+ }
1453
+ const out = []
1454
+ const err = []
1455
+ const timer = setTimeout(() => {
1456
+ try { child.kill() } catch { /* already gone */ }
1457
+ rejectPromise(new ApiError('DOC_TIMEOUT', '文档处理超时'))
1458
+ }, timeoutMs)
1459
+ child.stdout.on('data', (chunk) => out.push(chunk))
1460
+ child.stderr.on('data', (chunk) => err.push(chunk))
1461
+ child.on('error', (error) => {
1462
+ clearTimeout(timer)
1463
+ if (error && error.code === 'ENOENT') {
1464
+ rejectPromise(new ApiError('DOC_RUNTIME', '未找到 Python。请安装 Python 3 以及 python-docx / openpyxl / python-pptx / PyMuPDF,或设置 DSH_OFFICE_PYTHON。'))
1465
+ } else {
1466
+ rejectPromise(new ApiError('DOC_RUNTIME', error && error.message ? error.message : String(error)))
1467
+ }
1468
+ })
1469
+ child.on('close', (code) => {
1470
+ clearTimeout(timer)
1471
+ const stdout = Buffer.concat(out).toString('utf8')
1472
+ const stderr = Buffer.concat(err).toString('utf8')
1473
+ let parsed
1474
+ try {
1475
+ parsed = JSON.parse(stdout.trim().split(String.fromCharCode(10)).filter(Boolean).pop() || '{}')
1476
+ } catch {
1477
+ parsed = null
1478
+ }
1479
+ if (code !== 0 || !parsed || parsed.ok !== true) {
1480
+ const message = (parsed && parsed.error) || stderr.trim() || ('文档处理失败(退出码 ' + code + ')')
1481
+ rejectPromise(new ApiError('DOC_FAILED', String(message).slice(0, 800)))
1482
+ return
1483
+ }
1484
+ resolvePromise(parsed.data)
1485
+ })
1486
+ })
1487
+ }
1488
+
1489
+ async function docTarget(params) {
1490
+ if (params.linkedId || params.workspaceId) {
1491
+ const scope = await requireScope(params)
1492
+ const target = scopeTarget(scope, params.path)
1493
+ return { remote: true, scope, path: target }
1494
+ }
1495
+ const raw = typeof params.path === 'string' ? params.path.trim() : ''
1496
+ if (raw === '' || !isAbsolute(raw)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
1497
+ return { remote: false, path: resolve(raw) }
1498
+ }
1499
+
1500
+ async function readDocBytes(target, current) {
1501
+ if (!current.remote) {
1502
+ const info = await fsStat(current.path)
1503
+ if (!info.isFile()) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1504
+ if (info.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1505
+ return { data: readFileSync(current.path), mtime: info.mtimeMs, size: info.size }
1506
+ }
1507
+ const stat = await current.scope.remote.stat(current.path)
1508
+ if (stat.type !== 'file') throw new ApiError('FS_NOT_DIR', '只能打开文件')
1509
+ if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1510
+ const read = await current.scope.remote.readFile(current.path, { maxBytes: DOC_MAX_BYTES })
1511
+ return { data: read.data, mtime: stat.mtime, size: stat.size }
1512
+ }
1513
+
1514
+ async function writeDocBytes(target, data) {
1515
+ if (!target.remote) {
1516
+ await mkdir(dirname(target.path), { recursive: true })
1517
+ const temp = target.path + '.' + randomUUID() + '.tmp'
1518
+ writeFileSync(temp, data)
1519
+ try { renameSync(temp, target.path) } catch (error) { rmSync(temp, { force: true }); throw error }
1520
+ const info = await fsStat(target.path)
1521
+ return { path: target.path, size: info.size, mtime: info.mtimeMs }
1522
+ }
1523
+ const result = await target.scope.remote.writeFileAtomic(target.path, data)
1524
+ return { path: target.path, size: result.size, mtime: result.mtime }
1525
+ }
1526
+
1527
+ async function makeDocTemp() {
1528
+ const directory = mkdtempSync(join(tmpdir(), 'dsh-doc-'))
1529
+ return {
1530
+ dir: directory,
1531
+ file(name) { return join(directory, name) },
1532
+ cleanup() { try { rmSync(directory, { recursive: true, force: true }) } catch { /* best effort */ } },
1533
+ }
1534
+ }
1535
+
1536
+ function assertDocKind(path, expected) {
1537
+ const kind = docKind(path)
1538
+ if (kind === '') throw new ApiError('DOC_UNSUPPORTED', '仅支持 Markdown、PDF、DOCX、XLSX、PPTX')
1539
+ if (expected && expected !== kind) throw new ApiError('DOC_UNSUPPORTED', '文件类型与请求不一致')
1540
+ return kind
1541
+ }
1542
+
1543
+ async function docStat(target) {
1544
+ if (!target.remote) {
1545
+ try { const info = await fsStat(target.path); return info } catch { return null }
1546
+ }
1547
+ try { return await target.scope.remote.stat(target.path) } catch { return null }
1548
+ }
1549
+
1426
1550
  // ── method table ─────────────────────────────────────────────────────────
1427
1551
 
1428
1552
  const methods = {
@@ -1606,6 +1730,136 @@ export function apply(ctx) {
1606
1730
  return { path: target, size: info.size, mtime: info.mtime }
1607
1731
  },
1608
1732
 
1733
+ async 'docs.read'(params = {}) {
1734
+ const target = await docTarget(params)
1735
+ const kind = assertDocKind(target.path)
1736
+ const current = await readDocBytes(target, target)
1737
+ if (kind === 'markdown') {
1738
+ if (looksBinary(current.data)) throw new ApiError('DOC_BINARY', 'Markdown 文件包含二进制内容')
1739
+ return { kind, path: target.path, text: decodeUtf8(current.data), size: current.size, mtime: current.mtime }
1740
+ }
1741
+ const temp = await makeDocTemp()
1742
+ try {
1743
+ const input = temp.file('input.' + (kind === 'markdown' ? 'md' : kind))
1744
+ writeFileSync(input, current.data)
1745
+ const data = await runDocTool(['read', '--kind', kind, '--file', input])
1746
+ return { ...data, path: target.path, size: current.size, mtime: current.mtime }
1747
+ } finally {
1748
+ temp.cleanup()
1749
+ }
1750
+ },
1751
+
1752
+ async 'docs.write'(params = {}) {
1753
+ const target = await docTarget(params)
1754
+ const kind = assertDocKind(target.path)
1755
+ const data = params.data
1756
+ if (!data || typeof data !== 'object') throw new ApiError('BAD_REQUEST', '缺少文档数据')
1757
+ if (typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
1758
+ const current = await docStat(target)
1759
+ if (current && Math.floor(current.mtime / 1000) !== Math.floor(params.expectedMtime / 1000)) {
1760
+ throw new ApiError('CONFLICT', '文件已被修改,请重新加载后再保存')
1761
+ }
1762
+ }
1763
+ if (kind === 'markdown') {
1764
+ if (typeof data.text !== 'string') throw new ApiError('BAD_REQUEST', 'Markdown 内容无效')
1765
+ const written = await writeDocBytes(target, Buffer.from(data.text, 'utf8'))
1766
+ return { kind, ...written }
1767
+ }
1768
+ const current = await readDocBytes(target, target)
1769
+ const temp = await makeDocTemp()
1770
+ try {
1771
+ const source = temp.file('source.' + kind)
1772
+ const spec = temp.file('data.json')
1773
+ const output = temp.file('output.' + kind)
1774
+ writeFileSync(source, current.data)
1775
+ writeFileSync(spec, JSON.stringify(data))
1776
+ await runDocTool(['write', '--kind', kind, '--file', source, '--data', spec, '--out', output])
1777
+ const written = await writeDocBytes(target, readFileSync(output))
1778
+ return { kind, ...written }
1779
+ } finally {
1780
+ temp.cleanup()
1781
+ }
1782
+ },
1783
+
1784
+ async 'docs.create'(params = {}) {
1785
+ const target = await docTarget(params)
1786
+ const kind = assertDocKind(target.path, params.kind)
1787
+ const existing = await docStat(target)
1788
+ if (existing) throw new ApiError('FS_EXISTS', '目标文件已存在')
1789
+ const temp = await makeDocTemp()
1790
+ try {
1791
+ const cliKind = kind === 'markdown' ? 'md' : kind
1792
+ const output = temp.file('new.' + cliKind)
1793
+ await runDocTool(['create', '--kind', cliKind, '--out', output])
1794
+ const written = await writeDocBytes(target, readFileSync(output))
1795
+ return { kind, ...written }
1796
+ } finally {
1797
+ temp.cleanup()
1798
+ }
1799
+ },
1800
+
1801
+ async 'docs.mdPreview'(params = {}) {
1802
+ const target = await docTarget(params)
1803
+ assertDocKind(target.path, 'markdown')
1804
+ let text = typeof params.text === 'string' ? params.text : null
1805
+ if (text === null) {
1806
+ const current = await readDocBytes(target, target)
1807
+ text = decodeUtf8(current.data)
1808
+ }
1809
+ const temp = await makeDocTemp()
1810
+ try {
1811
+ const input = temp.file('preview.md')
1812
+ writeFileSync(input, text, 'utf8')
1813
+ const data = await runDocTool(['md-preview', '--file', input])
1814
+ return data
1815
+ } finally {
1816
+ temp.cleanup()
1817
+ }
1818
+ },
1819
+
1820
+ async 'docs.mdPdf'(params = {}) {
1821
+ const target = await docTarget(params)
1822
+ assertDocKind(target.path, 'markdown')
1823
+ let text = typeof params.text === 'string' ? params.text : null
1824
+ if (text === null) {
1825
+ const current = await readDocBytes(target, target)
1826
+ text = decodeUtf8(current.data)
1827
+ }
1828
+ const extension = extname(target.path)
1829
+ const base = target.path.slice(0, -extension.length)
1830
+ let outputPath = base + '.pdf'
1831
+ if (target.remote) {
1832
+ const directory = posix.dirname(outputPath)
1833
+ let candidate = outputPath
1834
+ let index = 1
1835
+ while (await target.scope.remote.stat(candidate).catch(() => null)) {
1836
+ candidate = posix.join(directory, posix.basename(base) + '-' + index + '.pdf')
1837
+ index += 1
1838
+ }
1839
+ outputPath = candidate
1840
+ } else {
1841
+ const directory = dirname(outputPath)
1842
+ let candidate = outputPath
1843
+ let index = 1
1844
+ while (existsSync(candidate)) {
1845
+ candidate = join(directory, basename(base) + '-' + index + '.pdf')
1846
+ index += 1
1847
+ }
1848
+ outputPath = candidate
1849
+ }
1850
+ const temp = await makeDocTemp()
1851
+ try {
1852
+ const input = temp.file('export.md')
1853
+ const output = temp.file('export.pdf')
1854
+ writeFileSync(input, text, 'utf8')
1855
+ await runDocTool(['md-pdf', '--file', input, '--out', output], 180000)
1856
+ const written = await writeDocBytes({ ...target, path: outputPath }, readFileSync(output))
1857
+ return { kind: 'pdf', ...written }
1858
+ } finally {
1859
+ temp.cleanup()
1860
+ }
1861
+ },
1862
+
1609
1863
  async 'fs.stat'(params = {}) {
1610
1864
  const scope = await requireScope(params)
1611
1865
  const target = scopeTarget(scope, params.path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.5.13",
3
+ "version": "0.6.0",
4
4
  "description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,7 +22,8 @@
22
22
  "LICENSE",
23
23
  "README.en.md",
24
24
  "docs/API.md",
25
- "docs/VERIFY.md"
25
+ "docs/VERIFY.md",
26
+ "scripts/doc_tool.py"
26
27
  ],
27
28
  "keywords": [
28
29
  "deepseek-harness",
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """Document bridge for dsh-linked-workspace.
3
+
4
+ Reads/edits Markdown, PDF, DOCX, XLSX and PPTX files. It is intentionally a
5
+ small, dependency-light helper: python-docx / openpyxl / python-pptx / PyMuPDF
6
+ are only needed when the corresponding format is used.
7
+ """
8
+ import argparse
9
+ import base64
10
+ import datetime
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+
16
+ MAX_PDF_PAGES = 40
17
+ MAX_PDF_WIDTH = 1100
18
+ MAX_SHEET_ROWS = 200
19
+ MAX_SHEET_COLS = 40
20
+ MAX_PPTX_SLIDES = 80
21
+
22
+
23
+ def fail(message):
24
+ print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=False))
25
+ sys.exit(1)
26
+
27
+
28
+ def emit(data):
29
+ print(json.dumps({"ok": True, "data": data}, ensure_ascii=False))
30
+
31
+
32
+ def json_value(value):
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
36
+ return value.isoformat()
37
+ if isinstance(value, (int, float, bool, str)):
38
+ return value
39
+ return str(value)
40
+
41
+
42
+ # ── DOCX ──────────────────────────────────────────────────────────────────
43
+
44
+ def read_docx(path):
45
+ from docx import Document
46
+ from docx.table import Table
47
+ from docx.text.paragraph import Paragraph
48
+
49
+ doc = Document(path)
50
+ paragraphs = []
51
+ for paragraph in doc.paragraphs:
52
+ paragraphs.append({
53
+ "style": paragraph.style.name if paragraph.style is not None else "",
54
+ "text": paragraph.text,
55
+ })
56
+ tables = []
57
+ for table in doc.tables:
58
+ rows = []
59
+ for row in table.rows:
60
+ rows.append([cell.text for cell in row.cells])
61
+ tables.append(rows)
62
+ emit({"kind": "docx", "paragraphs": paragraphs, "tables": tables})
63
+
64
+
65
+ def write_docx(source, data, out):
66
+ from docx import Document
67
+
68
+ doc = Document(source)
69
+ paragraphs = data.get("paragraphs")
70
+ if isinstance(paragraphs, list):
71
+ for index, block in enumerate(paragraphs):
72
+ if index >= len(doc.paragraphs):
73
+ break
74
+ if isinstance(block, dict) and isinstance(block.get("text"), str):
75
+ doc.paragraphs[index].text = block["text"]
76
+ tables = data.get("tables")
77
+ if isinstance(tables, list):
78
+ for table_index, rows in enumerate(tables):
79
+ if table_index >= len(doc.tables) or not isinstance(rows, list):
80
+ continue
81
+ table = doc.tables[table_index]
82
+ for row_index, row in enumerate(rows):
83
+ if row_index >= len(table.rows) or not isinstance(row, list):
84
+ continue
85
+ for cell_index, value in enumerate(row):
86
+ if cell_index >= len(table.rows[row_index].cells):
87
+ continue
88
+ if isinstance(value, str):
89
+ table.rows[row_index].cells[cell_index].text = value
90
+ doc.save(out)
91
+ emit({"kind": "docx", "path": out})
92
+
93
+
94
+ # ── XLSX ──────────────────────────────────────────────────────────────────
95
+
96
+ def read_xlsx(path):
97
+ from openpyxl import load_workbook
98
+
99
+ workbook = load_workbook(path, data_only=False)
100
+ sheets = []
101
+ for sheet in workbook.worksheets[:20]:
102
+ rows = []
103
+ max_row = min(sheet.max_row or 0, MAX_SHEET_ROWS)
104
+ max_col = min(sheet.max_column or 0, MAX_SHEET_COLS)
105
+ for row in sheet.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
106
+ rows.append([json_value(cell.value) for cell in row])
107
+ sheets.append({
108
+ "name": sheet.title,
109
+ "rows": rows,
110
+ "maxRow": max_row,
111
+ "maxCol": max_col,
112
+ "frozen": bool(sheet.freeze_panes),
113
+ })
114
+ emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets)})
115
+
116
+
117
+ def write_xlsx(source, data, out):
118
+ from openpyxl import load_workbook
119
+
120
+ workbook = load_workbook(source)
121
+ updates = data.get("updates") or []
122
+ for update in updates:
123
+ if not isinstance(update, dict):
124
+ continue
125
+ sheet_name = update.get("sheet")
126
+ cell = update.get("cell")
127
+ value = update.get("value")
128
+ if not isinstance(sheet_name, str) or not isinstance(cell, str):
129
+ continue
130
+ if sheet_name not in workbook.sheetnames:
131
+ continue
132
+ if not re.match(r"^[A-Za-z]{1,3}[1-9][0-9]*$", cell):
133
+ continue
134
+ if isinstance(value, str) and value.startswith("="):
135
+ workbook[sheet_name][cell] = value
136
+ elif isinstance(value, str) and re.match(r"^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$", value.strip()):
137
+ number = value.strip()
138
+ workbook[sheet_name][cell] = float(number) if "." in number else int(number)
139
+ elif value == "":
140
+ workbook[sheet_name][cell] = None
141
+ else:
142
+ workbook[sheet_name][cell] = value
143
+ workbook.save(out)
144
+ emit({"kind": "xlsx", "path": out})
145
+
146
+
147
+ # ── PPTX ──────────────────────────────────────────────────────────────────
148
+
149
+ def read_pptx(path):
150
+ from pptx import Presentation
151
+
152
+ presentation = Presentation(path)
153
+ slides = []
154
+ for slide_index, slide in enumerate(presentation.slides):
155
+ if slide_index >= MAX_PPTX_SLIDES:
156
+ break
157
+ texts = []
158
+ for shape_index, shape in enumerate(slide.shapes):
159
+ if not getattr(shape, "has_text_frame", False):
160
+ continue
161
+ text = shape.text_frame.text
162
+ if text.strip() == "":
163
+ continue
164
+ texts.append({
165
+ "shape": shape_index,
166
+ "name": getattr(shape, "name", ""),
167
+ "text": text,
168
+ })
169
+ slides.append({"index": slide_index, "texts": texts})
170
+ emit({"kind": "pptx", "slides": slides, "slideCount": len(presentation.slides)})
171
+
172
+
173
+ def write_pptx(source, data, out):
174
+ from pptx import Presentation
175
+
176
+ presentation = Presentation(source)
177
+ updates = data.get("updates") or []
178
+ for update in updates:
179
+ if not isinstance(update, dict):
180
+ continue
181
+ slide_index = update.get("slide")
182
+ shape_index = update.get("shape")
183
+ text = update.get("text")
184
+ if not isinstance(slide_index, int) or not isinstance(shape_index, int) or not isinstance(text, str):
185
+ continue
186
+ if slide_index < 0 or slide_index >= len(presentation.slides):
187
+ continue
188
+ slide = presentation.slides[slide_index]
189
+ if shape_index < 0 or shape_index >= len(slide.shapes):
190
+ continue
191
+ shape = slide.shapes[shape_index]
192
+ if not getattr(shape, "has_text_frame", False):
193
+ continue
194
+ shape.text_frame.text = text
195
+ presentation.save(out)
196
+ emit({"kind": "pptx", "path": out})
197
+
198
+
199
+ # ── PDF ───────────────────────────────────────────────────────────────────
200
+
201
+ def read_pdf(path):
202
+ import fitz
203
+
204
+ doc = fitz.open(path)
205
+ pages = []
206
+ for index, page in enumerate(doc):
207
+ if index >= MAX_PDF_PAGES:
208
+ break
209
+ rect = page.rect
210
+ scale = min(1.5, MAX_PDF_WIDTH / max(rect.width, 1))
211
+ pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
212
+ pages.append({
213
+ "index": index,
214
+ "width": rect.width,
215
+ "height": rect.height,
216
+ "rotation": page.rotation,
217
+ "png": "data:image/png;base64," + base64.b64encode(pixmap.tobytes("png")).decode("ascii"),
218
+ })
219
+ metadata = doc.metadata or {}
220
+ emit({
221
+ "kind": "pdf",
222
+ "pageCount": doc.page_count,
223
+ "metadata": {
224
+ "title": metadata.get("title", ""),
225
+ "author": metadata.get("author", ""),
226
+ "subject": metadata.get("subject", ""),
227
+ },
228
+ "pages": pages,
229
+ "truncated": doc.page_count > MAX_PDF_PAGES,
230
+ })
231
+
232
+
233
+ def write_pdf(source, data, out):
234
+ import fitz
235
+
236
+ doc = fitz.open(source)
237
+ metadata = data.get("metadata") or {}
238
+ if isinstance(metadata, dict):
239
+ current = doc.metadata or {}
240
+ current.update({
241
+ "title": metadata.get("title", current.get("title", "")),
242
+ "author": metadata.get("author", current.get("author", "")),
243
+ "subject": metadata.get("subject", current.get("subject", "")),
244
+ })
245
+ doc.set_metadata(current)
246
+ for item in data.get("rotations") or []:
247
+ if not isinstance(item, dict):
248
+ continue
249
+ index = item.get("index")
250
+ degrees = item.get("degrees")
251
+ if isinstance(index, int) and 0 <= index < doc.page_count and isinstance(degrees, int):
252
+ page = doc[index]
253
+ page.set_rotation(degrees % 360)
254
+ deleted = [item for item in (data.get("deleted") or []) if isinstance(item, int)]
255
+ for index in sorted(set(deleted), reverse=True):
256
+ if 0 <= index < doc.page_count and doc.page_count > 1:
257
+ doc.delete_page(index)
258
+ doc.save(out, garbage=3, deflate=True)
259
+ doc.close()
260
+ emit({"kind": "pdf", "path": out})
261
+
262
+
263
+ # ── create / Markdown ─────────────────────────────────────────────────────
264
+
265
+ def create(kind, out):
266
+ if kind == "md":
267
+ with open(out, "w", encoding="utf-8") as handle:
268
+ handle.write("")
269
+ elif kind == "docx":
270
+ from docx import Document
271
+
272
+ document = Document()
273
+ document.add_paragraph("")
274
+ document.save(out)
275
+ elif kind == "xlsx":
276
+ from openpyxl import Workbook
277
+
278
+ workbook = Workbook()
279
+ workbook.active.title = "Sheet1"
280
+ workbook.save(out)
281
+ elif kind == "pptx":
282
+ from pptx import Presentation
283
+
284
+ presentation = Presentation()
285
+ layout = presentation.slide_layouts[1] if len(presentation.slide_layouts) > 1 else presentation.slide_layouts[0]
286
+ slide = presentation.slides.add_slide(layout)
287
+ slide.shapes.title.text = "New presentation"
288
+ presentation.save(out)
289
+ elif kind == "pdf":
290
+ import fitz
291
+
292
+ document = fitz.open()
293
+ document.new_page()
294
+ document.save(out)
295
+ document.close()
296
+ else:
297
+ raise ValueError(f"unsupported kind: {kind}")
298
+ emit({"kind": kind, "path": out})
299
+
300
+
301
+ def markdown_to_html(path):
302
+ import markdown
303
+
304
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
305
+ text = handle.read()
306
+ body = markdown.markdown(text, extensions=["extra", "sane_lists", "tables", "fenced_code"])
307
+ html = """<!doctype html><html><head><meta charset="utf-8"><style>
308
+ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.65;color:#1f2328;max-width:900px;margin:0 auto;padding:28px;font-size:15px}
309
+ h1,h2,h3,h4{line-height:1.3;margin:1.2em 0 .55em} h1{font-size:1.8em;border-bottom:1px solid #e5e7eb;padding-bottom:.25em}
310
+ pre{background:#f6f8fa;padding:12px;border-radius:8px;overflow:auto} code{font-family:ui-monospace,Consolas,monospace;font-size:.92em}
311
+ pre code{background:none;padding:0} blockquote{border-left:4px solid #d0d7de;margin:0;padding:0 1em;color:#57606a}
312
+ table{border-collapse:collapse;margin:1em 0} th,td{border:1px solid #d0d7de;padding:6px 10px;text-align:left}
313
+ img{max-width:100%} a{color:#0969da} hr{border:0;border-top:1px solid #d0d7de;margin:1.5em 0}
314
+ </style></head><body>""" + body + "</body></html>"
315
+ return html
316
+
317
+
318
+ def markdown_preview(path):
319
+ html = markdown_to_html(path)
320
+ emit({"kind": "markdown-preview", "html": html})
321
+
322
+
323
+ def markdown_pdf(path, out):
324
+ import fitz
325
+
326
+ html = markdown_to_html(path)
327
+ story = fitz.Story(html=html)
328
+ writer = fitz.DocumentWriter(out)
329
+ mediabox = fitz.paper_rect("a4")
330
+ page_rect = mediabox + (48, 48, -48, -48)
331
+ more = True
332
+ while more:
333
+ device = writer.begin_page(mediabox)
334
+ more, _ = story.place(page_rect)
335
+ story.draw(device)
336
+ writer.end_page()
337
+ writer.close()
338
+ emit({"kind": "pdf", "path": out})
339
+
340
+
341
+ # ── CLI ───────────────────────────────────────────────────────────────────
342
+
343
+ def main():
344
+ parser = argparse.ArgumentParser(description="dsh-linked-workspace document bridge")
345
+ sub = parser.add_subparsers(dest="command", required=True)
346
+
347
+ read = sub.add_parser("read")
348
+ read.add_argument("--kind", required=True)
349
+ read.add_argument("--file", required=True)
350
+
351
+ write = sub.add_parser("write")
352
+ write.add_argument("--kind", required=True)
353
+ write.add_argument("--file", required=True)
354
+ write.add_argument("--data", required=True)
355
+ write.add_argument("--out", required=True)
356
+
357
+ create_parser = sub.add_parser("create")
358
+ create_parser.add_argument("--kind", required=True)
359
+ create_parser.add_argument("--out", required=True)
360
+
361
+ preview = sub.add_parser("md-preview")
362
+ preview.add_argument("--file", required=True)
363
+
364
+ pdf = sub.add_parser("md-pdf")
365
+ pdf.add_argument("--file", required=True)
366
+ pdf.add_argument("--out", required=True)
367
+
368
+ args = parser.parse_args()
369
+ try:
370
+ if args.command == "read":
371
+ if args.kind == "docx":
372
+ read_docx(args.file)
373
+ elif args.kind == "xlsx":
374
+ read_xlsx(args.file)
375
+ elif args.kind == "pptx":
376
+ read_pptx(args.file)
377
+ elif args.kind == "pdf":
378
+ read_pdf(args.file)
379
+ else:
380
+ raise ValueError(f"unsupported kind: {args.kind}")
381
+ elif args.command == "write":
382
+ with open(args.data, "r", encoding="utf-8") as handle:
383
+ data = json.load(handle)
384
+ if args.kind == "docx":
385
+ write_docx(args.file, data, args.out)
386
+ elif args.kind == "xlsx":
387
+ write_xlsx(args.file, data, args.out)
388
+ elif args.kind == "pptx":
389
+ write_pptx(args.file, data, args.out)
390
+ elif args.kind == "pdf":
391
+ write_pdf(args.file, data, args.out)
392
+ else:
393
+ raise ValueError(f"unsupported kind: {args.kind}")
394
+ elif args.command == "create":
395
+ create(args.kind, args.out)
396
+ elif args.command == "md-preview":
397
+ markdown_preview(args.file)
398
+ elif args.command == "md-pdf":
399
+ markdown_pdf(args.file, args.out)
400
+ except Exception as error: # pragma: no cover - CLI boundary
401
+ fail(error)
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()