dsh-linked-workspace 0.5.13 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.en.md +22 -2
- package/README.md +12 -0
- package/docs/API.md +10 -0
- package/docs/VERIFY.md +12 -0
- package/lib/client.js +392 -9
- 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,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- Fixed the linked-workspace directory tab disappearing for a new or blank linked-workspace session: the client session store is now consulted for cwd/workspaceId before falling back to session.info, so the tab and the start-page option are registered immediately when the workspace session opens.
|
|
6
|
+
- Availability also matches the linked record workspaceId, not only the local path.
|
|
7
|
+
|
|
8
|
+
## 0.6.0 — 2026-11-05
|
|
9
|
+
|
|
10
|
+
- **Document editing in the content panel** for Markdown, PDF, DOCX, XLSX and PPTX, on both local and remote files.
|
|
11
|
+
- Markdown: raw editing, rendered preview, export to PDF.
|
|
12
|
+
- DOCX: paragraph and table text editing (styles are preserved at paragraph level; rich inline formatting may be simplified).
|
|
13
|
+
- XLSX: sheet tabs and editable cell grid; formulas and untouched styles are preserved.
|
|
14
|
+
- PPTX: slide navigation and text-frame editing.
|
|
15
|
+
- PDF: page navigation, rotate/delete page, title/author metadata; page images are rendered by PyMuPDF.
|
|
16
|
+
- New document files can be created from the directory context menu (`.md`, `.pdf`, `.docx`, `.xlsx`, `.pptx`).
|
|
17
|
+
- 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.
|
|
18
|
+
- Runs on both local folders and remote linked/directories (remote files are downloaded, processed and uploaded atomically).
|
|
19
|
+
|
|
3
20
|
## 0.5.13 — 2026-11-05
|
|
4
21
|
|
|
5
22
|
- 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.
|
|
@@ -78,11 +90,15 @@ The remote side is not modified by mirroring. The `linked_workspace` tool suppor
|
|
|
78
90
|
|
|
79
91
|
[](https://github.com/jiahui-huang1/dsh-linked-workspace/blob/main/docs/screenshots/file-editor.png)
|
|
80
92
|
|
|
81
|
-
[](https://github.com/jiahui-huang1/dsh-linked-workspace/blob/main/docs/screenshots/remote-readonly-view.png)
|
|
82
|
-
|
|
83
93
|
## Installation
|
|
84
94
|
|
|
95
|
+
```bash
|
|
96
|
+
dsh plugin --profile web add dsh-linked-workspace
|
|
85
97
|
```
|
|
98
|
+
|
|
99
|
+
Install from source (development):
|
|
100
|
+
|
|
101
|
+
```bash
|
|
86
102
|
dsh plugin --profile "$DSH_PROFILE" add "file:$PWD/dsh-linked-workspace"
|
|
87
103
|
```
|
|
88
104
|
|
|
@@ -99,6 +115,10 @@ Old “Remote directories” tabs are restored after refresh through hidden lega
|
|
|
99
115
|
|
|
100
116
|
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
|
|
101
117
|
|
|
118
|
+
### 0.6.1
|
|
119
|
+
|
|
120
|
+
Fix the linked-workspace directory tab and start-page entry disappearing in a new blank linked-workspace session by consulting the client session store for cwd and workspaceId first.
|
|
121
|
+
|
|
102
122
|
### 0.5.13
|
|
103
123
|
|
|
104
124
|
The directory panel divider is draggable (double-click resets to 300 px; width persists). The local tree refreshes silently every 4 seconds and on window focus, so new local subdirectories appear without a manual refresh.
|
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/docs/VERIFY.md
CHANGED
|
@@ -97,3 +97,15 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
|
|
|
97
97
|
- SSH Tunnel 面板、其他 better-sidebar 标签、旧 `@goodandready` 的 remote_* 工具仍可用。
|
|
98
98
|
- 安装过程中若再次出现 pnpm 清理旧包,确认 `@goodandready/dsh-remote-workspace` 仍在 profile 依赖中。
|
|
99
99
|
- 普通(非联动)工作区的行图标仍是文件夹,右侧栏旧文件标签/编辑器不受影响。
|
|
100
|
+
|
|
101
|
+
## 文档编辑
|
|
102
|
+
|
|
103
|
+
1. 在目录右键「新建文件」输入 `demo.md` / `demo.docx` / `demo.xlsx` / `demo.pptx` / `demo.pdf`,应创建对应空白文档并在内容面板打开。
|
|
104
|
+
2. Markdown:编辑文本 → 保存 → 预览 → 导出 PDF;导出的 PDF 与 Markdown 同目录,中文正常。
|
|
105
|
+
3. DOCX:修改段落/表格文字 → 保存 → 重新打开内容仍在。
|
|
106
|
+
4. XLSX:切换工作表、修改单元格 → 保存 → 重新打开值仍在。
|
|
107
|
+
5. PPTX:切换幻灯片、修改文本框 → 保存 → 重新打开文字仍在。
|
|
108
|
+
6. PDF:翻页、旋转/删除页面、修改标题/作者 → 保存 → 重新打开生效。
|
|
109
|
+
7. 以上操作在本地目录与远端(联动工作区)文件上都应可用;Office/PDF 需要 Python 3 + python-docx/openpyxl/python-pptx/PyMuPDF。
|
|
110
|
+
|
|
111
|
+
- 新建一个空白联动工作区会话:右侧栏开始页应出现「联动工作区目录」,打开后目录面板正常显示。
|
package/lib/client.js
CHANGED
|
@@ -1838,6 +1838,7 @@ function fileIconSpec(name, isDir) {
|
|
|
1838
1838
|
var TREE_TAB_TYPE = 'dsh-linked-workspace';
|
|
1839
1839
|
var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
|
|
1840
1840
|
var linkedLocalKeys = new Set();
|
|
1841
|
+
var linkedWorkspaceKeys = new Set();
|
|
1841
1842
|
var hasRemoteDirectories = false;
|
|
1842
1843
|
var linkedTabDisposer = null;
|
|
1843
1844
|
var remoteTabDisposer = null;
|
|
@@ -1847,6 +1848,10 @@ function fileIconSpec(name, isDir) {
|
|
|
1847
1848
|
return String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
1848
1849
|
}
|
|
1849
1850
|
|
|
1851
|
+
function isLinkedWorkspaceId(workspaceId) {
|
|
1852
|
+
return Boolean(workspaceId) && linkedWorkspaceKeys.has(String(workspaceId));
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1850
1855
|
function isLinkedScope(scope) {
|
|
1851
1856
|
var key = linkedScopeKey(scope && scope.cwd);
|
|
1852
1857
|
if (!key) return false;
|
|
@@ -1866,8 +1871,14 @@ function fileIconSpec(name, isDir) {
|
|
|
1866
1871
|
|
|
1867
1872
|
function rememberDirectoryAvailability(links, workspaces) {
|
|
1868
1873
|
var next = new Set();
|
|
1869
|
-
|
|
1874
|
+
var workspaceKeys = new Set();
|
|
1875
|
+
(links || []).forEach(function (link) {
|
|
1876
|
+
var key = linkedScopeKey(link.localPath);
|
|
1877
|
+
if (key) next.add(key);
|
|
1878
|
+
if (link.workspaceId) workspaceKeys.add(String(link.workspaceId));
|
|
1879
|
+
});
|
|
1870
1880
|
linkedLocalKeys = next;
|
|
1881
|
+
linkedWorkspaceKeys = workspaceKeys;
|
|
1871
1882
|
hasRemoteDirectories = Array.isArray(workspaces) && workspaces.length > 0;
|
|
1872
1883
|
}
|
|
1873
1884
|
|
|
@@ -2058,6 +2069,21 @@ function fileIconSpec(name, isDir) {
|
|
|
2058
2069
|
toggleDirectory: '开启/关闭联动工作区目录面板',
|
|
2059
2070
|
closeFile: '关闭文件标签',
|
|
2060
2071
|
resizeDirectory: '拖动调整目录面板宽度;双击恢复默认宽度',
|
|
2072
|
+
docPreview: '预览',
|
|
2073
|
+
docEdit: '编辑',
|
|
2074
|
+
exportPdf: '导出 PDF',
|
|
2075
|
+
docPages: '页',
|
|
2076
|
+
docRotate: '旋转 90°',
|
|
2077
|
+
docDeletePage: '删除当前页',
|
|
2078
|
+
docParagraph: '段落',
|
|
2079
|
+
docTable: '表格',
|
|
2080
|
+
docSheet: '工作表',
|
|
2081
|
+
docSlide: '幻灯片',
|
|
2082
|
+
docPdfTitle: '标题',
|
|
2083
|
+
docPdfAuthor: '作者',
|
|
2084
|
+
docPageOf: '第 {page} / {total} 页',
|
|
2085
|
+
docExportDone: '已导出 PDF:{path}',
|
|
2086
|
+
docPreviewFailed: '预览失败',
|
|
2061
2087
|
remoteTabTitle: '远程目录',
|
|
2062
2088
|
remoteTabDesc: '只读查看已添加的远程目录及其文件,不允许编辑。',
|
|
2063
2089
|
remoteDirsTitle: '远程目录',
|
|
@@ -2333,6 +2359,21 @@ function fileIconSpec(name, isDir) {
|
|
|
2333
2359
|
toggleDirectory: 'Show or hide the linked workspace directory panel',
|
|
2334
2360
|
closeFile: 'Close file tab',
|
|
2335
2361
|
resizeDirectory: 'Drag to resize the directory panel; double-click to reset',
|
|
2362
|
+
docPreview: 'Preview',
|
|
2363
|
+
docEdit: 'Edit',
|
|
2364
|
+
exportPdf: 'Export PDF',
|
|
2365
|
+
docPages: 'pages',
|
|
2366
|
+
docRotate: 'Rotate 90°',
|
|
2367
|
+
docDeletePage: 'Delete current page',
|
|
2368
|
+
docParagraph: 'Paragraph',
|
|
2369
|
+
docTable: 'Table',
|
|
2370
|
+
docSheet: 'Sheet',
|
|
2371
|
+
docSlide: 'Slide',
|
|
2372
|
+
docPdfTitle: 'Title',
|
|
2373
|
+
docPdfAuthor: 'Author',
|
|
2374
|
+
docPageOf: 'Page {page} / {total}',
|
|
2375
|
+
docExportDone: 'Exported PDF: {path}',
|
|
2376
|
+
docPreviewFailed: 'Preview failed',
|
|
2336
2377
|
remoteTabTitle: 'Remote directories',
|
|
2337
2378
|
remoteTabDesc: 'Read-only view of configured remote directories and files; editing is disabled.',
|
|
2338
2379
|
remoteDirsTitle: 'Remote directories',
|
|
@@ -2589,6 +2630,35 @@ function fileIconSpec(name, isDir) {
|
|
|
2589
2630
|
return error.message || String(error);
|
|
2590
2631
|
}
|
|
2591
2632
|
|
|
2633
|
+
function documentKindOf(path) {
|
|
2634
|
+
var ext = String(path || '').toLowerCase().match(/\.[^./\\]+$/);
|
|
2635
|
+
if (!ext) return '';
|
|
2636
|
+
var value = ext[0];
|
|
2637
|
+
if (value === '.md' || value === '.markdown') return 'markdown';
|
|
2638
|
+
if (value === '.pdf') return 'pdf';
|
|
2639
|
+
if (value === '.docx') return 'docx';
|
|
2640
|
+
if (value === '.xlsx') return 'xlsx';
|
|
2641
|
+
if (value === '.pptx') return 'pptx';
|
|
2642
|
+
return '';
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function sanitizeDocumentHtml(html) {
|
|
2646
|
+
try {
|
|
2647
|
+
var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
|
2648
|
+
parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
|
|
2649
|
+
parsed.querySelectorAll('*').forEach(function (element) {
|
|
2650
|
+
Array.from(element.attributes).forEach(function (attribute) {
|
|
2651
|
+
if (/^on/i.test(attribute.name) || /^javascript:/i.test(attribute.value) || /^data:text\/html/i.test(attribute.value)) {
|
|
2652
|
+
element.removeAttribute(attribute.name);
|
|
2653
|
+
}
|
|
2654
|
+
});
|
|
2655
|
+
});
|
|
2656
|
+
return parsed.body.innerHTML;
|
|
2657
|
+
} catch (error) {
|
|
2658
|
+
return '';
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2592
2662
|
function basename(path) {
|
|
2593
2663
|
if (!path) return '';
|
|
2594
2664
|
var clean = path.replace(/[\\/]+$/, '');
|
|
@@ -2938,6 +3008,35 @@ function fileIconSpec(name, isDir) {
|
|
|
2938
3008
|
'.rw-content-pane{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0}',
|
|
2939
3009
|
'.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
3010
|
'.rw-pane-resizer{flex:0 0 6px;align-self:stretch;cursor:col-resize;background:transparent;position:relative;z-index:2;touch-action:none}',
|
|
3011
|
+
'.rw-doc{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;min-width:0;background:var(--dsw-alias-bg-base)}',
|
|
3012
|
+
'.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}',
|
|
3013
|
+
'.rw-doc-body{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;overflow:auto;padding:10px}',
|
|
3014
|
+
'.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}',
|
|
3015
|
+
'.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}',
|
|
3016
|
+
'.rw-doc-preview h1,.rw-doc-preview h2,.rw-doc-preview h3{line-height:1.3;margin:1em 0 .5em}',
|
|
3017
|
+
'.rw-doc-preview pre{background:var(--dsw-alias-bg-layer-2);padding:10px;border-radius:8px;overflow:auto}',
|
|
3018
|
+
'.rw-doc-preview table{border-collapse:collapse;margin:.8em 0}',
|
|
3019
|
+
'.rw-doc-preview th,.rw-doc-preview td{border:1px solid var(--dsw-alias-border-l2);padding:5px 8px}',
|
|
3020
|
+
'.rw-doc-preview img{max-width:100%}',
|
|
3021
|
+
'.rw-doc-preview blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:0;padding-left:10px;color:var(--dsw-alias-label-secondary)}',
|
|
3022
|
+
'.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}',
|
|
3023
|
+
'.rw-doc-field{display:flex;align-items:center;gap:8px;margin:6px 0}',
|
|
3024
|
+
'.rw-doc-field label{font-size:11.5px;color:var(--dsw-alias-label-tertiary);min-width:38px}',
|
|
3025
|
+
'.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}',
|
|
3026
|
+
'.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)}',
|
|
3027
|
+
'.rw-doc-block-label{font-size:10px;line-height:15px;color:var(--dsw-alias-label-tertiary);margin-bottom:3px}',
|
|
3028
|
+
'.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}',
|
|
3029
|
+
'.rw-doc-table{border-collapse:collapse;width:100%;margin:0 0 10px;font-size:12px}',
|
|
3030
|
+
'.rw-doc-table td{border:1px solid var(--dsw-alias-border-l2);padding:0}',
|
|
3031
|
+
'.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}',
|
|
3032
|
+
'.rw-doc-tabs{display:flex;gap:3px;padding:5px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow:auto;flex:none}',
|
|
3033
|
+
'.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}',
|
|
3034
|
+
'.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)}',
|
|
3035
|
+
'.rw-doc-grid{width:100%;border-collapse:collapse;font-size:11.5px}',
|
|
3036
|
+
'.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}',
|
|
3037
|
+
'.rw-doc-grid td{border:1px solid var(--dsw-alias-border-l1);padding:0;min-width:60px}',
|
|
3038
|
+
'.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}',
|
|
3039
|
+
'.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
3040
|
'.rw-pane-resizer:hover,.rw-pane-resizer:active{background:var(--dsw-alias-brand-primary)}',
|
|
2942
3041
|
'.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
3042
|
'.rw-directory-body{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}',
|
|
@@ -4319,6 +4418,243 @@ function fileIconSpec(name, isDir) {
|
|
|
4319
4418
|
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('close'))));
|
|
4320
4419
|
}
|
|
4321
4420
|
|
|
4421
|
+
function DocumentPane(props) {
|
|
4422
|
+
var ctx = props.ctx;
|
|
4423
|
+
var t = props.t;
|
|
4424
|
+
var locale = props.locale;
|
|
4425
|
+
var scope = props.scope;
|
|
4426
|
+
var path = props.path;
|
|
4427
|
+
var local = props.local === true;
|
|
4428
|
+
var onSaved = props.onSaved;
|
|
4429
|
+
var kind = documentKindOf(path);
|
|
4430
|
+
var stateRef = useState({
|
|
4431
|
+
loading: true, error: '', data: null, text: '', dirty: false, saving: false,
|
|
4432
|
+
preview: false, html: '', previewLoading: false, page: 0, sheet: 0, slide: 0,
|
|
4433
|
+
updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, notice: '',
|
|
4434
|
+
});
|
|
4435
|
+
var state = stateRef[0];
|
|
4436
|
+
var setState = stateRef[1];
|
|
4437
|
+
|
|
4438
|
+
function patch(next) {
|
|
4439
|
+
setState(function (previous) { return Object.assign({}, previous, next); });
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
function params(extra) {
|
|
4443
|
+
var base = local ? { path: path } : Object.assign({ path: path }, scopeParams(scope));
|
|
4444
|
+
return Object.assign(base, extra || {});
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
function load() {
|
|
4448
|
+
patch({ loading: true, error: '', notice: '' });
|
|
4449
|
+
apiCall('docs.read', params()).then(function (result) {
|
|
4450
|
+
patch({
|
|
4451
|
+
loading: false, error: '', data: result,
|
|
4452
|
+
text: result.kind === 'markdown' ? String(result.text || '') : '',
|
|
4453
|
+
preview: false, html: '', page: 0, sheet: 0, slide: 0,
|
|
4454
|
+
updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, dirty: false,
|
|
4455
|
+
});
|
|
4456
|
+
}).catch(function (error) {
|
|
4457
|
+
patch({ loading: false, error: messageOf(error, t, locale) });
|
|
4458
|
+
});
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
useEffect(function () { load(); }, [path, local, scope && scope.kind, scope && scope.id]);
|
|
4462
|
+
|
|
4463
|
+
useEffect(function () {
|
|
4464
|
+
if (typeof props.onDirtyChange === 'function') props.onDirtyChange(state.dirty);
|
|
4465
|
+
}, [state.dirty]);
|
|
4466
|
+
|
|
4467
|
+
function save() {
|
|
4468
|
+
if (state.saving) return;
|
|
4469
|
+
patch({ saving: true, error: '', notice: '' });
|
|
4470
|
+
var data = {};
|
|
4471
|
+
if (kind === 'markdown') data = { text: state.text };
|
|
4472
|
+
if (kind === 'docx' && state.data) data = { paragraphs: state.data.paragraphs || [], tables: state.data.tables || [] };
|
|
4473
|
+
if (kind === 'xlsx') data = { updates: Object.keys(state.updates).map(function (key) { return state.updates[key]; }) };
|
|
4474
|
+
if (kind === 'pptx') data = { updates: Object.keys(state.pptxUpdates).map(function (key) { return state.pptxUpdates[key]; }) };
|
|
4475
|
+
if (kind === 'pdf' && state.data) {
|
|
4476
|
+
data = {
|
|
4477
|
+
metadata: state.data.metadata || {},
|
|
4478
|
+
rotations: Object.keys(state.rotations).map(function (index) { return { index: Number(index), degrees: state.rotations[index] }; }),
|
|
4479
|
+
deleted: Object.keys(state.deleted).filter(function (index) { return state.deleted[index]; }).map(Number),
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function () {
|
|
4483
|
+
patch({ saving: false, dirty: false, notice: t('saved') });
|
|
4484
|
+
if (onSaved) onSaved();
|
|
4485
|
+
load();
|
|
4486
|
+
}).catch(function (error) {
|
|
4487
|
+
patch({ saving: false, error: messageOf(error, t, locale) });
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
function showPreview() {
|
|
4492
|
+
var next = !state.preview;
|
|
4493
|
+
patch({ preview: next, previewLoading: next && !state.html });
|
|
4494
|
+
if (!next || state.html) return;
|
|
4495
|
+
apiCall('docs.mdPreview', params({ text: state.text })).then(function (result) {
|
|
4496
|
+
patch({ previewLoading: false, html: sanitizeDocumentHtml(result.html) });
|
|
4497
|
+
}).catch(function (error) {
|
|
4498
|
+
patch({ previewLoading: false, error: messageOf(error, t, locale) });
|
|
4499
|
+
});
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
function exportPdf() {
|
|
4503
|
+
patch({ saving: true, error: '', notice: '' });
|
|
4504
|
+
apiCall('docs.mdPdf', params({ text: state.text })).then(function (result) {
|
|
4505
|
+
patch({ saving: false, notice: t('docExportDone', { path: result.path }) });
|
|
4506
|
+
if (onSaved) onSaved();
|
|
4507
|
+
}).catch(function (error) {
|
|
4508
|
+
patch({ saving: false, error: messageOf(error, t, locale) });
|
|
4509
|
+
});
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
function updateParagraph(index, value) {
|
|
4513
|
+
var data = state.data || {};
|
|
4514
|
+
var paragraphs = (data.paragraphs || []).slice();
|
|
4515
|
+
paragraphs[index] = Object.assign({}, paragraphs[index], { text: value });
|
|
4516
|
+
patch({ data: Object.assign({}, data, { paragraphs: paragraphs }), dirty: true });
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
function updateTableCell(tableIndex, rowIndex, cellIndex, value) {
|
|
4520
|
+
var data = state.data || {};
|
|
4521
|
+
var tables = (data.tables || []).map(function (rows) { return rows.map(function (row) { return row.slice(); }); });
|
|
4522
|
+
if (!tables[tableIndex] || !tables[tableIndex][rowIndex]) return;
|
|
4523
|
+
tables[tableIndex][rowIndex][cellIndex] = value;
|
|
4524
|
+
patch({ data: Object.assign({}, data, { tables: tables }), dirty: true });
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
function updateCell(sheetName, cell, value) {
|
|
4528
|
+
var key = sheetName + '!' + cell;
|
|
4529
|
+
var updates = Object.assign({}, state.updates);
|
|
4530
|
+
updates[key] = { sheet: sheetName, cell: cell, value: value };
|
|
4531
|
+
patch({ updates: updates, dirty: true });
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
function updateSlideText(slideIndex, shapeIndex, value) {
|
|
4535
|
+
var key = slideIndex + ':' + shapeIndex;
|
|
4536
|
+
var updates = Object.assign({}, state.pptxUpdates);
|
|
4537
|
+
updates[key] = { slide: slideIndex, shape: shapeIndex, text: value };
|
|
4538
|
+
patch({ pptxUpdates: updates, dirty: true });
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
var toolbar = h('div', { className: 'rw-doc-toolbar' },
|
|
4542
|
+
h('span', { className: 'rw-chip' }, kind.toUpperCase()),
|
|
4543
|
+
h('span', { className: 'rw-hint', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: path }, path),
|
|
4544
|
+
kind === 'markdown' ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: showPreview }, state.preview ? t('docEdit') : t('docPreview')) : null,
|
|
4545
|
+
kind === 'markdown' ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: exportPdf, disabled: state.saving }, IconDownload(12), t('exportPdf')) : null,
|
|
4546
|
+
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')));
|
|
4547
|
+
|
|
4548
|
+
if (state.loading) {
|
|
4549
|
+
return h('div', { className: 'rw-doc' }, toolbar, h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
|
|
4550
|
+
}
|
|
4551
|
+
if (state.error && !state.data) {
|
|
4552
|
+
return h('div', { className: 'rw-doc' }, toolbar,
|
|
4553
|
+
h('div', { className: 'rw-editor-message' },
|
|
4554
|
+
h(Banner, { kind: 'error' }, state.error),
|
|
4555
|
+
h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('reload'))));
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
var body = null;
|
|
4559
|
+
if (kind === 'markdown') {
|
|
4560
|
+
body = state.preview
|
|
4561
|
+
? h('div', { className: 'rw-doc-body' },
|
|
4562
|
+
state.previewLoading ? h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')) : null,
|
|
4563
|
+
state.html ? h('div', { className: 'rw-doc-preview', dangerouslySetInnerHTML: { __html: state.html } }) : null)
|
|
4564
|
+
: h('div', { className: 'rw-doc-body' },
|
|
4565
|
+
h('textarea', {
|
|
4566
|
+
className: 'rw-doc-textarea', value: state.text, spellCheck: false,
|
|
4567
|
+
onChange: function (event) { patch({ text: event.currentTarget.value, dirty: true, html: '' }); },
|
|
4568
|
+
}));
|
|
4569
|
+
} else if (kind === 'pdf' && state.data) {
|
|
4570
|
+
var pages = state.data.pages || [];
|
|
4571
|
+
var visible = pages.filter(function (page) { return !state.deleted[page.index]; });
|
|
4572
|
+
var current = visible[Math.min(state.page, Math.max(visible.length - 1, 0))] || null;
|
|
4573
|
+
body = h('div', { className: 'rw-doc-body' },
|
|
4574
|
+
h('div', { className: 'rw-doc-field' },
|
|
4575
|
+
h('label', null, t('docPdfTitle')),
|
|
4576
|
+
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 }); } }),
|
|
4577
|
+
h('label', null, t('docPdfAuthor')),
|
|
4578
|
+
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 }); } })),
|
|
4579
|
+
h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', margin: '6px 0' } },
|
|
4580
|
+
h('div', { className: 'rw-inline' },
|
|
4581
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { patch({ page: state.page - 1 }); } }, '‹'),
|
|
4582
|
+
h('span', { className: 'rw-hint' }, current ? t('docPageOf', { page: current.index + 1, total: state.data.pageCount }) : ''),
|
|
4583
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= visible.length - 1, onClick: function () { patch({ page: state.page + 1 }); } }, '›')),
|
|
4584
|
+
h('div', { className: 'rw-inline' },
|
|
4585
|
+
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')),
|
|
4586
|
+
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')))),
|
|
4587
|
+
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')));
|
|
4588
|
+
} else if (kind === 'docx' && state.data) {
|
|
4589
|
+
var paragraphs = state.data.paragraphs || [];
|
|
4590
|
+
var tables = state.data.tables || [];
|
|
4591
|
+
body = h('div', { className: 'rw-doc-body' },
|
|
4592
|
+
paragraphs.map(function (paragraph, index) {
|
|
4593
|
+
return h('div', { key: 'p' + index, className: 'rw-doc-block' },
|
|
4594
|
+
h('div', { className: 'rw-doc-block-label' }, (paragraph.style || t('docParagraph')) + ' · ' + (index + 1)),
|
|
4595
|
+
h('textarea', { value: paragraph.text || '', onChange: function (event) { updateParagraph(index, event.currentTarget.value); } }));
|
|
4596
|
+
}),
|
|
4597
|
+
tables.map(function (rows, tableIndex) {
|
|
4598
|
+
return h('div', { key: 't' + tableIndex },
|
|
4599
|
+
h('div', { className: 'rw-doc-block-label' }, t('docTable') + ' ' + (tableIndex + 1)),
|
|
4600
|
+
h('table', { className: 'rw-doc-table' }, h('tbody', null,
|
|
4601
|
+
rows.map(function (row, rowIndex) {
|
|
4602
|
+
return h('tr', { key: rowIndex }, row.map(function (cell, cellIndex) {
|
|
4603
|
+
return h('td', { key: cellIndex }, h('input', { value: cell || '', onChange: function (event) { updateTableCell(tableIndex, rowIndex, cellIndex, event.currentTarget.value); } }));
|
|
4604
|
+
}));
|
|
4605
|
+
}))));
|
|
4606
|
+
}));
|
|
4607
|
+
} else if (kind === 'xlsx' && state.data) {
|
|
4608
|
+
var sheets = state.data.sheets || [];
|
|
4609
|
+
var active = sheets[Math.min(state.sheet, Math.max(sheets.length - 1, 0))] || { name: '', rows: [] };
|
|
4610
|
+
var columns = Math.max(active.maxCol || 0, 8);
|
|
4611
|
+
var header = h('tr', null, h('th', null, ''), Array.from({ length: columns }, function (_, index) { return h('th', { key: index }, String.fromCharCode(65 + index)); }));
|
|
4612
|
+
var gridRows = (active.rows || []).map(function (row, rowIndex) {
|
|
4613
|
+
return h('tr', { key: rowIndex },
|
|
4614
|
+
h('th', null, String(rowIndex + 1)),
|
|
4615
|
+
Array.from({ length: columns }, function (_, colIndex) {
|
|
4616
|
+
var cell = String.fromCharCode(65 + colIndex) + (rowIndex + 1);
|
|
4617
|
+
var value = row[colIndex];
|
|
4618
|
+
return h('td', { key: colIndex }, h('input', {
|
|
4619
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
4620
|
+
onChange: function (event) { updateCell(active.name, cell, event.currentTarget.value); },
|
|
4621
|
+
}));
|
|
4622
|
+
}));
|
|
4623
|
+
});
|
|
4624
|
+
body = h('div', { className: 'rw-doc-body', style: { padding: 0, overflow: 'hidden' } },
|
|
4625
|
+
h('div', { className: 'rw-doc-tabs' }, sheets.map(function (sheet, index) {
|
|
4626
|
+
return h('button', { key: sheet.name, type: 'button', className: 'rw-doc-tab' + (index === state.sheet ? ' is-on' : ''), onClick: function () { patch({ sheet: index }); } }, sheet.name);
|
|
4627
|
+
})),
|
|
4628
|
+
h('div', { style: { flex: '1 1 auto', overflow: 'auto', padding: 8 } },
|
|
4629
|
+
h('table', { className: 'rw-doc-grid' },
|
|
4630
|
+
h('thead', null, header),
|
|
4631
|
+
h('tbody', null, gridRows))));
|
|
4632
|
+
} else if (kind === 'pptx' && state.data) {
|
|
4633
|
+
var slides = state.data.slides || [];
|
|
4634
|
+
var slide = slides[Math.min(state.slide, Math.max(slides.length - 1, 0))] || { texts: [] };
|
|
4635
|
+
body = h('div', { className: 'rw-doc-body' },
|
|
4636
|
+
h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', marginBottom: 6 } },
|
|
4637
|
+
h('div', { className: 'rw-inline' },
|
|
4638
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.slide <= 0, onClick: function () { patch({ slide: state.slide - 1 }); } }, '‹'),
|
|
4639
|
+
h('span', { className: 'rw-hint' }, (state.slide + 1) + ' / ' + slides.length),
|
|
4640
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.slide >= slides.length - 1, onClick: function () { patch({ slide: state.slide + 1 }); } }, '›'))),
|
|
4641
|
+
(slide.texts || []).map(function (item) {
|
|
4642
|
+
return h('div', { key: item.shape, className: 'rw-doc-block' },
|
|
4643
|
+
h('div', { className: 'rw-doc-block-label' }, (item.name || t('docSlide')) + ' · ' + item.shape),
|
|
4644
|
+
h('textarea', { value: (state.pptxUpdates[state.slide + ':' + item.shape] || item).text || '', onChange: function (event) { updateSlideText(state.slide, item.shape, event.currentTarget.value); } }));
|
|
4645
|
+
}));
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
return h('div', { className: 'rw-doc' }, toolbar,
|
|
4649
|
+
state.notice ? h('div', { style: { padding: '6px 10px 0' } }, h(Banner, { kind: 'success' }, state.notice)) : null,
|
|
4650
|
+
state.error ? h('div', { style: { padding: '6px 10px 0' } }, h(Banner, { kind: 'error' }, state.error)) : null,
|
|
4651
|
+
body,
|
|
4652
|
+
h('div', { className: 'rw-doc-status' },
|
|
4653
|
+
h('span', null, kind.toUpperCase()),
|
|
4654
|
+
h('span', null, path),
|
|
4655
|
+
state.dirty ? h('span', { className: 'rw-status-chip rw-status-warn' }, t('dirty')) : null));
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4322
4658
|
function RemoteDirectoryTab(props) {
|
|
4323
4659
|
var ctx = props.ctx;
|
|
4324
4660
|
var t = props.t;
|
|
@@ -4734,19 +5070,25 @@ function fileIconSpec(name, isDir) {
|
|
|
4734
5070
|
|
|
4735
5071
|
function submitNew(side, dirPath, name, isDir) {
|
|
4736
5072
|
var target = joinChildPath(dirPath, name, side);
|
|
5073
|
+
var docKind = documentKindOf(name);
|
|
4737
5074
|
var promise;
|
|
4738
|
-
if (
|
|
4739
|
-
promise =
|
|
5075
|
+
if (isDir) {
|
|
5076
|
+
promise = side === 'remote'
|
|
4740
5077
|
? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
|
|
4741
|
-
: apiCall('
|
|
5078
|
+
: apiCall('local.mkdir', { path: dirPath, name: name });
|
|
5079
|
+
} else if (docKind) {
|
|
5080
|
+
promise = side === 'remote'
|
|
5081
|
+
? apiCall('docs.create', Object.assign({ path: target, kind: docKind }, scopeParams(scopeRef)))
|
|
5082
|
+
: apiCall('docs.create', { path: target, kind: docKind });
|
|
4742
5083
|
} else {
|
|
4743
|
-
promise =
|
|
4744
|
-
? apiCall('
|
|
5084
|
+
promise = side === 'remote'
|
|
5085
|
+
? apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)))
|
|
4745
5086
|
: apiCall('local.newFile', { path: target });
|
|
4746
5087
|
}
|
|
4747
5088
|
promise.then(function () {
|
|
4748
5089
|
showNotice('success', t('newDone'));
|
|
4749
5090
|
refreshTree();
|
|
5091
|
+
if (!isDir && docKind) openInternalFile(side, target);
|
|
4750
5092
|
}).catch(noticeError);
|
|
4751
5093
|
}
|
|
4752
5094
|
|
|
@@ -4997,8 +5339,25 @@ function fileIconSpec(name, isDir) {
|
|
|
4997
5339
|
}
|
|
4998
5340
|
|
|
4999
5341
|
var activeFile = openFiles.filter(function (file) { return file.key === activeFileKey; })[0] || null;
|
|
5342
|
+
var activeDocKind = activeFile ? documentKindOf(activeFile.name) : '';
|
|
5000
5343
|
var contentArea = activeFile
|
|
5001
|
-
?
|
|
5344
|
+
? (activeDocKind
|
|
5345
|
+
? h(DocumentPane, {
|
|
5346
|
+
ctx: ctx, t: t, locale: locale,
|
|
5347
|
+
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
|
5348
|
+
path: activeFile.path,
|
|
5349
|
+
local: activeFile.side === 'local',
|
|
5350
|
+
onSaved: refreshTree,
|
|
5351
|
+
onDirtyChange: function (dirty) {
|
|
5352
|
+
setFileDirty(function (previous) {
|
|
5353
|
+
if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
|
|
5354
|
+
var next = Object.assign({}, previous);
|
|
5355
|
+
next[activeFile.key] = Boolean(dirty);
|
|
5356
|
+
return next;
|
|
5357
|
+
});
|
|
5358
|
+
},
|
|
5359
|
+
})
|
|
5360
|
+
: h(RemoteFileEditor, {
|
|
5002
5361
|
ctx: ctx, t: t, locale: locale,
|
|
5003
5362
|
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
|
5004
5363
|
path: activeFile.path,
|
|
@@ -5013,7 +5372,7 @@ function fileIconSpec(name, isDir) {
|
|
|
5013
5372
|
return next;
|
|
5014
5373
|
});
|
|
5015
5374
|
},
|
|
5016
|
-
})
|
|
5375
|
+
}))
|
|
5017
5376
|
: h('div', { className: 'rw-placeholder' },
|
|
5018
5377
|
h('span', { style: { display: 'inline-flex', opacity: 0.7 } }, IconFolder(30, false)),
|
|
5019
5378
|
h('div', { className: 'rw-placeholder-title' }, t('openFileTitle')),
|
|
@@ -6258,6 +6617,25 @@ function fileIconSpec(name, isDir) {
|
|
|
6258
6617
|
remoteTabDisposer = null;
|
|
6259
6618
|
}
|
|
6260
6619
|
|
|
6620
|
+
function activeSessionClientInfo(sessionId) {
|
|
6621
|
+
if (!sessionId) return null;
|
|
6622
|
+
try {
|
|
6623
|
+
var sessions = ctx.sessions || (ctx.get && ctx.get('sessions'));
|
|
6624
|
+
var list = sessions && sessions.list;
|
|
6625
|
+
var snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
|
|
6626
|
+
var record = snapshot && snapshot.byId ? snapshot.byId[sessionId] : null;
|
|
6627
|
+
if (!record) return null;
|
|
6628
|
+
var header = record.header || record;
|
|
6629
|
+
return { cwd: header.cwd || record.cwd || '', workspaceId: header.workspaceId || record.workspaceId || '' };
|
|
6630
|
+
} catch (error) {
|
|
6631
|
+
return null;
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
|
|
6635
|
+
function isLinkedSessionInfo(info) {
|
|
6636
|
+
return Boolean(info && ((info.cwd && isLinkedScope({ cwd: info.cwd })) || isLinkedWorkspaceId(info.workspaceId)));
|
|
6637
|
+
}
|
|
6638
|
+
|
|
6261
6639
|
function syncDirectoryTabs() {
|
|
6262
6640
|
if (syncDirectoryTabsInFlight) return;
|
|
6263
6641
|
syncDirectoryTabsInFlight = true;
|
|
@@ -6270,8 +6648,13 @@ function fileIconSpec(name, isDir) {
|
|
|
6270
6648
|
disposeLinkedTab();
|
|
6271
6649
|
return undefined;
|
|
6272
6650
|
}
|
|
6651
|
+
var clientInfo = activeSessionClientInfo(sessionId);
|
|
6652
|
+
if (isLinkedSessionInfo(clientInfo)) {
|
|
6653
|
+
installLinkedTab();
|
|
6654
|
+
return undefined;
|
|
6655
|
+
}
|
|
6273
6656
|
return apiCall('session.info', { sessionId: sessionId }).then(function (info) {
|
|
6274
|
-
if (
|
|
6657
|
+
if (isLinkedSessionInfo(info)) installLinkedTab();
|
|
6275
6658
|
else disposeLinkedTab();
|
|
6276
6659
|
}).catch(function () { disposeLinkedTab(); });
|
|
6277
6660
|
}).catch(function () { /* ignore */ }).then(function () { syncDirectoryTabsInFlight = false; });
|
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.1",
|
|
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()
|