regen.mde 0.2.2 → 0.7.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/README.md +409 -295
- package/bin/build-corpus-editor.js +5 -3
- package/bin/postinstall.js +259 -187
- package/bin/regen-mdeditor-install.js +1 -1
- package/bin/regen-mdeditor-uninstall.js +1 -1
- package/desktop/BuildCorpusEditor/BuildCorpusBridge.cs +493 -270
- package/desktop/BuildCorpusEditor/EditorForm.cs +853 -540
- package/desktop/BuildCorpusEditor/Program.cs +85 -81
- package/dist/release/regen-mde-0.3.0-win-x64-setup.exe +0 -0
- package/dist/release/{regen.mde-0.2.2-win-x64.zip → regen-mde-0.3.0-win-x64.zip} +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64-setup.exe +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64.zip +0 -0
- package/dist/windows-editor/BuildCorpusEditor.dll +0 -0
- package/dist/windows-editor/BuildCorpusEditor.exe +0 -0
- package/dist/windows-editor/BuildCorpusEditor.pdb +0 -0
- package/dist/windows-editor/wwwroot/assets/index-C_VxJk4k.js +375 -0
- package/dist/windows-editor/wwwroot/assets/index-Wt9zSjIw.css +1 -0
- package/dist/windows-editor/wwwroot/index.html +3 -3
- package/editor-web/index.html +1 -1
- package/editor-web/src/main.jsx +1044 -399
- package/editor-web/src/styles.css +846 -602
- package/installer/install-regen-mde.ps1 +49 -10
- package/installer/regen-mde.nsi +16 -16
- package/package.json +90 -86
- package/pyproject.toml +35 -33
- package/requirements.txt +6 -4
- package/scripts/package-windows-editor.ps1 +8 -8
- package/scripts/release-dual.mjs +105 -0
- package/scripts/run-editor-implementation-plane.ps1 +29 -6
- package/src/build_corpus/docx_exporter.py +1055 -798
- package/src/build_corpus/equations.py +80 -0
- package/src/build_corpus/exporter.py +1488 -1195
- package/src/build_corpus/frontmatter.py +302 -0
- package/src/build_corpus/ppt_exporter.py +543 -532
- package/dist/release/regen.mde-0.2.2-win-x64-setup.exe +0 -0
- package/dist/windows-editor/wwwroot/assets/index-DjJ6xmhy.js +0 -326
- package/dist/windows-editor/wwwroot/assets/index-_dwMNNsm.css +0 -1
|
@@ -1,270 +1,493 @@
|
|
|
1
|
-
using System.Diagnostics;
|
|
2
|
-
using System.Text.Json;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
private readonly string
|
|
10
|
-
private readonly string
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return new
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
var
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
var
|
|
129
|
-
|
|
130
|
-
var
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return new
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
1
|
+
using System.Diagnostics;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
using System.Text.RegularExpressions;
|
|
4
|
+
|
|
5
|
+
namespace BuildCorpusEditor;
|
|
6
|
+
|
|
7
|
+
internal sealed class BuildCorpusBridge
|
|
8
|
+
{
|
|
9
|
+
private readonly string? initialPath;
|
|
10
|
+
private readonly string root;
|
|
11
|
+
private readonly string sessionRoot;
|
|
12
|
+
private readonly bool composeMode;
|
|
13
|
+
private readonly string? paneId;
|
|
14
|
+
private readonly string composeDropDir;
|
|
15
|
+
|
|
16
|
+
public BuildCorpusBridge(string? initialPath, bool composeMode = false, string? paneId = null)
|
|
17
|
+
{
|
|
18
|
+
this.initialPath = string.IsNullOrWhiteSpace(initialPath) ? null : Path.GetFullPath(initialPath);
|
|
19
|
+
this.composeMode = composeMode;
|
|
20
|
+
this.paneId = string.IsNullOrWhiteSpace(paneId) ? null : paneId.Trim();
|
|
21
|
+
root = LocateRoot();
|
|
22
|
+
sessionRoot = Path.Combine(Path.GetTempPath(), "build-corpus-editor");
|
|
23
|
+
Directory.CreateDirectory(sessionRoot);
|
|
24
|
+
composeDropDir = Path.Combine(
|
|
25
|
+
Path.GetTempPath(),
|
|
26
|
+
"regen-mde-compose",
|
|
27
|
+
DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public StartupResult Startup() => new(initialPath, composeMode, paneId);
|
|
31
|
+
|
|
32
|
+
public ComposeInfoResult ComposeInfo() => new(composeMode, paneId, composeDropDir);
|
|
33
|
+
|
|
34
|
+
// Save a dropped file (image or any file) to the per-session compose temp folder
|
|
35
|
+
// and return the absolute local path so the caller can insert it as plain text.
|
|
36
|
+
// This is the workaround for not being able to drag-drop images into a terminal TUI.
|
|
37
|
+
public DroppedFileResult SaveDroppedFile(string fileName, string? mimeType, string base64Data)
|
|
38
|
+
{
|
|
39
|
+
Directory.CreateDirectory(composeDropDir);
|
|
40
|
+
var safeName = MakeSafeFileName(fileName);
|
|
41
|
+
var target = Path.Combine(composeDropDir, safeName);
|
|
42
|
+
// De-duplicate if a file with the same name already exists this session.
|
|
43
|
+
if (File.Exists(target))
|
|
44
|
+
{
|
|
45
|
+
var stem = Path.GetFileNameWithoutExtension(safeName);
|
|
46
|
+
var ext = Path.GetExtension(safeName);
|
|
47
|
+
target = Path.Combine(composeDropDir, $"{stem}-{Guid.NewGuid().ToString("N")[..6]}{ext}");
|
|
48
|
+
}
|
|
49
|
+
var bytes = Convert.FromBase64String(StripDataUriPrefix(base64Data));
|
|
50
|
+
File.WriteAllBytes(target, bytes);
|
|
51
|
+
var isImage = (mimeType ?? "").StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
|
52
|
+
|| Path.GetExtension(target).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".bmp" or ".svg";
|
|
53
|
+
return new DroppedFileResult(target, isImage, bytes.LongLength);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Write the composed plain-text to a temp file and bracket-paste it into the
|
|
57
|
+
// captured WezTerm pane via `wezterm cli send-text`. Does NOT press Enter —
|
|
58
|
+
// the user reviews and submits in the terminal.
|
|
59
|
+
public SendToPaneResult SendToPane(string content, string? overridePaneId)
|
|
60
|
+
{
|
|
61
|
+
var targetPane = string.IsNullOrWhiteSpace(overridePaneId) ? paneId : overridePaneId.Trim();
|
|
62
|
+
if (string.IsNullOrWhiteSpace(targetPane))
|
|
63
|
+
{
|
|
64
|
+
throw new InvalidOperationException("No WezTerm pane id was provided; cannot send text.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Directory.CreateDirectory(composeDropDir);
|
|
68
|
+
var stampPath = Path.Combine(composeDropDir, $"prompt-{DateTimeOffset.UtcNow:HHmmss}-{Guid.NewGuid().ToString("N")[..6]}.txt");
|
|
69
|
+
// Persist exactly what we send, as plain UTF-8 (no BOM), for audit/recovery.
|
|
70
|
+
File.WriteAllText(stampPath, content ?? "", new System.Text.UTF8Encoding(false));
|
|
71
|
+
|
|
72
|
+
var wezterm = LocateWezTerm();
|
|
73
|
+
var psi = new ProcessStartInfo
|
|
74
|
+
{
|
|
75
|
+
FileName = wezterm,
|
|
76
|
+
RedirectStandardInput = true,
|
|
77
|
+
RedirectStandardOutput = true,
|
|
78
|
+
RedirectStandardError = true,
|
|
79
|
+
UseShellExecute = false,
|
|
80
|
+
StandardInputEncoding = new System.Text.UTF8Encoding(false),
|
|
81
|
+
};
|
|
82
|
+
psi.ArgumentList.Add("cli");
|
|
83
|
+
psi.ArgumentList.Add("send-text");
|
|
84
|
+
// --no-paste would send raw; default uses bracketed paste when the app
|
|
85
|
+
// has it enabled, letting the TUI treat it as a single pasted block.
|
|
86
|
+
psi.ArgumentList.Add("--pane-id");
|
|
87
|
+
psi.ArgumentList.Add(targetPane);
|
|
88
|
+
|
|
89
|
+
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Could not start wezterm CLI.");
|
|
90
|
+
process.StandardInput.Write(content ?? "");
|
|
91
|
+
process.StandardInput.Close();
|
|
92
|
+
var stderr = process.StandardError.ReadToEnd();
|
|
93
|
+
process.WaitForExit();
|
|
94
|
+
if (process.ExitCode != 0)
|
|
95
|
+
{
|
|
96
|
+
throw new InvalidOperationException(
|
|
97
|
+
string.IsNullOrWhiteSpace(stderr) ? $"wezterm send-text exited {process.ExitCode}" : stderr.Trim());
|
|
98
|
+
}
|
|
99
|
+
return new SendToPaneResult(targetPane, stampPath, (content ?? "").Length);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static string MakeSafeFileName(string? name)
|
|
103
|
+
{
|
|
104
|
+
var parsed = string.IsNullOrWhiteSpace(name) ? "drop.bin" : Path.GetFileName(name);
|
|
105
|
+
var stem = Path.GetFileNameWithoutExtension(parsed);
|
|
106
|
+
var ext = Path.GetExtension(parsed);
|
|
107
|
+
var invalid = Path.GetInvalidFileNameChars();
|
|
108
|
+
stem = new string((stem ?? "drop").Where(c => !invalid.Contains(c)).ToArray());
|
|
109
|
+
if (stem.Length == 0) stem = "drop";
|
|
110
|
+
if (stem.Length > 80) stem = stem[..80];
|
|
111
|
+
ext = new string((ext ?? "").Where(c => !invalid.Contains(c)).ToArray());
|
|
112
|
+
return stem + ext;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private static string StripDataUriPrefix(string data)
|
|
116
|
+
{
|
|
117
|
+
if (string.IsNullOrEmpty(data)) return "";
|
|
118
|
+
var comma = data.IndexOf(',');
|
|
119
|
+
if (data.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
|
|
120
|
+
{
|
|
121
|
+
return data[(comma + 1)..];
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static string LocateWezTerm()
|
|
127
|
+
{
|
|
128
|
+
var env = Environment.GetEnvironmentVariable("WEZTERM_EXECUTABLE");
|
|
129
|
+
if (!string.IsNullOrWhiteSpace(env) && File.Exists(env)) return env;
|
|
130
|
+
var candidates = new[]
|
|
131
|
+
{
|
|
132
|
+
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WezTerm", "wezterm.exe"),
|
|
133
|
+
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "WezTerm", "wezterm.exe"),
|
|
134
|
+
};
|
|
135
|
+
foreach (var candidate in candidates)
|
|
136
|
+
{
|
|
137
|
+
if (File.Exists(candidate)) return candidate;
|
|
138
|
+
}
|
|
139
|
+
// Fall back to PATH resolution.
|
|
140
|
+
return "wezterm.exe";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public object? ChooseOpen()
|
|
144
|
+
{
|
|
145
|
+
using var dialog = new OpenFileDialog
|
|
146
|
+
{
|
|
147
|
+
Title = "Open Markdown, Word, or PowerPoint",
|
|
148
|
+
Filter = "Supported documents|*.md;*.markdown;*.docx;*.pptx;*.ppt|Markdown|*.md;*.markdown|Word|*.docx|PowerPoint|*.pptx;*.ppt|All files|*.*",
|
|
149
|
+
CheckFileExists = true,
|
|
150
|
+
};
|
|
151
|
+
return dialog.ShowDialog() == DialogResult.OK ? new { path = dialog.FileName } : new { path = "" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public object? ChooseFolder()
|
|
155
|
+
{
|
|
156
|
+
using var dialog = new FolderBrowserDialog
|
|
157
|
+
{
|
|
158
|
+
Description = "Choose a folder of Word or PowerPoint files",
|
|
159
|
+
UseDescriptionForTitle = true,
|
|
160
|
+
ShowNewFolderButton = false,
|
|
161
|
+
};
|
|
162
|
+
return dialog.ShowDialog() == DialogResult.OK ? new { path = dialog.SelectedPath } : new { path = "" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public async Task<object?> ChooseImageAsync()
|
|
166
|
+
{
|
|
167
|
+
using var dialog = new OpenFileDialog
|
|
168
|
+
{
|
|
169
|
+
Title = "Insert Image",
|
|
170
|
+
Filter = "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp|All files|*.*",
|
|
171
|
+
CheckFileExists = true,
|
|
172
|
+
};
|
|
173
|
+
if (dialog.ShowDialog() != DialogResult.OK) return new { src = "", alt = "" };
|
|
174
|
+
|
|
175
|
+
var bytes = await File.ReadAllBytesAsync(dialog.FileName);
|
|
176
|
+
var extension = Path.GetExtension(dialog.FileName).ToLowerInvariant();
|
|
177
|
+
var mime = extension switch
|
|
178
|
+
{
|
|
179
|
+
".jpg" or ".jpeg" => "image/jpeg",
|
|
180
|
+
".gif" => "image/gif",
|
|
181
|
+
".webp" => "image/webp",
|
|
182
|
+
".bmp" => "image/bmp",
|
|
183
|
+
_ => "image/png",
|
|
184
|
+
};
|
|
185
|
+
return new
|
|
186
|
+
{
|
|
187
|
+
src = $"data:{mime};base64,{Convert.ToBase64String(bytes)}",
|
|
188
|
+
alt = Path.GetFileNameWithoutExtension(dialog.FileName),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async Task<OpenResult> OpenAsync(string? path)
|
|
193
|
+
{
|
|
194
|
+
var source = RequirePath(path);
|
|
195
|
+
if (!File.Exists(source)) throw new FileNotFoundException("File not found.", source);
|
|
196
|
+
var ext = Path.GetExtension(source).ToLowerInvariant();
|
|
197
|
+
if (ext is ".md" or ".markdown")
|
|
198
|
+
{
|
|
199
|
+
return new OpenResult(source, source, "markdown", await File.ReadAllTextAsync(source));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ext is not ".docx" and not ".pptx" and not ".ppt")
|
|
203
|
+
{
|
|
204
|
+
throw new InvalidOperationException($"Unsupported file type: {ext}");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var outDir = Path.Combine(sessionRoot, Path.GetFileNameWithoutExtension(source) + "-" + Guid.NewGuid().ToString("N")[..8]);
|
|
208
|
+
Directory.CreateDirectory(outDir);
|
|
209
|
+
var report = RunBuildCorpus(source, "--out", outDir);
|
|
210
|
+
var output = report.RootElement.GetProperty("outputs")[0].GetString();
|
|
211
|
+
if (string.IsNullOrWhiteSpace(output) || !File.Exists(output))
|
|
212
|
+
{
|
|
213
|
+
throw new InvalidOperationException("Build Corpus did not produce a Markdown file.");
|
|
214
|
+
}
|
|
215
|
+
return new OpenResult(source, output, ext.TrimStart('.'), await File.ReadAllTextAsync(output));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public SaveResult Save(string? path, string content)
|
|
219
|
+
{
|
|
220
|
+
var target = RequirePath(path);
|
|
221
|
+
if (Path.GetExtension(target).ToLowerInvariant() is not ".md" and not ".markdown")
|
|
222
|
+
{
|
|
223
|
+
target = Path.ChangeExtension(target, ".md");
|
|
224
|
+
}
|
|
225
|
+
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
|
226
|
+
File.WriteAllText(target, content);
|
|
227
|
+
return new SaveResult(target);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public async Task<DocumentSelfTestResult> RunDocumentSelfTestAsync(string? inputPath, string? outputDir)
|
|
231
|
+
{
|
|
232
|
+
var source = RequirePath(inputPath);
|
|
233
|
+
var destination = string.IsNullOrWhiteSpace(outputDir)
|
|
234
|
+
? Path.Combine(sessionRoot, "document-self-test-" + Guid.NewGuid().ToString("N")[..8])
|
|
235
|
+
: Path.GetFullPath(outputDir);
|
|
236
|
+
Directory.CreateDirectory(destination);
|
|
237
|
+
|
|
238
|
+
var opened = await OpenAsync(source);
|
|
239
|
+
if (string.IsNullOrWhiteSpace(opened.Content))
|
|
240
|
+
{
|
|
241
|
+
throw new InvalidOperationException("Opened document was empty.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
var marker = $"regen-mde functional edit {DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
|
|
245
|
+
var edited = opened.Content.TrimEnd() + Environment.NewLine + Environment.NewLine + marker + Environment.NewLine;
|
|
246
|
+
var savedMarkdown = Path.Combine(destination, Path.GetFileNameWithoutExtension(source) + ".edited.md");
|
|
247
|
+
var save = Save(savedMarkdown, edited);
|
|
248
|
+
if (!File.ReadAllText(save.Output).Contains(marker, StringComparison.Ordinal))
|
|
249
|
+
{
|
|
250
|
+
throw new InvalidOperationException("Saved Markdown did not contain the edit marker.");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
var exportedWord = Path.Combine(destination, Path.GetFileNameWithoutExtension(source) + ".edited.docx");
|
|
254
|
+
await ExportWordAsync(exportedWord, edited);
|
|
255
|
+
if (!File.Exists(exportedWord) || new FileInfo(exportedWord).Length == 0)
|
|
256
|
+
{
|
|
257
|
+
throw new InvalidOperationException("Word export was missing or empty.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
var reconvertDir = Path.Combine(destination, "reconverted");
|
|
261
|
+
Directory.CreateDirectory(reconvertDir);
|
|
262
|
+
var report = RunBuildCorpus(exportedWord, "--out", reconvertDir);
|
|
263
|
+
var reconvertedMarkdown = report.RootElement.GetProperty("outputs")[0].GetString();
|
|
264
|
+
if (string.IsNullOrWhiteSpace(reconvertedMarkdown) || !File.Exists(reconvertedMarkdown))
|
|
265
|
+
{
|
|
266
|
+
throw new InvalidOperationException("Exported Word document did not reconvert to Markdown.");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
var reconvertedText = await File.ReadAllTextAsync(reconvertedMarkdown);
|
|
270
|
+
if (!reconvertedText.Contains("regen-mde functional edit", StringComparison.Ordinal))
|
|
271
|
+
{
|
|
272
|
+
throw new InvalidOperationException("Reconverted Markdown did not contain the edit marker.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return new DocumentSelfTestResult(
|
|
276
|
+
source,
|
|
277
|
+
opened.WorkingPath,
|
|
278
|
+
save.Output,
|
|
279
|
+
exportedWord,
|
|
280
|
+
reconvertedMarkdown,
|
|
281
|
+
marker);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public async Task<SaveResult?> SaveAsAsync(string? suggestedPath, string format, string content)
|
|
285
|
+
{
|
|
286
|
+
using var dialog = new SaveFileDialog
|
|
287
|
+
{
|
|
288
|
+
Title = format == "word" ? "Export Word Document" : "Save Markdown",
|
|
289
|
+
Filter = format == "word" ? "Word document|*.docx" : "Markdown|*.md",
|
|
290
|
+
FileName = SuggestedFileName(suggestedPath, format == "word" ? ".docx" : ".md"),
|
|
291
|
+
AddExtension = true,
|
|
292
|
+
OverwritePrompt = true,
|
|
293
|
+
};
|
|
294
|
+
if (dialog.ShowDialog() != DialogResult.OK) return null;
|
|
295
|
+
|
|
296
|
+
if (format == "word")
|
|
297
|
+
{
|
|
298
|
+
return new SaveResult(await ExportWordAsync(dialog.FileName, content));
|
|
299
|
+
}
|
|
300
|
+
File.WriteAllText(dialog.FileName, content);
|
|
301
|
+
return new SaveResult(dialog.FileName);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public async Task<SaveResult> SaveAsDirectAsync(string? targetPath, string format, string content)
|
|
305
|
+
{
|
|
306
|
+
var target = RequirePath(targetPath);
|
|
307
|
+
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
|
308
|
+
if (format == "word")
|
|
309
|
+
{
|
|
310
|
+
return new SaveResult(await ExportWordAsync(target, content));
|
|
311
|
+
}
|
|
312
|
+
if (Path.GetExtension(target).ToLowerInvariant() is not ".md" and not ".markdown")
|
|
313
|
+
{
|
|
314
|
+
target = Path.ChangeExtension(target, ".md");
|
|
315
|
+
}
|
|
316
|
+
await File.WriteAllTextAsync(target, content);
|
|
317
|
+
return new SaveResult(target);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
public BatchConvertResult ConvertBatch(string? path, bool moveSources)
|
|
321
|
+
{
|
|
322
|
+
var source = RequirePath(path);
|
|
323
|
+
var args = new List<string> { source, "--out-same-dir" };
|
|
324
|
+
if (moveSources) args.Add("--move-sources");
|
|
325
|
+
var report = RunBuildCorpus(args.ToArray());
|
|
326
|
+
var outputs = report.RootElement.GetProperty("outputs").EnumerateArray()
|
|
327
|
+
.Select(item => item.GetString() ?? "")
|
|
328
|
+
.Where(item => !string.IsNullOrWhiteSpace(item))
|
|
329
|
+
.ToArray();
|
|
330
|
+
return new BatchConvertResult(source, outputs);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
public async Task<InlineImagesResult> InlineImagesAsync(string content, string? basePath)
|
|
334
|
+
{
|
|
335
|
+
var baseDir = string.IsNullOrWhiteSpace(basePath)
|
|
336
|
+
? Directory.GetCurrentDirectory()
|
|
337
|
+
: Path.GetDirectoryName(Path.GetFullPath(basePath)) ?? Directory.GetCurrentDirectory();
|
|
338
|
+
using var http = new HttpClient();
|
|
339
|
+
var converted = 0;
|
|
340
|
+
var skipped = new List<string>();
|
|
341
|
+
var pattern = new Regex(@"!\[([^\]]*)\]\(([^)\s]+)(?:\s+""[^""]*"")?\)");
|
|
342
|
+
var output = new System.Text.StringBuilder();
|
|
343
|
+
var cursor = 0;
|
|
344
|
+
foreach (Match match in pattern.Matches(content))
|
|
345
|
+
{
|
|
346
|
+
output.Append(content, cursor, match.Index - cursor);
|
|
347
|
+
cursor = match.Index + match.Length;
|
|
348
|
+
var alt = match.Groups[1].Value;
|
|
349
|
+
var reference = match.Groups[2].Value;
|
|
350
|
+
if (reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
351
|
+
{
|
|
352
|
+
output.Append(match.Value);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
try
|
|
356
|
+
{
|
|
357
|
+
output.Append(await InlineOneImageAsync(http, baseDir, alt, reference, match.Value));
|
|
358
|
+
converted++;
|
|
359
|
+
}
|
|
360
|
+
catch (Exception exc)
|
|
361
|
+
{
|
|
362
|
+
skipped.Add($"{reference}: {exc.Message}");
|
|
363
|
+
output.Append(match.Value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
output.Append(content, cursor, content.Length - cursor);
|
|
367
|
+
return new InlineImagesResult(output.ToString(), converted, skipped.ToArray());
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private static async Task<string> InlineOneImageAsync(HttpClient http, string baseDir, string alt, string reference, string fallback)
|
|
371
|
+
{
|
|
372
|
+
byte[] data;
|
|
373
|
+
string mime;
|
|
374
|
+
if (Uri.TryCreate(reference, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https"))
|
|
375
|
+
{
|
|
376
|
+
using var response = await http.GetAsync(uri);
|
|
377
|
+
response.EnsureSuccessStatusCode();
|
|
378
|
+
data = await response.Content.ReadAsByteArrayAsync();
|
|
379
|
+
mime = response.Content.Headers.ContentType?.MediaType ?? GuessImageMime(reference, data);
|
|
380
|
+
}
|
|
381
|
+
else
|
|
382
|
+
{
|
|
383
|
+
var path = Path.IsPathRooted(reference) ? reference : Path.Combine(baseDir, reference.Replace('/', Path.DirectorySeparatorChar));
|
|
384
|
+
if (!File.Exists(path)) return fallback;
|
|
385
|
+
data = await File.ReadAllBytesAsync(path);
|
|
386
|
+
mime = GuessImageMime(path, data);
|
|
387
|
+
}
|
|
388
|
+
return $"})";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private static string GuessImageMime(string reference, byte[] data)
|
|
392
|
+
{
|
|
393
|
+
var ext = Path.GetExtension(reference).ToLowerInvariant();
|
|
394
|
+
return ext switch
|
|
395
|
+
{
|
|
396
|
+
".jpg" or ".jpeg" => "image/jpeg",
|
|
397
|
+
".gif" => "image/gif",
|
|
398
|
+
".webp" => "image/webp",
|
|
399
|
+
".bmp" => "image/bmp",
|
|
400
|
+
".svg" => "image/svg+xml",
|
|
401
|
+
_ when data.Length > 8 && data[0] == 0x89 && data[1] == 0x50 => "image/png",
|
|
402
|
+
_ => "image/png",
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async Task<string> ExportWordAsync(string targetPath, string content)
|
|
407
|
+
{
|
|
408
|
+
var tmp = Path.Combine(sessionRoot, "export-" + Guid.NewGuid().ToString("N"));
|
|
409
|
+
Directory.CreateDirectory(tmp);
|
|
410
|
+
var md = Path.Combine(tmp, Path.GetFileNameWithoutExtension(targetPath) + ".md");
|
|
411
|
+
await File.WriteAllTextAsync(md, content);
|
|
412
|
+
RunBuildCorpus(md, "--to", "word", "--out-same-dir");
|
|
413
|
+
var generated = Path.ChangeExtension(md, ".docx");
|
|
414
|
+
File.Copy(generated, targetPath, overwrite: true);
|
|
415
|
+
return targetPath;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private JsonDocument RunBuildCorpus(params string[] args)
|
|
419
|
+
{
|
|
420
|
+
var psi = new ProcessStartInfo
|
|
421
|
+
{
|
|
422
|
+
FileName = "node",
|
|
423
|
+
WorkingDirectory = root,
|
|
424
|
+
RedirectStandardOutput = true,
|
|
425
|
+
RedirectStandardError = true,
|
|
426
|
+
UseShellExecute = false,
|
|
427
|
+
};
|
|
428
|
+
psi.ArgumentList.Add(Path.Combine(root, "bin", "build-corpus.js"));
|
|
429
|
+
foreach (var arg in args) psi.ArgumentList.Add(arg);
|
|
430
|
+
psi.Environment["PYTHONPATH"] = Path.Combine(root, "src");
|
|
431
|
+
|
|
432
|
+
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Could not start build-corpus.");
|
|
433
|
+
var stdout = process.StandardOutput.ReadToEnd();
|
|
434
|
+
var stderr = process.StandardError.ReadToEnd();
|
|
435
|
+
process.WaitForExit();
|
|
436
|
+
if (process.ExitCode != 0)
|
|
437
|
+
{
|
|
438
|
+
throw new InvalidOperationException(stderr.Length > 0 ? stderr : stdout);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
var start = stdout.IndexOf('{');
|
|
442
|
+
var end = stdout.LastIndexOf('}');
|
|
443
|
+
if (start < 0 || end < start) throw new InvalidOperationException("Build Corpus did not return JSON.");
|
|
444
|
+
return JsonDocument.Parse(stdout[start..(end + 1)]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private static string RequirePath(string? path)
|
|
448
|
+
{
|
|
449
|
+
if (string.IsNullOrWhiteSpace(path)) throw new InvalidOperationException("Missing path.");
|
|
450
|
+
return Path.GetFullPath(path);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private static string SuggestedFileName(string? path, string extension)
|
|
454
|
+
{
|
|
455
|
+
if (string.IsNullOrWhiteSpace(path)) return "document" + extension;
|
|
456
|
+
return Path.ChangeExtension(Path.GetFileName(path), extension);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private static string LocateRoot()
|
|
460
|
+
{
|
|
461
|
+
var env = Environment.GetEnvironmentVariable("BUILD_CORPUS_ROOT");
|
|
462
|
+
if (!string.IsNullOrWhiteSpace(env) && File.Exists(Path.Combine(env, "bin", "build-corpus.js")))
|
|
463
|
+
{
|
|
464
|
+
return Path.GetFullPath(env);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
var current = AppContext.BaseDirectory;
|
|
468
|
+
while (!string.IsNullOrWhiteSpace(current))
|
|
469
|
+
{
|
|
470
|
+
if (File.Exists(Path.Combine(current, "bin", "build-corpus.js"))) return current;
|
|
471
|
+
var parent = Directory.GetParent(current)?.FullName;
|
|
472
|
+
if (parent == current) break;
|
|
473
|
+
current = parent ?? "";
|
|
474
|
+
}
|
|
475
|
+
return Directory.GetCurrentDirectory();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
internal sealed record StartupResult(string? InitialPath, bool ComposeMode = false, string? PaneId = null);
|
|
480
|
+
internal sealed record ComposeInfoResult(bool ComposeMode, string? PaneId, string DropDir);
|
|
481
|
+
internal sealed record DroppedFileResult(string Path, bool IsImage, long Size);
|
|
482
|
+
internal sealed record SendToPaneResult(string PaneId, string SavedPath, int Length);
|
|
483
|
+
internal sealed record OpenResult(string SourcePath, string WorkingPath, string OriginalFormat, string Content);
|
|
484
|
+
internal sealed record SaveResult(string Output);
|
|
485
|
+
internal sealed record BatchConvertResult(string Input, string[] Outputs);
|
|
486
|
+
internal sealed record InlineImagesResult(string Content, int Converted, string[] Skipped);
|
|
487
|
+
internal sealed record DocumentSelfTestResult(
|
|
488
|
+
string SourcePath,
|
|
489
|
+
string WorkingPath,
|
|
490
|
+
string SavedMarkdown,
|
|
491
|
+
string ExportedWord,
|
|
492
|
+
string ReconvertedMarkdown,
|
|
493
|
+
string Marker);
|