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 +12 -0
- package/README.en.md +18 -0
- package/README.md +12 -0
- package/docs/API.md +10 -0
- package/lib/client.js +355 -7
- package/lib/index.js +256 -2
- package/package.json +3 -2
- package/scripts/doc_tool.py +405 -0
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 (
|
|
4739
|
-
promise =
|
|
5064
|
+
if (isDir) {
|
|
5065
|
+
promise = side === 'remote'
|
|
4740
5066
|
? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
|
|
4741
|
-
: apiCall('
|
|
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 =
|
|
4744
|
-
? apiCall('
|
|
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
|
-
?
|
|
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.
|
|
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()
|