pinokiod 7.2.17 → 7.2.18
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/agent_instructions.js +166 -0
- package/kernel/api/index.js +137 -12
- package/kernel/bin/huggingface.js +1 -1
- package/kernel/environment.js +23 -9
- package/kernel/plugin_sources.js +57 -4
- package/kernel/prototype.js +4 -0
- package/kernel/shell.js +2 -0
- package/kernel/watch/index.js +31 -4
- package/package.json +1 -1
- package/server/features/index.js +4 -4
- package/server/features/{drafts → notes}/index.js +9 -9
- package/server/features/{drafts → notes}/parser.js +12 -7
- package/server/features/notes/public/notes.css +955 -0
- package/server/features/notes/public/notes.js +1149 -0
- package/server/features/{drafts → notes}/registry_import.js +22 -22
- package/server/features/notes/routes.js +156 -0
- package/server/features/notes/service.js +326 -0
- package/server/features/{drafts → notes}/watcher.js +14 -16
- package/server/index.js +61 -30
- package/server/lib/content_validation.js +19 -8
- package/server/lib/workspace_catalog.js +18 -18
- package/server/public/task-launcher.css +11 -3
- package/server/public/tasker.css +336 -0
- package/server/public/tasker.js +407 -0
- package/server/views/d.ejs +33 -2
- package/server/views/partials/menu.ejs +1 -1
- package/server/views/partials/workspace_row.ejs +11 -11
- package/server/views/pre.ejs +1 -1
- package/server/views/task_launch.ejs +10 -10
- package/server/views/tasker.ejs +40 -0
- package/server/views/terminal.ejs +15 -6
- package/server/views/terminals.ejs +0 -1
- package/server/views/workspaces.ejs +2 -1
- package/system/plugin/antigravity/pinokio.js +2 -4
- package/system/plugin/claude/pinokio.js +2 -4
- package/system/plugin/claude-auto/pinokio.js +2 -4
- package/system/plugin/claude-desktop/pinokio.js +2 -4
- package/system/plugin/codex/pinokio.js +2 -4
- package/system/plugin/codex-auto/pinokio.js +2 -4
- package/system/plugin/codex-desktop/pinokio.js +2 -4
- package/system/plugin/crush/pinokio.js +2 -4
- package/system/plugin/cursor/pinokio.js +2 -4
- package/system/plugin/gemini/pinokio.js +2 -4
- package/system/plugin/gemini-auto/pinokio.js +2 -4
- package/system/plugin/qwen/pinokio.js +2 -4
- package/system/plugin/vscode/pinokio.js +2 -4
- package/system/plugin/windsurf/pinokio.js +2 -4
- package/test/plugin-sources.test.js +45 -0
- package/server/features/drafts/public/drafts.js +0 -1504
- package/server/features/drafts/routes.js +0 -68
- package/server/features/drafts/service.js +0 -261
|
@@ -145,10 +145,10 @@ function buildAuthorizeUrl(req, registryBase, item, bundle) {
|
|
|
145
145
|
return authorizeUrl
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
async function
|
|
148
|
+
async function findNoteById(notes, id) {
|
|
149
149
|
const normalized = String(id || "").trim()
|
|
150
150
|
if (!normalized) return null
|
|
151
|
-
const items = await
|
|
151
|
+
const items = await notes.listPending({})
|
|
152
152
|
return (items || []).find((item) => item && item.id === normalized) || null
|
|
153
153
|
}
|
|
154
154
|
|
|
@@ -168,9 +168,9 @@ function normalizeParent(parent) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
async function buildDraftBundle(item, query = {}) {
|
|
171
|
-
const markdown = await fs.promises.readFile(item.
|
|
172
|
-
const resultDir = path.dirname(item.
|
|
173
|
-
const titleFallback = item.title || (item.workspaceName ? `
|
|
171
|
+
const markdown = await fs.promises.readFile(item.notePath, "utf8")
|
|
172
|
+
const resultDir = path.dirname(item.notePath)
|
|
173
|
+
const titleFallback = item.title || (item.workspaceName ? `Note for ${item.workspaceName}` : "Note")
|
|
174
174
|
const extracted = extractTitleAndBody(markdown, titleFallback)
|
|
175
175
|
const metadataTitle = item.metadata && typeof item.metadata.title === "string"
|
|
176
176
|
? normalizeTitle(item.metadata.title)
|
|
@@ -195,17 +195,17 @@ function preflightBundle(bundle, options = {}) {
|
|
|
195
195
|
const maxFiles = Number(options.maxFiles || DEFAULT_MAX_FILES)
|
|
196
196
|
const maxFileBytes = Number(options.maxFileBytes || DEFAULT_MAX_FILE_BYTES)
|
|
197
197
|
if (!bundle.title) {
|
|
198
|
-
return "
|
|
198
|
+
return "Note title is missing."
|
|
199
199
|
}
|
|
200
200
|
if (!isRegistryPostPublish(bundle.publish)) {
|
|
201
|
-
return "This
|
|
201
|
+
return "This note is not configured for registry publishing."
|
|
202
202
|
}
|
|
203
203
|
if (bundle.media.length > maxFiles) {
|
|
204
|
-
return `
|
|
204
|
+
return `Note has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
|
|
205
205
|
}
|
|
206
206
|
const missing = bundle.media.filter((item) => !item.exists)
|
|
207
207
|
if (missing.length > 0) {
|
|
208
|
-
return `
|
|
208
|
+
return `Note references missing media: ${missing.map((item) => item.ref).join(", ")}`
|
|
209
209
|
}
|
|
210
210
|
const oversized = bundle.media.find((item) => item.bytes > maxFileBytes)
|
|
211
211
|
if (oversized) {
|
|
@@ -268,10 +268,10 @@ async function uploadBundle(registryBase, token, bundle) {
|
|
|
268
268
|
return response.data || {}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
async function uploadDraftFromRequest(
|
|
272
|
-
const item = await
|
|
271
|
+
async function uploadDraftFromRequest(notes, query, token, registryBase, options = {}) {
|
|
272
|
+
const item = await findNoteById(notes, query.draft)
|
|
273
273
|
if (!item) {
|
|
274
|
-
const error = new Error("The local
|
|
274
|
+
const error = new Error("The local note is no longer available.")
|
|
275
275
|
error.status = 404
|
|
276
276
|
throw error
|
|
277
277
|
}
|
|
@@ -286,17 +286,17 @@ async function uploadDraftFromRequest(drafts, query, token, registryBase, option
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
function registerDraftImportRoutes(app, options = {}) {
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
291
|
-
throw new Error("
|
|
289
|
+
const notes = options.notes
|
|
290
|
+
if (!notes) {
|
|
291
|
+
throw new Error("notes is required")
|
|
292
292
|
}
|
|
293
293
|
const defaultRegistryUrl = options.defaultRegistryUrl || "https://beta.pinokio.co"
|
|
294
294
|
const router = express.Router()
|
|
295
295
|
|
|
296
296
|
router.get("/registry/draft-import/authorize-url", asyncHandler(async (req, res) => {
|
|
297
|
-
const item = await
|
|
297
|
+
const item = await findNoteById(notes, req.query.draft)
|
|
298
298
|
if (!item) {
|
|
299
|
-
return res.status(404).json({ error: "The local
|
|
299
|
+
return res.status(404).json({ error: "The local note is no longer available." })
|
|
300
300
|
}
|
|
301
301
|
const bundle = await buildDraftBundle(item, req.query)
|
|
302
302
|
const problem = preflightBundle(bundle, options)
|
|
@@ -316,14 +316,14 @@ function registerDraftImportRoutes(app, options = {}) {
|
|
|
316
316
|
}))
|
|
317
317
|
|
|
318
318
|
router.get("/registry/draft-import/start", asyncHandler(async (req, res) => {
|
|
319
|
-
const item = await
|
|
319
|
+
const item = await findNoteById(notes, req.query.draft)
|
|
320
320
|
if (!item) {
|
|
321
|
-
return renderMessage(res, 404, "
|
|
321
|
+
return renderMessage(res, 404, "Note not found", "The local note is no longer available.")
|
|
322
322
|
}
|
|
323
323
|
const bundle = await buildDraftBundle(item, req.query)
|
|
324
324
|
const problem = preflightBundle(bundle, options)
|
|
325
325
|
if (problem) {
|
|
326
|
-
return renderMessage(res, 400, "
|
|
326
|
+
return renderMessage(res, 400, "Note is not ready", problem)
|
|
327
327
|
}
|
|
328
328
|
const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
|
|
329
329
|
if (!registryBase) {
|
|
@@ -354,7 +354,7 @@ function registerDraftImportRoutes(app, options = {}) {
|
|
|
354
354
|
app: req.body && req.body.app ? String(req.body.app) : ""
|
|
355
355
|
})
|
|
356
356
|
const result = await uploadDraftFromRequest(
|
|
357
|
-
|
|
357
|
+
notes,
|
|
358
358
|
{ draft: req.body && req.body.draft, app: req.body && req.body.app },
|
|
359
359
|
token,
|
|
360
360
|
registryBase,
|
|
@@ -392,7 +392,7 @@ function registerDraftImportRoutes(app, options = {}) {
|
|
|
392
392
|
return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
|
|
393
393
|
}
|
|
394
394
|
try {
|
|
395
|
-
const result = await uploadDraftFromRequest(
|
|
395
|
+
const result = await uploadDraftFromRequest(notes, req.query, token, registryBase, options)
|
|
396
396
|
if (result && result.editUrl) {
|
|
397
397
|
return res.redirect(String(result.editUrl))
|
|
398
398
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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 isInside(candidate, parent) {
|
|
12
|
+
const relative = path.relative(parent, candidate)
|
|
13
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parsePublishConfig(value) {
|
|
17
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
let parsed
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(value)
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
const target = String(parsed.target || "").trim().toLowerCase()
|
|
30
|
+
const type = String(parsed.type || "post").trim().toLowerCase()
|
|
31
|
+
if (target !== "registry" || type !== "post") {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
const publish = { target: "registry", type: "post" }
|
|
35
|
+
const parent = parsed.parent && typeof parsed.parent === "object" && !Array.isArray(parsed.parent)
|
|
36
|
+
? parsed.parent
|
|
37
|
+
: null
|
|
38
|
+
const parentType = parent ? String(parent.type || "").trim().toLowerCase() : ""
|
|
39
|
+
const parentUrl = parent ? String(parent.url || parent.repoUrl || "").trim() : ""
|
|
40
|
+
if (parentType === "app" && parentUrl) {
|
|
41
|
+
publish.parent = { type: "app", url: parentUrl }
|
|
42
|
+
}
|
|
43
|
+
return publish
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function registerNoteRoutes(app, options = {}) {
|
|
47
|
+
const notes = options.notes
|
|
48
|
+
if (!notes) {
|
|
49
|
+
throw new Error("notes is required")
|
|
50
|
+
}
|
|
51
|
+
const kernel = options.kernel
|
|
52
|
+
|
|
53
|
+
const router = express.Router()
|
|
54
|
+
router.get("/notes", asyncHandler(async (req, res) => {
|
|
55
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : ""
|
|
56
|
+
if (cwd && typeof notes.inspectWorkspace === "function") {
|
|
57
|
+
const resolvedCwd = path.resolve(cwd)
|
|
58
|
+
const home = kernel && kernel.homedir ? path.resolve(kernel.homedir) : ""
|
|
59
|
+
if (!home || isInside(resolvedCwd, home)) {
|
|
60
|
+
const publish = parsePublishConfig(req.query.publish)
|
|
61
|
+
const note = publish ? { publish } : undefined
|
|
62
|
+
await notes.inspectWorkspace({ cwd: resolvedCwd, note }).catch(() => null)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const items = await notes.listPending({ cwd })
|
|
66
|
+
res.json({
|
|
67
|
+
ok: true,
|
|
68
|
+
items
|
|
69
|
+
})
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
router.get("/notes/:id/media/:index", asyncHandler(async (req, res) => {
|
|
73
|
+
const item = typeof notes.getPendingById === "function"
|
|
74
|
+
? await notes.getPendingById(req.params.id)
|
|
75
|
+
: null
|
|
76
|
+
if (!item) {
|
|
77
|
+
res.status(404).send("Note not found")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const index = Number(req.params.index)
|
|
81
|
+
const media = Array.isArray(item.media) && Number.isInteger(index)
|
|
82
|
+
? item.media[index]
|
|
83
|
+
: null
|
|
84
|
+
if (!media || !media.exists || !media.path) {
|
|
85
|
+
res.status(404).send("Media not found")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const filePath = path.resolve(media.path)
|
|
89
|
+
const basePath = path.resolve(item.resultDir)
|
|
90
|
+
const relative = path.relative(basePath, filePath)
|
|
91
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
92
|
+
res.status(403).send("Media path is outside the note")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
res.setHeader("Cache-Control", "no-store")
|
|
96
|
+
res.sendFile(filePath)
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
router.put("/notes/:id", express.text({ type: "*/*", limit: "6mb" }), asyncHandler(async (req, res) => {
|
|
100
|
+
if (typeof notes.savePendingById !== "function") {
|
|
101
|
+
res.status(501).json({ ok: false, error: "Note editing is unavailable." })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const markdown = typeof req.body === "string"
|
|
105
|
+
? req.body
|
|
106
|
+
: (req.body && typeof req.body.markdown === "string"
|
|
107
|
+
? req.body.markdown
|
|
108
|
+
: null)
|
|
109
|
+
if (markdown === null) {
|
|
110
|
+
res.status(400).json({ ok: false, error: "markdown is required" })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const revision = typeof req.get("x-pinokio-note-revision") === "string"
|
|
114
|
+
? req.get("x-pinokio-note-revision")
|
|
115
|
+
: (req.body && typeof req.body.revision === "string"
|
|
116
|
+
? req.body.revision
|
|
117
|
+
: "")
|
|
118
|
+
try {
|
|
119
|
+
const item = await notes.savePendingById(req.params.id, { markdown, revision })
|
|
120
|
+
if (!item) {
|
|
121
|
+
res.status(404).json({ ok: false, error: "Note not found" })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
res.json({ ok: true, item })
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error && error.code === "NOTE_CONFLICT") {
|
|
127
|
+
res.status(409).json({
|
|
128
|
+
ok: false,
|
|
129
|
+
error: error.message,
|
|
130
|
+
item: error.item || null
|
|
131
|
+
})
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (error && (error.code === "NOTE_TOO_LARGE" || error.code === "NOTE_INVALID_PATH")) {
|
|
135
|
+
res.status(400).json({ ok: false, error: error.message })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}))
|
|
141
|
+
|
|
142
|
+
router.get("/notes.js", (req, res) => {
|
|
143
|
+
res.setHeader("Cache-Control", "no-store")
|
|
144
|
+
res.sendFile(path.resolve(__dirname, "public", "notes.js"))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
router.get("/notes.css", (req, res) => {
|
|
148
|
+
res.setHeader("Cache-Control", "no-store")
|
|
149
|
+
res.sendFile(path.resolve(__dirname, "public", "notes.css"))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
app.use(router)
|
|
153
|
+
registerDraftImportRoutes(app, options)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = registerNoteRoutes
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
const fs = require("fs")
|
|
2
|
+
const path = require("path")
|
|
3
|
+
const crypto = require("crypto")
|
|
4
|
+
const {
|
|
5
|
+
RESULT_RELATIVE_DIR,
|
|
6
|
+
NOTE_FILENAME,
|
|
7
|
+
METADATA_FILENAME,
|
|
8
|
+
buildExcerpt,
|
|
9
|
+
describeMediaRefs,
|
|
10
|
+
extractTitle,
|
|
11
|
+
parseNoteMetadata
|
|
12
|
+
} = require("./parser")
|
|
13
|
+
|
|
14
|
+
const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
function createHash(value) {
|
|
17
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clonePlainObject(value) {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(JSON.stringify(value))
|
|
26
|
+
} catch (_) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRelativePath(value, fallback) {
|
|
32
|
+
const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
|
|
33
|
+
if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
|
|
34
|
+
return fallback
|
|
35
|
+
}
|
|
36
|
+
const normalized = path.posix.normalize(raw)
|
|
37
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
38
|
+
return fallback
|
|
39
|
+
}
|
|
40
|
+
return normalized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isInside(candidate, parent) {
|
|
44
|
+
const relative = path.relative(path.resolve(parent), path.resolve(candidate))
|
|
45
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeNoteConfig(config = {}) {
|
|
49
|
+
const params = config && typeof config === "object" ? config : {}
|
|
50
|
+
return {
|
|
51
|
+
path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
|
|
52
|
+
content: normalizeRelativePath(params.content, ""),
|
|
53
|
+
ready: normalizeRelativePath(params.ready, METADATA_FILENAME),
|
|
54
|
+
description: typeof params.description === "string" ? params.description.trim() : "",
|
|
55
|
+
publish: clonePlainObject(params.publish)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function findMetadataFiles(rootDir, filename) {
|
|
60
|
+
const root = path.resolve(rootDir)
|
|
61
|
+
const stats = await fs.promises.stat(root).catch(() => null)
|
|
62
|
+
if (!stats || !stats.isDirectory()) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
const target = String(filename || METADATA_FILENAME)
|
|
66
|
+
const results = []
|
|
67
|
+
const visit = async (dir) => {
|
|
68
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => null)
|
|
69
|
+
if (!entries) return
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const fullPath = path.resolve(dir, entry.name)
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await visit(fullPath)
|
|
74
|
+
} else if (entry.isFile() && entry.name === target) {
|
|
75
|
+
results.push(fullPath)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(root)
|
|
80
|
+
return results
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createNoteService({ kernel, taskWorkspaceLinks } = {}) {
|
|
84
|
+
if (!kernel) {
|
|
85
|
+
throw new Error("kernel is required")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const resultsByBundle = new Map()
|
|
89
|
+
|
|
90
|
+
async function readMarkdown(notePath, stats) {
|
|
91
|
+
const noteStats = stats || await fs.promises.stat(notePath)
|
|
92
|
+
if (noteStats.size > MAX_MARKDOWN_BYTES) {
|
|
93
|
+
throw new Error(`note markdown is too large (${noteStats.size} bytes)`)
|
|
94
|
+
}
|
|
95
|
+
return fs.promises.readFile(notePath, "utf8")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readNoteMetadata(metadataPath) {
|
|
99
|
+
if (path.extname(metadataPath).toLowerCase() !== ".json") {
|
|
100
|
+
return {}
|
|
101
|
+
}
|
|
102
|
+
const raw = await fs.promises.readFile(metadataPath, "utf8")
|
|
103
|
+
return parseNoteMetadata(raw)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function inspectNoteBundle({ taskId, ref, cwd, note, bundlePath, metadataPath } = {}) {
|
|
107
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
const workspacePath = path.resolve(cwd.trim())
|
|
111
|
+
const noteConfig = normalizeNoteConfig(note)
|
|
112
|
+
const rootDir = path.resolve(workspacePath, noteConfig.path)
|
|
113
|
+
const resultDir = path.resolve(bundlePath || rootDir)
|
|
114
|
+
const readyPath = path.resolve(metadataPath || path.resolve(resultDir, noteConfig.ready))
|
|
115
|
+
const readyStats = await fs.promises.stat(readyPath).catch(() => null)
|
|
116
|
+
if (!readyStats || !readyStats.isFile()) {
|
|
117
|
+
resultsByBundle.delete(resultDir)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
let metadata = {}
|
|
121
|
+
try {
|
|
122
|
+
metadata = await readNoteMetadata(readyPath)
|
|
123
|
+
} catch (_) {
|
|
124
|
+
resultsByBundle.delete(resultDir)
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const contentPath = normalizeRelativePath(metadata.content || noteConfig.content, NOTE_FILENAME)
|
|
129
|
+
const notePath = path.resolve(resultDir, contentPath)
|
|
130
|
+
const noteStats = await fs.promises.stat(notePath).catch(() => null)
|
|
131
|
+
if (!noteStats || !noteStats.isFile()) {
|
|
132
|
+
resultsByBundle.delete(resultDir)
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const markdown = await readMarkdown(notePath, noteStats)
|
|
137
|
+
const workspaceName = path.basename(workspacePath)
|
|
138
|
+
const bundleName = path.basename(resultDir)
|
|
139
|
+
const media = await describeMediaRefs(markdown, resultDir)
|
|
140
|
+
const updatedAtMs = Math.max(readyStats.mtimeMs || 0, noteStats.mtimeMs || 0)
|
|
141
|
+
const id = createHash(`${workspacePath}|${resultDir}|${notePath}|${readyPath}`)
|
|
142
|
+
const previous = resultsByBundle.get(resultDir) || null
|
|
143
|
+
const publish = noteConfig.publish || (previous && previous.publish) || null
|
|
144
|
+
const description = noteConfig.description || (previous && previous.description) || ""
|
|
145
|
+
const mediaRevision = media
|
|
146
|
+
.map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
|
|
147
|
+
.join("|")
|
|
148
|
+
const revision = createHash(`${noteStats.size}|${noteStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
|
|
149
|
+
const result = {
|
|
150
|
+
id,
|
|
151
|
+
revision,
|
|
152
|
+
taskId,
|
|
153
|
+
ref,
|
|
154
|
+
cwd: workspacePath,
|
|
155
|
+
workspaceName,
|
|
156
|
+
bundleName,
|
|
157
|
+
title: metadata.title || extractTitle(markdown, bundleName || workspaceName),
|
|
158
|
+
markdown,
|
|
159
|
+
excerpt: buildExcerpt(markdown),
|
|
160
|
+
resultDir,
|
|
161
|
+
watchRoot: rootDir,
|
|
162
|
+
notePath,
|
|
163
|
+
contentPath: notePath,
|
|
164
|
+
readyPath,
|
|
165
|
+
metadataPath: readyPath,
|
|
166
|
+
metadata,
|
|
167
|
+
publish,
|
|
168
|
+
description,
|
|
169
|
+
noteBytes: noteStats.size,
|
|
170
|
+
media: media.map((item, index) => ({
|
|
171
|
+
index,
|
|
172
|
+
ref: item.ref,
|
|
173
|
+
path: item.path,
|
|
174
|
+
bytes: item.bytes,
|
|
175
|
+
mtimeMs: item.mtimeMs,
|
|
176
|
+
exists: item.exists,
|
|
177
|
+
ext: path.extname(item.ref || "").toLowerCase()
|
|
178
|
+
})),
|
|
179
|
+
mediaCount: media.length,
|
|
180
|
+
missingMediaCount: media.filter((item) => !item.exists).length,
|
|
181
|
+
mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
|
|
182
|
+
updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
|
|
183
|
+
}
|
|
184
|
+
resultsByBundle.set(resultDir, result)
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function inspectWorkspace({ taskId, ref, cwd, note } = {}) {
|
|
189
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
const workspacePath = path.resolve(cwd.trim())
|
|
193
|
+
const noteConfig = normalizeNoteConfig(note)
|
|
194
|
+
const rootDir = path.resolve(workspacePath, noteConfig.path)
|
|
195
|
+
const metadataFiles = await findMetadataFiles(rootDir, noteConfig.ready)
|
|
196
|
+
const seen = new Set()
|
|
197
|
+
const results = []
|
|
198
|
+
for (const metadataPath of metadataFiles) {
|
|
199
|
+
const bundlePath = path.dirname(metadataPath)
|
|
200
|
+
const result = await inspectNoteBundle({
|
|
201
|
+
taskId,
|
|
202
|
+
ref,
|
|
203
|
+
cwd: workspacePath,
|
|
204
|
+
note: noteConfig,
|
|
205
|
+
bundlePath,
|
|
206
|
+
metadataPath
|
|
207
|
+
})
|
|
208
|
+
if (result) {
|
|
209
|
+
seen.add(result.resultDir)
|
|
210
|
+
results.push(result)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const [bundlePath, result] of Array.from(resultsByBundle.entries())) {
|
|
214
|
+
if (result && result.cwd === workspacePath && result.watchRoot === rootDir && !seen.has(bundlePath)) {
|
|
215
|
+
resultsByBundle.delete(bundlePath)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return results[0] || null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function trackWorkspace({ taskId, ref, cwd } = {}) {
|
|
222
|
+
let resolvedCwd = cwd
|
|
223
|
+
if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
|
|
224
|
+
resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
|
|
225
|
+
}
|
|
226
|
+
if (!resolvedCwd) {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function listPending(options = {}) {
|
|
233
|
+
const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
|
|
234
|
+
? path.resolve(options.cwd.trim())
|
|
235
|
+
: ""
|
|
236
|
+
return Array.from(resultsByBundle.values())
|
|
237
|
+
.filter((result) => !filterCwd || result.cwd === filterCwd)
|
|
238
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function getPendingById(id) {
|
|
242
|
+
const normalizedId = typeof id === "string" ? id.trim() : ""
|
|
243
|
+
if (!normalizedId) {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
return Array.from(resultsByBundle.values()).find((item) => item.id === normalizedId) || null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function refreshPendingItem(item) {
|
|
250
|
+
if (!item || !item.cwd || !item.resultDir || !item.metadataPath) {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
return inspectNoteBundle({
|
|
254
|
+
taskId: item.taskId,
|
|
255
|
+
ref: item.ref,
|
|
256
|
+
cwd: item.cwd,
|
|
257
|
+
note: {
|
|
258
|
+
description: item.description,
|
|
259
|
+
publish: item.publish
|
|
260
|
+
},
|
|
261
|
+
bundlePath: item.resultDir,
|
|
262
|
+
metadataPath: item.metadataPath
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function savePendingById(id, options = {}) {
|
|
267
|
+
const item = await getPendingById(id)
|
|
268
|
+
if (!item) {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
const markdown = typeof options.markdown === "string" ? options.markdown : null
|
|
272
|
+
if (markdown === null) {
|
|
273
|
+
throw new Error("markdown is required")
|
|
274
|
+
}
|
|
275
|
+
if (Buffer.byteLength(markdown, "utf8") > MAX_MARKDOWN_BYTES) {
|
|
276
|
+
const error = new Error(`note markdown is too large; limit is ${MAX_MARKDOWN_BYTES} bytes`)
|
|
277
|
+
error.code = "NOTE_TOO_LARGE"
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const current = await refreshPendingItem(item)
|
|
282
|
+
if (!current) {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
const expectedRevision = typeof options.revision === "string" ? options.revision.trim() : ""
|
|
286
|
+
if (expectedRevision && current.revision !== expectedRevision) {
|
|
287
|
+
const error = new Error("Note changed on disk. Reload it before saving.")
|
|
288
|
+
error.code = "NOTE_CONFLICT"
|
|
289
|
+
error.item = current
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const notePath = path.resolve(current.notePath)
|
|
294
|
+
const resultDir = path.resolve(current.resultDir)
|
|
295
|
+
if (!isInside(notePath, resultDir)) {
|
|
296
|
+
const error = new Error("Note path is outside the note folder")
|
|
297
|
+
error.code = "NOTE_INVALID_PATH"
|
|
298
|
+
throw error
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await fs.promises.writeFile(notePath, markdown, "utf8")
|
|
302
|
+
return refreshPendingItem(current)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function start() {
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function stop() {
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
RESULT_RELATIVE_DIR,
|
|
313
|
+
getPendingById,
|
|
314
|
+
inspectNoteBundle,
|
|
315
|
+
inspectWorkspace,
|
|
316
|
+
listPending,
|
|
317
|
+
savePendingById,
|
|
318
|
+
start,
|
|
319
|
+
stop,
|
|
320
|
+
trackWorkspace
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
createNoteService
|
|
326
|
+
}
|
|
@@ -5,21 +5,19 @@ function isInside(candidate, parent) {
|
|
|
5
5
|
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
class
|
|
8
|
+
class NoteWatcher {
|
|
9
9
|
constructor(options = {}) {
|
|
10
|
-
this.
|
|
10
|
+
this.notes = options.notes
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
async
|
|
14
|
-
if (!this.
|
|
15
|
-
throw new Error("
|
|
13
|
+
async watch(ctx, params = {}) {
|
|
14
|
+
if (!this.notes || typeof this.notes.inspectWorkspace !== "function") {
|
|
15
|
+
throw new Error("note service is unavailable")
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
path: params.path || ".pinokio/
|
|
21
|
-
content: params.content,
|
|
22
|
-
ready: params.ready,
|
|
18
|
+
const noteDir = ctx.resolve(params.path || ".pinokio/notes")
|
|
19
|
+
const noteConfig = {
|
|
20
|
+
path: params.path || ".pinokio/notes",
|
|
23
21
|
description: params.description,
|
|
24
22
|
publish: params.publish
|
|
25
23
|
}
|
|
@@ -28,7 +26,7 @@ class DraftWatcher {
|
|
|
28
26
|
|
|
29
27
|
const inspect = async () => {
|
|
30
28
|
if (disposed) return
|
|
31
|
-
await this.
|
|
29
|
+
await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig })
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
const scheduleInspect = () => {
|
|
@@ -37,7 +35,7 @@ class DraftWatcher {
|
|
|
37
35
|
timer = setTimeout(() => {
|
|
38
36
|
timer = null
|
|
39
37
|
inspect().catch((error) => {
|
|
40
|
-
console.warn("[
|
|
38
|
+
console.warn("[notes] failed to inspect workspace", error && error.message ? error.message : error)
|
|
41
39
|
})
|
|
42
40
|
}, 250)
|
|
43
41
|
}
|
|
@@ -46,12 +44,12 @@ class DraftWatcher {
|
|
|
46
44
|
const stopPoll = ctx.poll(params.interval || 1500, inspect, {
|
|
47
45
|
immediate: false,
|
|
48
46
|
onError: (error) => {
|
|
49
|
-
console.warn("[
|
|
47
|
+
console.warn("[notes] poll failed", error && error.message ? error.message : error)
|
|
50
48
|
}
|
|
51
49
|
})
|
|
52
50
|
const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
|
|
53
51
|
if (!Array.isArray(events)) return
|
|
54
|
-
if (events.some((event) => event && event.path && isInside(path.resolve(event.path),
|
|
52
|
+
if (events.some((event) => event && event.path && isInside(path.resolve(event.path), noteDir))) {
|
|
55
53
|
scheduleInspect()
|
|
56
54
|
}
|
|
57
55
|
})
|
|
@@ -61,7 +59,7 @@ class DraftWatcher {
|
|
|
61
59
|
clearTimeout(timer)
|
|
62
60
|
timer = null
|
|
63
61
|
}
|
|
64
|
-
await this.
|
|
62
|
+
await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig }).catch(() => {})
|
|
65
63
|
disposed = true
|
|
66
64
|
if (typeof stopPoll === "function") {
|
|
67
65
|
await stopPoll()
|
|
@@ -73,4 +71,4 @@ class DraftWatcher {
|
|
|
73
71
|
}
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
module.exports =
|
|
74
|
+
module.exports = NoteWatcher
|