pinokiod 7.2.8 → 7.2.9
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/kernel/api/index.js +26 -0
- package/kernel/index.js +2 -0
- package/kernel/watch/context.js +42 -0
- package/kernel/watch/drivers/fs.js +71 -0
- package/kernel/watch/drivers/poll.js +33 -0
- package/kernel/watch/index.js +158 -0
- package/package.json +1 -1
- package/server/features/drafts/index.js +41 -0
- package/server/features/drafts/parser.js +169 -0
- package/server/features/drafts/public/drafts.js +1546 -0
- package/server/{routes/draft_import.js → features/drafts/registry_import.js} +35 -77
- package/server/features/drafts/routes.js +68 -0
- package/server/features/drafts/service.js +261 -0
- package/server/features/drafts/watcher.js +76 -0
- package/server/features/index.js +13 -0
- package/server/index.js +25 -36
- package/server/views/app.ejs +159 -14
- package/server/views/terminal.ejs +8 -6
- package/server/lib/drafts.js +0 -376
- package/server/public/drafts.js +0 -632
|
@@ -4,6 +4,11 @@ const path = require("path")
|
|
|
4
4
|
const axios = require("axios")
|
|
5
5
|
const FormData = require("form-data")
|
|
6
6
|
const mime = require("mime-types")
|
|
7
|
+
const {
|
|
8
|
+
describeMediaRefs,
|
|
9
|
+
extractTitleAndBody,
|
|
10
|
+
normalizeTitle
|
|
11
|
+
} = require("./parser")
|
|
7
12
|
|
|
8
13
|
const DEFAULT_MAX_FILES = 10
|
|
9
14
|
const DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
@@ -77,7 +82,7 @@ function renderImportLauncher(res, { authorizeUrl, draftId, registryOrigin, auto
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
function openRegistry() {
|
|
80
|
-
registryWindow = window.open(authorizeUrl, "
|
|
85
|
+
registryWindow = window.open(authorizeUrl, "_blank");
|
|
81
86
|
if (!registryWindow) {
|
|
82
87
|
setStatus("The registry window was blocked. Click Open registry to continue.");
|
|
83
88
|
} else {
|
|
@@ -145,65 +150,6 @@ function requestOrigin(req) {
|
|
|
145
150
|
return `${req.protocol || "http"}://${host}`
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
function isExternalRef(value) {
|
|
149
|
-
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function normalizeMarkdownRef(value) {
|
|
153
|
-
const raw = String(value || "").trim().replace(/^<|>$/g, "")
|
|
154
|
-
if (!raw || raw.includes("\0") || isExternalRef(raw) || path.isAbsolute(raw)) {
|
|
155
|
-
return ""
|
|
156
|
-
}
|
|
157
|
-
const withoutHash = raw.split("#")[0]
|
|
158
|
-
const withoutQuery = withoutHash.split("?")[0]
|
|
159
|
-
if (!withoutQuery) {
|
|
160
|
-
return ""
|
|
161
|
-
}
|
|
162
|
-
let decoded = withoutQuery
|
|
163
|
-
try {
|
|
164
|
-
decoded = decodeURIComponent(withoutQuery)
|
|
165
|
-
} catch (_) {
|
|
166
|
-
}
|
|
167
|
-
const normalized = path.posix.normalize(decoded.replace(/\\/g, "/"))
|
|
168
|
-
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
169
|
-
return ""
|
|
170
|
-
}
|
|
171
|
-
return normalized
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function collectMarkdownRefs(markdown) {
|
|
175
|
-
const refs = []
|
|
176
|
-
const seen = new Set()
|
|
177
|
-
const patterns = [
|
|
178
|
-
/!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
|
|
179
|
-
/\[(?:video|audio|media|image|screenshot|file|asset)[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gi,
|
|
180
|
-
/\[[^\]]+]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
|
|
181
|
-
]
|
|
182
|
-
for (const pattern of patterns) {
|
|
183
|
-
let match = null
|
|
184
|
-
while ((match = pattern.exec(markdown))) {
|
|
185
|
-
const ref = normalizeMarkdownRef(match[1])
|
|
186
|
-
if (!ref || seen.has(ref)) continue
|
|
187
|
-
seen.add(ref)
|
|
188
|
-
refs.push(ref)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return refs
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function extractTitleAndBody(markdown, fallbackTitle) {
|
|
195
|
-
const lines = String(markdown || "").split(/\r?\n/)
|
|
196
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
197
|
-
const match = lines[i].match(/^#\s+(.+?)\s*#*\s*$/)
|
|
198
|
-
if (!match || !match[1]) continue
|
|
199
|
-
const title = match[1].replace(/\s+/g, " ").trim().slice(0, 160)
|
|
200
|
-
const bodyLines = [...lines.slice(0, i), ...lines.slice(i + 1)]
|
|
201
|
-
while (bodyLines.length && !bodyLines[0].trim()) bodyLines.shift()
|
|
202
|
-
return { title: title || fallbackTitle, body: bodyLines.join("\n").trim() }
|
|
203
|
-
}
|
|
204
|
-
return { title: fallbackTitle, body: String(markdown || "").trim() }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
153
|
async function findDraftById(drafts, id) {
|
|
208
154
|
const normalized = String(id || "").trim()
|
|
209
155
|
if (!normalized) return null
|
|
@@ -211,32 +157,40 @@ async function findDraftById(drafts, id) {
|
|
|
211
157
|
return (items || []).find((item) => item && item.id === normalized) || null
|
|
212
158
|
}
|
|
213
159
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
return media
|
|
160
|
+
function isRegistryPostPublish(publish) {
|
|
161
|
+
if (!publish || typeof publish !== "object") return false
|
|
162
|
+
const target = String(publish.target || "").trim().toLowerCase()
|
|
163
|
+
const type = String(publish.type || "post").trim().toLowerCase()
|
|
164
|
+
return target === "registry" && type === "post"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeParent(parent) {
|
|
168
|
+
if (!parent || typeof parent !== "object" || Array.isArray(parent)) return null
|
|
169
|
+
const type = String(parent.type || "").trim().toLowerCase()
|
|
170
|
+
const url = String(parent.url || parent.repoUrl || "").trim()
|
|
171
|
+
if (type !== "app" || !url) return null
|
|
172
|
+
return { type: "app", url }
|
|
229
173
|
}
|
|
230
174
|
|
|
231
175
|
async function buildDraftBundle(item, query = {}) {
|
|
232
176
|
const markdown = await fs.promises.readFile(item.postPath, "utf8")
|
|
233
177
|
const resultDir = path.dirname(item.postPath)
|
|
234
178
|
const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
|
|
235
|
-
const
|
|
236
|
-
const
|
|
179
|
+
const extracted = extractTitleAndBody(markdown, titleFallback)
|
|
180
|
+
const metadataTitle = item.metadata && typeof item.metadata.title === "string"
|
|
181
|
+
? normalizeTitle(item.metadata.title)
|
|
182
|
+
: ""
|
|
183
|
+
const title = metadataTitle || extracted.title
|
|
184
|
+
const body = metadataTitle && extracted.title && normalizeTitle(extracted.title) === metadataTitle
|
|
185
|
+
? extracted.body
|
|
186
|
+
: (!metadataTitle ? extracted.body : String(markdown || "").trim())
|
|
187
|
+
const publish = item.publish && typeof item.publish === "object" ? item.publish : null
|
|
188
|
+
const media = await describeMediaRefs(markdown, resultDir, { mediaOnly: false })
|
|
237
189
|
return {
|
|
238
190
|
title,
|
|
239
191
|
body,
|
|
192
|
+
publish,
|
|
193
|
+
parent: normalizeParent(publish && publish.parent),
|
|
240
194
|
appSlug: String(query.app || "").trim(),
|
|
241
195
|
media
|
|
242
196
|
}
|
|
@@ -248,6 +202,9 @@ function preflightBundle(bundle, options = {}) {
|
|
|
248
202
|
if (!bundle.title) {
|
|
249
203
|
return "Draft title is missing."
|
|
250
204
|
}
|
|
205
|
+
if (!isRegistryPostPublish(bundle.publish)) {
|
|
206
|
+
return "This draft is not configured for registry publishing."
|
|
207
|
+
}
|
|
251
208
|
if (bundle.media.length > maxFiles) {
|
|
252
209
|
return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
|
|
253
210
|
}
|
|
@@ -268,6 +225,7 @@ async function uploadBundle(registryBase, token, bundle) {
|
|
|
268
225
|
title: bundle.title,
|
|
269
226
|
body: bundle.body,
|
|
270
227
|
app: bundle.appSlug || "",
|
|
228
|
+
parent: bundle.parent || null,
|
|
271
229
|
media: bundle.media.map((item) => ({ path: item.ref }))
|
|
272
230
|
})
|
|
273
231
|
form.append("metadata_b64", Buffer.from(metadata, "utf8").toString("base64"))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const express = require("express")
|
|
2
|
+
const path = require("path")
|
|
3
|
+
const registerDraftImportRoutes = require("./registry_import")
|
|
4
|
+
|
|
5
|
+
function asyncHandler(fn) {
|
|
6
|
+
return (req, res, next) => {
|
|
7
|
+
Promise.resolve(fn(req, res, next)).catch(next)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function registerDraftRoutes(app, options = {}) {
|
|
12
|
+
const drafts = options.drafts
|
|
13
|
+
if (!drafts) {
|
|
14
|
+
throw new Error("drafts is required")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const router = express.Router()
|
|
18
|
+
router.get("/drafts", asyncHandler(async (req, res) => {
|
|
19
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : ""
|
|
20
|
+
const items = await drafts.listPending({ cwd })
|
|
21
|
+
res.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
items
|
|
24
|
+
})
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
router.post("/drafts/:id/dismiss", asyncHandler(async (req, res) => {
|
|
28
|
+
const ok = await drafts.dismiss(req.params.id, req.body && req.body.revision)
|
|
29
|
+
res.json({ ok })
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
router.get("/drafts/:id/media/:index", asyncHandler(async (req, res) => {
|
|
33
|
+
const item = typeof drafts.getPendingById === "function"
|
|
34
|
+
? await drafts.getPendingById(req.params.id)
|
|
35
|
+
: null
|
|
36
|
+
if (!item) {
|
|
37
|
+
res.status(404).send("Draft not found")
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const index = Number(req.params.index)
|
|
41
|
+
const media = Array.isArray(item.media) && Number.isInteger(index)
|
|
42
|
+
? item.media[index]
|
|
43
|
+
: null
|
|
44
|
+
if (!media || !media.exists || !media.path) {
|
|
45
|
+
res.status(404).send("Media not found")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const filePath = path.resolve(media.path)
|
|
49
|
+
const basePath = path.resolve(item.resultDir)
|
|
50
|
+
const relative = path.relative(basePath, filePath)
|
|
51
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
52
|
+
res.status(403).send("Media path is outside the draft")
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
res.setHeader("Cache-Control", "no-store")
|
|
56
|
+
res.sendFile(filePath)
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
router.get("/drafts.js", (req, res) => {
|
|
60
|
+
res.setHeader("Cache-Control", "no-store")
|
|
61
|
+
res.sendFile(path.resolve(__dirname, "public", "drafts.js"))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
app.use(router)
|
|
65
|
+
registerDraftImportRoutes(app, options)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = registerDraftRoutes
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
const fs = require("fs")
|
|
2
|
+
const path = require("path")
|
|
3
|
+
const crypto = require("crypto")
|
|
4
|
+
const {
|
|
5
|
+
RESULT_RELATIVE_DIR,
|
|
6
|
+
POST_FILENAME,
|
|
7
|
+
DEFAULT_READY_FILENAME,
|
|
8
|
+
buildExcerpt,
|
|
9
|
+
describeMediaRefs,
|
|
10
|
+
extractTitle,
|
|
11
|
+
parseDraftMetadata
|
|
12
|
+
} = require("./parser")
|
|
13
|
+
|
|
14
|
+
const STATE_FILENAME = "drafts.json"
|
|
15
|
+
const MAX_PREVIEW_BYTES = 256 * 1024
|
|
16
|
+
|
|
17
|
+
function createHash(value) {
|
|
18
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dismissalKey(id, revision) {
|
|
22
|
+
const normalizedId = typeof id === "string" ? id.trim() : ""
|
|
23
|
+
const normalizedRevision = typeof revision === "string" ? revision.trim() : ""
|
|
24
|
+
return normalizedRevision ? `${normalizedId}:${normalizedRevision}` : normalizedId
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clonePlainObject(value) {
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(JSON.stringify(value))
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeRelativePath(value, fallback) {
|
|
39
|
+
const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
|
|
40
|
+
if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
|
|
41
|
+
return fallback
|
|
42
|
+
}
|
|
43
|
+
const normalized = path.posix.normalize(raw)
|
|
44
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
45
|
+
return fallback
|
|
46
|
+
}
|
|
47
|
+
return normalized
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeDraftConfig(config = {}) {
|
|
51
|
+
const params = config && typeof config === "object" ? config : {}
|
|
52
|
+
return {
|
|
53
|
+
path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
|
|
54
|
+
content: normalizeRelativePath(params.content || params.post, POST_FILENAME),
|
|
55
|
+
ready: normalizeRelativePath(params.ready, DEFAULT_READY_FILENAME),
|
|
56
|
+
description: typeof params.description === "string" ? params.description.trim() : "",
|
|
57
|
+
publish: clonePlainObject(params.publish)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createDraftService({ kernel, taskWorkspaceLinks } = {}) {
|
|
62
|
+
if (!kernel) {
|
|
63
|
+
throw new Error("kernel is required")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const statePath = () => path.resolve(kernel.path("tasks"), STATE_FILENAME)
|
|
67
|
+
const resultsByWorkspace = new Map()
|
|
68
|
+
const dismissedIds = new Set()
|
|
69
|
+
let started = false
|
|
70
|
+
let stateLoaded = false
|
|
71
|
+
|
|
72
|
+
async function ensureStateLoaded() {
|
|
73
|
+
if (stateLoaded) return
|
|
74
|
+
stateLoaded = true
|
|
75
|
+
try {
|
|
76
|
+
const raw = await fs.promises.readFile(statePath(), "utf8")
|
|
77
|
+
const parsed = JSON.parse(raw)
|
|
78
|
+
if (parsed && Array.isArray(parsed.dismissed)) {
|
|
79
|
+
parsed.dismissed.forEach((id) => {
|
|
80
|
+
if (typeof id === "string" && id.trim()) {
|
|
81
|
+
dismissedIds.add(id.trim())
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
} catch (_) {
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function saveState() {
|
|
90
|
+
await fs.promises.mkdir(path.dirname(statePath()), { recursive: true })
|
|
91
|
+
const payload = {
|
|
92
|
+
version: 1,
|
|
93
|
+
dismissed: Array.from(dismissedIds).slice(-500)
|
|
94
|
+
}
|
|
95
|
+
await fs.promises.writeFile(statePath(), JSON.stringify(payload, null, 2))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readMarkdownPreview(postPath) {
|
|
99
|
+
const handle = await fs.promises.open(postPath, "r")
|
|
100
|
+
try {
|
|
101
|
+
const buffer = Buffer.alloc(MAX_PREVIEW_BYTES)
|
|
102
|
+
const read = await handle.read(buffer, 0, MAX_PREVIEW_BYTES, 0)
|
|
103
|
+
return buffer.slice(0, read.bytesRead).toString("utf8")
|
|
104
|
+
} finally {
|
|
105
|
+
await handle.close().catch(() => {})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readDraftMetadata(metadataPath) {
|
|
110
|
+
if (path.extname(metadataPath).toLowerCase() !== ".json") {
|
|
111
|
+
return {}
|
|
112
|
+
}
|
|
113
|
+
const raw = await fs.promises.readFile(metadataPath, "utf8")
|
|
114
|
+
return parseDraftMetadata(raw)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function inspectWorkspace({ taskId, ref, cwd, draft } = {}) {
|
|
118
|
+
await ensureStateLoaded()
|
|
119
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
const workspacePath = path.resolve(cwd.trim())
|
|
123
|
+
const draftConfig = normalizeDraftConfig(draft)
|
|
124
|
+
const resultDir = path.resolve(workspacePath, draftConfig.path)
|
|
125
|
+
const readyPath = path.resolve(resultDir, draftConfig.ready)
|
|
126
|
+
const postPath = path.resolve(resultDir, draftConfig.content)
|
|
127
|
+
const readyStats = await fs.promises.stat(readyPath).catch(() => null)
|
|
128
|
+
const postStats = await fs.promises.stat(postPath).catch(() => null)
|
|
129
|
+
if (!readyStats || !readyStats.isFile() || !postStats || !postStats.isFile()) {
|
|
130
|
+
resultsByWorkspace.delete(workspacePath)
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
let metadata = {}
|
|
134
|
+
try {
|
|
135
|
+
metadata = await readDraftMetadata(readyPath)
|
|
136
|
+
} catch (_) {
|
|
137
|
+
resultsByWorkspace.delete(workspacePath)
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const markdown = await readMarkdownPreview(postPath)
|
|
142
|
+
const workspaceName = path.basename(workspacePath)
|
|
143
|
+
const media = await describeMediaRefs(markdown, resultDir)
|
|
144
|
+
const updatedAtMs = Math.max(readyStats.mtimeMs || 0, postStats.mtimeMs || 0)
|
|
145
|
+
const id = createHash(`${workspacePath}|${resultDir}|${postPath}|${readyPath}`)
|
|
146
|
+
const mediaRevision = media
|
|
147
|
+
.map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
|
|
148
|
+
.join("|")
|
|
149
|
+
const revision = createHash(`${postStats.size}|${postStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
|
|
150
|
+
const result = {
|
|
151
|
+
id,
|
|
152
|
+
revision,
|
|
153
|
+
taskId,
|
|
154
|
+
ref,
|
|
155
|
+
cwd: workspacePath,
|
|
156
|
+
workspaceName,
|
|
157
|
+
title: metadata.title || extractTitle(markdown, workspaceName),
|
|
158
|
+
markdown,
|
|
159
|
+
excerpt: buildExcerpt(markdown),
|
|
160
|
+
resultDir,
|
|
161
|
+
postPath,
|
|
162
|
+
contentPath: postPath,
|
|
163
|
+
readyPath,
|
|
164
|
+
metadataPath: readyPath,
|
|
165
|
+
metadata,
|
|
166
|
+
publish: draftConfig.publish,
|
|
167
|
+
description: draftConfig.description,
|
|
168
|
+
postBytes: postStats.size,
|
|
169
|
+
media: media.map((item, index) => ({
|
|
170
|
+
index,
|
|
171
|
+
ref: item.ref,
|
|
172
|
+
path: item.path,
|
|
173
|
+
bytes: item.bytes,
|
|
174
|
+
mtimeMs: item.mtimeMs,
|
|
175
|
+
exists: item.exists,
|
|
176
|
+
ext: path.extname(item.ref || "").toLowerCase()
|
|
177
|
+
})),
|
|
178
|
+
mediaCount: media.length,
|
|
179
|
+
missingMediaCount: media.filter((item) => !item.exists).length,
|
|
180
|
+
mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
|
|
181
|
+
updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
|
|
182
|
+
}
|
|
183
|
+
resultsByWorkspace.set(workspacePath, result)
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function trackWorkspace({ taskId, ref, cwd } = {}) {
|
|
188
|
+
let resolvedCwd = cwd
|
|
189
|
+
if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
|
|
190
|
+
resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
|
|
191
|
+
}
|
|
192
|
+
if (!resolvedCwd) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function listPending(options = {}) {
|
|
199
|
+
await ensureStateLoaded()
|
|
200
|
+
const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
|
|
201
|
+
? path.resolve(options.cwd.trim())
|
|
202
|
+
: ""
|
|
203
|
+
return Array.from(resultsByWorkspace.values())
|
|
204
|
+
.filter((result) => !dismissedIds.has(dismissalKey(result.id, result.revision)) && !dismissedIds.has(result.id))
|
|
205
|
+
.filter((result) => !filterCwd || result.cwd === filterCwd)
|
|
206
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function getPendingById(id) {
|
|
210
|
+
await ensureStateLoaded()
|
|
211
|
+
const normalizedId = typeof id === "string" ? id.trim() : ""
|
|
212
|
+
if (!normalizedId) {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
const result = Array.from(resultsByWorkspace.values()).find((item) => item.id === normalizedId) || null
|
|
216
|
+
if (!result || dismissedIds.has(dismissalKey(result.id, result.revision)) || dismissedIds.has(result.id)) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function dismiss(id, revision) {
|
|
223
|
+
await ensureStateLoaded()
|
|
224
|
+
const normalizedId = typeof id === "string" ? id.trim() : ""
|
|
225
|
+
if (!normalizedId) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
dismissedIds.add(dismissalKey(normalizedId, revision))
|
|
229
|
+
await saveState()
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function refreshLinkedWorkspaces() {
|
|
234
|
+
await ensureStateLoaded()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function start() {
|
|
238
|
+
if (started) return
|
|
239
|
+
started = true
|
|
240
|
+
await ensureStateLoaded()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function stop() {
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
RESULT_RELATIVE_DIR,
|
|
248
|
+
dismiss,
|
|
249
|
+
getPendingById,
|
|
250
|
+
inspectWorkspace,
|
|
251
|
+
listPending,
|
|
252
|
+
refreshLinkedWorkspaces,
|
|
253
|
+
start,
|
|
254
|
+
stop,
|
|
255
|
+
trackWorkspace
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
createDraftService
|
|
261
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const path = require("path")
|
|
2
|
+
|
|
3
|
+
function isInside(candidate, parent) {
|
|
4
|
+
const relative = path.relative(parent, candidate)
|
|
5
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class DraftWatcher {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.drafts = options.drafts
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async ready(ctx, params = {}) {
|
|
14
|
+
if (!this.drafts || typeof this.drafts.inspectWorkspace !== "function") {
|
|
15
|
+
throw new Error("draft service is unavailable")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const draftDir = ctx.resolve(params.path || ".pinokio/draft")
|
|
19
|
+
const draftConfig = {
|
|
20
|
+
path: params.path || ".pinokio/draft",
|
|
21
|
+
content: params.content,
|
|
22
|
+
ready: params.ready,
|
|
23
|
+
description: params.description,
|
|
24
|
+
publish: params.publish
|
|
25
|
+
}
|
|
26
|
+
let timer = null
|
|
27
|
+
let disposed = false
|
|
28
|
+
|
|
29
|
+
const inspect = async () => {
|
|
30
|
+
if (disposed) return
|
|
31
|
+
await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scheduleInspect = () => {
|
|
35
|
+
if (disposed) return
|
|
36
|
+
if (timer) clearTimeout(timer)
|
|
37
|
+
timer = setTimeout(() => {
|
|
38
|
+
timer = null
|
|
39
|
+
inspect().catch((error) => {
|
|
40
|
+
console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error)
|
|
41
|
+
})
|
|
42
|
+
}, 250)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await inspect()
|
|
46
|
+
const stopPoll = ctx.poll(params.interval || 1500, inspect, {
|
|
47
|
+
immediate: false,
|
|
48
|
+
onError: (error) => {
|
|
49
|
+
console.warn("[drafts] poll failed", error && error.message ? error.message : error)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
|
|
53
|
+
if (!Array.isArray(events)) return
|
|
54
|
+
if (events.some((event) => event && event.path && isInside(path.resolve(event.path), draftDir))) {
|
|
55
|
+
scheduleInspect()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return async () => {
|
|
60
|
+
if (timer) {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
timer = null
|
|
63
|
+
}
|
|
64
|
+
await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig }).catch(() => {})
|
|
65
|
+
disposed = true
|
|
66
|
+
if (typeof stopPoll === "function") {
|
|
67
|
+
await stopPoll()
|
|
68
|
+
}
|
|
69
|
+
if (typeof unsubscribe === "function") {
|
|
70
|
+
await unsubscribe()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = DraftWatcher
|