@plannotator/pi-extension 0.8.5 → 0.9.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/README.md +1 -1
- package/package.json +1 -1
- package/plannotator.html +339 -330
- package/review-editor.html +4 -4
- package/server.ts +214 -2
package/server.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
11
|
import os from "node:os";
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { join, basename } from "node:path";
|
|
12
14
|
|
|
13
15
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
14
16
|
|
|
@@ -72,6 +74,181 @@ export function openBrowser(url: string): void {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
// ── Version History (Node-compatible, duplicated from packages/server) ──
|
|
78
|
+
|
|
79
|
+
function sanitizeTag(name: string): string | null {
|
|
80
|
+
if (!name || typeof name !== "string") return null;
|
|
81
|
+
const sanitized = name
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/[\s_]+/g, "-")
|
|
85
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
86
|
+
.replace(/-+/g, "-")
|
|
87
|
+
.replace(/^-|-$/g, "")
|
|
88
|
+
.slice(0, 30);
|
|
89
|
+
return sanitized.length >= 2 ? sanitized : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractFirstHeading(markdown: string): string | null {
|
|
93
|
+
const match = markdown.match(/^#\s+(.+)$/m);
|
|
94
|
+
if (!match) return null;
|
|
95
|
+
return match[1].trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function generateSlug(plan: string): string {
|
|
99
|
+
const date = new Date().toISOString().split("T")[0];
|
|
100
|
+
const heading = extractFirstHeading(plan);
|
|
101
|
+
const slug = heading ? sanitizeTag(heading) : null;
|
|
102
|
+
return slug ? `${slug}-${date}` : `plan-${date}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function detectProjectName(): string {
|
|
106
|
+
try {
|
|
107
|
+
const toplevel = execSync("git rev-parse --show-toplevel", {
|
|
108
|
+
encoding: "utf-8",
|
|
109
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
110
|
+
}).trim();
|
|
111
|
+
const name = basename(toplevel);
|
|
112
|
+
return sanitizeTag(name) ?? "_unknown";
|
|
113
|
+
} catch {
|
|
114
|
+
// Not a git repo — fall back to cwd
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const name = basename(process.cwd());
|
|
118
|
+
return sanitizeTag(name) ?? "_unknown";
|
|
119
|
+
} catch {
|
|
120
|
+
return "_unknown";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getHistoryDir(project: string, slug: string): string {
|
|
125
|
+
const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
|
|
126
|
+
mkdirSync(historyDir, { recursive: true });
|
|
127
|
+
return historyDir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getNextVersionNumber(historyDir: string): number {
|
|
131
|
+
try {
|
|
132
|
+
const entries = readdirSync(historyDir);
|
|
133
|
+
let max = 0;
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const match = entry.match(/^(\d+)\.md$/);
|
|
136
|
+
if (match) {
|
|
137
|
+
const num = parseInt(match[1], 10);
|
|
138
|
+
if (num > max) max = num;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return max + 1;
|
|
142
|
+
} catch {
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function saveToHistory(
|
|
148
|
+
project: string,
|
|
149
|
+
slug: string,
|
|
150
|
+
plan: string,
|
|
151
|
+
): { version: number; path: string; isNew: boolean } {
|
|
152
|
+
const historyDir = getHistoryDir(project, slug);
|
|
153
|
+
const nextVersion = getNextVersionNumber(historyDir);
|
|
154
|
+
if (nextVersion > 1) {
|
|
155
|
+
const latestPath = join(historyDir, `${String(nextVersion - 1).padStart(3, "0")}.md`);
|
|
156
|
+
try {
|
|
157
|
+
const existing = readFileSync(latestPath, "utf-8");
|
|
158
|
+
if (existing === plan) {
|
|
159
|
+
return { version: nextVersion - 1, path: latestPath, isNew: false };
|
|
160
|
+
}
|
|
161
|
+
} catch { /* proceed with saving */ }
|
|
162
|
+
}
|
|
163
|
+
const fileName = `${String(nextVersion).padStart(3, "0")}.md`;
|
|
164
|
+
const filePath = join(historyDir, fileName);
|
|
165
|
+
writeFileSync(filePath, plan, "utf-8");
|
|
166
|
+
return { version: nextVersion, path: filePath, isNew: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getPlanVersion(
|
|
170
|
+
project: string,
|
|
171
|
+
slug: string,
|
|
172
|
+
version: number,
|
|
173
|
+
): string | null {
|
|
174
|
+
const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
|
|
175
|
+
const fileName = `${String(version).padStart(3, "0")}.md`;
|
|
176
|
+
const filePath = join(historyDir, fileName);
|
|
177
|
+
try {
|
|
178
|
+
return readFileSync(filePath, "utf-8");
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getVersionCount(project: string, slug: string): number {
|
|
185
|
+
const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
|
|
186
|
+
try {
|
|
187
|
+
const entries = readdirSync(historyDir);
|
|
188
|
+
return entries.filter((e) => /^\d+\.md$/.test(e)).length;
|
|
189
|
+
} catch {
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function listVersions(
|
|
195
|
+
project: string,
|
|
196
|
+
slug: string,
|
|
197
|
+
): Array<{ version: number; timestamp: string }> {
|
|
198
|
+
const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
|
|
199
|
+
try {
|
|
200
|
+
const entries = readdirSync(historyDir);
|
|
201
|
+
const versions: Array<{ version: number; timestamp: string }> = [];
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const match = entry.match(/^(\d+)\.md$/);
|
|
204
|
+
if (match) {
|
|
205
|
+
const version = parseInt(match[1], 10);
|
|
206
|
+
const filePath = join(historyDir, entry);
|
|
207
|
+
try {
|
|
208
|
+
const stat = statSync(filePath);
|
|
209
|
+
versions.push({ version, timestamp: stat.mtime.toISOString() });
|
|
210
|
+
} catch {
|
|
211
|
+
versions.push({ version, timestamp: "" });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return versions.sort((a, b) => a.version - b.version);
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function listProjectPlans(
|
|
222
|
+
project: string,
|
|
223
|
+
): Array<{ slug: string; versions: number; lastModified: string }> {
|
|
224
|
+
const projectDir = join(os.homedir(), ".plannotator", "history", project);
|
|
225
|
+
try {
|
|
226
|
+
const entries = readdirSync(projectDir, { withFileTypes: true });
|
|
227
|
+
const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory()) continue;
|
|
230
|
+
const slugDir = join(projectDir, entry.name);
|
|
231
|
+
const files = readdirSync(slugDir).filter((f) => /^\d+\.md$/.test(f));
|
|
232
|
+
if (files.length === 0) continue;
|
|
233
|
+
let latest = 0;
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
try {
|
|
236
|
+
const mtime = statSync(join(slugDir, file)).mtime.getTime();
|
|
237
|
+
if (mtime > latest) latest = mtime;
|
|
238
|
+
} catch { /* skip */ }
|
|
239
|
+
}
|
|
240
|
+
plans.push({
|
|
241
|
+
slug: entry.name,
|
|
242
|
+
versions: files.length,
|
|
243
|
+
lastModified: latest ? new Date(latest).toISOString() : "",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return plans.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
|
|
247
|
+
} catch {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
75
252
|
// ── Plan Review Server ──────────────────────────────────────────────────
|
|
76
253
|
|
|
77
254
|
export interface PlanServerResult {
|
|
@@ -86,6 +263,20 @@ export function startPlanReviewServer(options: {
|
|
|
86
263
|
htmlContent: string;
|
|
87
264
|
origin?: string;
|
|
88
265
|
}): PlanServerResult {
|
|
266
|
+
// Version history
|
|
267
|
+
const slug = generateSlug(options.plan);
|
|
268
|
+
const project = detectProjectName();
|
|
269
|
+
const historyResult = saveToHistory(project, slug, options.plan);
|
|
270
|
+
const previousPlan =
|
|
271
|
+
historyResult.version > 1
|
|
272
|
+
? getPlanVersion(project, slug, historyResult.version - 1)
|
|
273
|
+
: null;
|
|
274
|
+
const versionInfo = {
|
|
275
|
+
version: historyResult.version,
|
|
276
|
+
totalVersions: getVersionCount(project, slug),
|
|
277
|
+
project,
|
|
278
|
+
};
|
|
279
|
+
|
|
89
280
|
let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void;
|
|
90
281
|
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => {
|
|
91
282
|
resolveDecision = r;
|
|
@@ -94,8 +285,29 @@ export function startPlanReviewServer(options: {
|
|
|
94
285
|
const server = createServer(async (req, res) => {
|
|
95
286
|
const url = new URL(req.url!, `http://localhost`);
|
|
96
287
|
|
|
97
|
-
if (url.pathname === "/api/plan") {
|
|
98
|
-
|
|
288
|
+
if (url.pathname === "/api/plan/version") {
|
|
289
|
+
const vParam = url.searchParams.get("v");
|
|
290
|
+
if (!vParam) {
|
|
291
|
+
json(res, { error: "Missing v parameter" }, 400);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const v = parseInt(vParam, 10);
|
|
295
|
+
if (isNaN(v) || v < 1) {
|
|
296
|
+
json(res, { error: "Invalid version number" }, 400);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const content = getPlanVersion(project, slug, v);
|
|
300
|
+
if (content === null) {
|
|
301
|
+
json(res, { error: "Version not found" }, 404);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
json(res, { plan: content, version: v });
|
|
305
|
+
} else if (url.pathname === "/api/plan/versions") {
|
|
306
|
+
json(res, { project, slug, versions: listVersions(project, slug) });
|
|
307
|
+
} else if (url.pathname === "/api/plan/history") {
|
|
308
|
+
json(res, { project, plans: listProjectPlans(project) });
|
|
309
|
+
} else if (url.pathname === "/api/plan") {
|
|
310
|
+
json(res, { plan: options.plan, origin: options.origin ?? "pi", previousPlan, versionInfo });
|
|
99
311
|
} else if (url.pathname === "/api/approve" && req.method === "POST") {
|
|
100
312
|
const body = await parseBody(req);
|
|
101
313
|
resolveDecision({ approved: true, feedback: body.feedback as string | undefined });
|