md-annotator 0.5.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/LICENSE +21 -0
- package/README.md +171 -0
- package/client/dist/favicon.svg +12 -0
- package/client/dist/index.html +2989 -0
- package/index.js +168 -0
- package/package.json +61 -0
- package/server/annotator.js +171 -0
- package/server/browser.js +20 -0
- package/server/config.js +36 -0
- package/server/feedback.js +156 -0
- package/server/file.js +43 -0
- package/server/index.js +55 -0
- package/server/notes.js +100 -0
- package/server/routes.js +151 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express server with Promise-based decision flow.
|
|
3
|
+
* Waits for user to click Approve or Submit Feedback in the browser.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { startAnnotatorServer } from './annotator.js'
|
|
7
|
+
import { config } from './config.js'
|
|
8
|
+
|
|
9
|
+
// Re-export for plugin usage
|
|
10
|
+
export { startAnnotatorServer } from './annotator.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create and start the annotation server (CLI compatibility wrapper).
|
|
14
|
+
* Accepts a single file path (string) or an array of file paths.
|
|
15
|
+
* Returns an object with port, waitForDecision(), and shutdown().
|
|
16
|
+
*/
|
|
17
|
+
export async function createServer(targetFilePaths, origin = 'cli', options = {}) {
|
|
18
|
+
const filePaths = Array.isArray(targetFilePaths) ? targetFilePaths : [targetFilePaths]
|
|
19
|
+
let resolveDecision
|
|
20
|
+
|
|
21
|
+
const server = await startAnnotatorServer({
|
|
22
|
+
filePaths,
|
|
23
|
+
origin,
|
|
24
|
+
feedbackNotes: options.feedbackNotes || null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Create a wrapper promise that can be resolved by signals
|
|
28
|
+
const decisionPromise = new Promise((resolve) => {
|
|
29
|
+
resolveDecision = resolve
|
|
30
|
+
// Forward the actual decision
|
|
31
|
+
server.waitForDecision().then(resolve)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Shutdown handler
|
|
35
|
+
function shutdown() {
|
|
36
|
+
server.stop()
|
|
37
|
+
setTimeout(() => process.exit(1), config.forceExitTimeoutMs)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Graceful shutdown on signals
|
|
41
|
+
process.on('SIGTERM', () => {
|
|
42
|
+
resolveDecision({ approved: true })
|
|
43
|
+
shutdown()
|
|
44
|
+
})
|
|
45
|
+
process.on('SIGINT', () => {
|
|
46
|
+
resolveDecision({ approved: true })
|
|
47
|
+
shutdown()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
port: server.port,
|
|
52
|
+
waitForDecision: () => decisionPromise,
|
|
53
|
+
shutdown,
|
|
54
|
+
}
|
|
55
|
+
}
|
package/server/notes.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert feedback notes (from --feedback-notes) to annotation objects.
|
|
3
|
+
* Maps line-referenced notes to block IDs + offsets using the shared parser.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { parseMarkdownToBlocks } from '../client/src/utils/parser.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert an array of notes into annotation objects for a single file.
|
|
11
|
+
*
|
|
12
|
+
* @param {Array<{text: string, line?: number}>} notes
|
|
13
|
+
* @param {string} markdownContent - The raw markdown file content
|
|
14
|
+
* @returns {Array<Object>} Annotation objects ready for the store
|
|
15
|
+
*/
|
|
16
|
+
export function convertNotesToAnnotations(notes, markdownContent) {
|
|
17
|
+
if (!Array.isArray(notes) || notes.length === 0) {
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const blocks = parseMarkdownToBlocks(markdownContent)
|
|
22
|
+
const contentLines = markdownContent.split('\n')
|
|
23
|
+
|
|
24
|
+
return notes
|
|
25
|
+
.filter(note => note && typeof note.text === 'string' && note.text.trim())
|
|
26
|
+
.map(note => {
|
|
27
|
+
if (note.line !== null && typeof note.line === 'number' && note.line > 0) {
|
|
28
|
+
return createLineAnnotation(note, blocks, contentLines)
|
|
29
|
+
}
|
|
30
|
+
return createGlobalAnnotation(note)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createGlobalAnnotation(note) {
|
|
35
|
+
return {
|
|
36
|
+
id: randomUUID(),
|
|
37
|
+
blockId: '',
|
|
38
|
+
startOffset: 0,
|
|
39
|
+
endOffset: 0,
|
|
40
|
+
type: 'NOTES',
|
|
41
|
+
targetType: 'global',
|
|
42
|
+
text: note.text.trim(),
|
|
43
|
+
originalText: '',
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
startMeta: null,
|
|
46
|
+
endMeta: null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createLineAnnotation(note, blocks, contentLines) {
|
|
51
|
+
const line = note.line
|
|
52
|
+
const block = findBlockForLine(blocks, line)
|
|
53
|
+
|
|
54
|
+
if (!block) {
|
|
55
|
+
// Fallback to global if line is out of range
|
|
56
|
+
return createGlobalAnnotation(note)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get the line content and compute offsets within the block
|
|
60
|
+
const lineContent = (line <= contentLines.length) ? contentLines[line - 1] : ''
|
|
61
|
+
const blockLines = block.content.split('\n')
|
|
62
|
+
|
|
63
|
+
// Find which line within the block corresponds to the target line
|
|
64
|
+
const lineWithinBlock = Math.min(line - block.startLine, blockLines.length - 1)
|
|
65
|
+
let startOffset = 0
|
|
66
|
+
for (let i = 0; i < lineWithinBlock && i < blockLines.length; i++) {
|
|
67
|
+
startOffset += blockLines[i].length + 1 // +1 for newline
|
|
68
|
+
}
|
|
69
|
+
const endOffset = startOffset + (blockLines[lineWithinBlock] || '').length
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: randomUUID(),
|
|
73
|
+
blockId: block.id,
|
|
74
|
+
startOffset,
|
|
75
|
+
endOffset,
|
|
76
|
+
type: 'NOTES',
|
|
77
|
+
targetType: undefined,
|
|
78
|
+
text: note.text.trim(),
|
|
79
|
+
originalText: blockLines[lineWithinBlock] || lineContent,
|
|
80
|
+
createdAt: Date.now(),
|
|
81
|
+
startMeta: null,
|
|
82
|
+
endMeta: null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find which block a given line number falls into.
|
|
88
|
+
* Blocks are sorted by startLine; we find the last block whose startLine <= line.
|
|
89
|
+
*/
|
|
90
|
+
function findBlockForLine(blocks, line) {
|
|
91
|
+
let matched = null
|
|
92
|
+
for (const block of blocks) {
|
|
93
|
+
if (block.startLine <= line) {
|
|
94
|
+
matched = block
|
|
95
|
+
} else {
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return matched
|
|
100
|
+
}
|
package/server/routes.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { relative, resolve, dirname, isAbsolute } from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readMarkdownFile, isMarkdownFile } from './file.js'
|
|
5
|
+
import { exportFeedback, exportMultiFileFeedback } from './feedback.js'
|
|
6
|
+
|
|
7
|
+
function success(data) {
|
|
8
|
+
return { success: true, data }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function failure(error) {
|
|
12
|
+
return { success: false, error }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createApiRouter(filePaths, resolveDecision, origin = 'cli', stores = []) {
|
|
16
|
+
const router = Router()
|
|
17
|
+
|
|
18
|
+
// Multi-file endpoint — returns all files
|
|
19
|
+
router.get('/api/files', async (_req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
const files = await Promise.all(
|
|
22
|
+
stores.map(async (store, index) => {
|
|
23
|
+
const content = await readMarkdownFile(store.absolutePath)
|
|
24
|
+
const relativePath = relative(process.cwd(), store.absolutePath) || store.absolutePath
|
|
25
|
+
const currentHash = createHash('sha256').update(content).digest('hex')
|
|
26
|
+
return {
|
|
27
|
+
index,
|
|
28
|
+
path: relativePath,
|
|
29
|
+
content,
|
|
30
|
+
contentHash: currentHash,
|
|
31
|
+
hashMismatch: currentHash !== store.contentHash
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
res.json(success({ files, origin }))
|
|
36
|
+
} catch (error) {
|
|
37
|
+
res.status(500).json(failure(error.message))
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Single-file endpoint — backward compat (returns first file)
|
|
42
|
+
router.get('/api/file', async (_req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const content = await readMarkdownFile(filePaths[0])
|
|
45
|
+
const relativePath = relative(process.cwd(), filePaths[0]) || filePaths[0]
|
|
46
|
+
res.json(success({
|
|
47
|
+
content,
|
|
48
|
+
path: relativePath,
|
|
49
|
+
origin,
|
|
50
|
+
contentHash: stores[0]?.contentHash || null
|
|
51
|
+
}))
|
|
52
|
+
} catch (error) {
|
|
53
|
+
res.status(500).json(failure(error.message))
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Open a linked file (linked navigation)
|
|
58
|
+
const baseDir = process.cwd()
|
|
59
|
+
|
|
60
|
+
router.get('/api/file/open', async (req, res) => {
|
|
61
|
+
const { path: requestedPath, relativeTo } = req.query
|
|
62
|
+
if (!requestedPath) {
|
|
63
|
+
return res.status(400).json(failure('path query parameter required'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const referenceDir = relativeTo
|
|
67
|
+
? dirname(resolve(baseDir, relativeTo))
|
|
68
|
+
: dirname(filePaths[0])
|
|
69
|
+
const absolutePath = resolve(referenceDir, requestedPath)
|
|
70
|
+
|
|
71
|
+
const rel = relative(baseDir, absolutePath)
|
|
72
|
+
if (rel.startsWith('..') || rel === '' || isAbsolute(rel)) {
|
|
73
|
+
return res.status(403).json(failure('Access denied: path outside project directory'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isMarkdownFile(absolutePath)) {
|
|
77
|
+
return res.status(400).json(failure('Not a markdown file'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const content = await readMarkdownFile(absolutePath)
|
|
82
|
+
const contentHash = createHash('sha256').update(content).digest('hex')
|
|
83
|
+
const relativePath = relative(baseDir, absolutePath) || absolutePath
|
|
84
|
+
|
|
85
|
+
let fileIndex = stores.findIndex(s => s.absolutePath === absolutePath)
|
|
86
|
+
if (fileIndex === -1) {
|
|
87
|
+
stores.push({ absolutePath, contentHash, annotations: [] })
|
|
88
|
+
fileIndex = stores.length - 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
res.json(success({ index: fileIndex, path: relativePath, content, contentHash }))
|
|
92
|
+
} catch (error) {
|
|
93
|
+
res.status(404).json(failure(error.message))
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Annotations: scoped by fileIndex query param (default 0)
|
|
98
|
+
router.get('/api/annotations', (req, res) => {
|
|
99
|
+
const fileIndex = parseInt(req.query.fileIndex, 10) || 0
|
|
100
|
+
const store = stores[fileIndex]
|
|
101
|
+
if (!store) {
|
|
102
|
+
return res.json(success({ annotations: [], contentHash: null }))
|
|
103
|
+
}
|
|
104
|
+
res.json(success({
|
|
105
|
+
annotations: store.annotations,
|
|
106
|
+
contentHash: store.contentHash
|
|
107
|
+
}))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
router.post('/api/annotations', (req, res) => {
|
|
111
|
+
const { annotations, fileIndex = 0 } = req.body
|
|
112
|
+
const store = stores[fileIndex]
|
|
113
|
+
if (!store) {
|
|
114
|
+
return res.json(success({ saved: false }))
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(annotations)) {
|
|
117
|
+
return res.status(400).json(failure('annotations must be an array'))
|
|
118
|
+
}
|
|
119
|
+
store.annotations = [...annotations]
|
|
120
|
+
res.json(success({ saved: true, count: annotations.length }))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
router.post('/api/approve', (_req, res) => {
|
|
124
|
+
res.json(success({ message: 'Approved' }))
|
|
125
|
+
setTimeout(() => resolveDecision({ approved: true }), 100)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
router.post('/api/feedback', (req, res) => {
|
|
129
|
+
const { files, annotations, blocks } = req.body
|
|
130
|
+
|
|
131
|
+
// Multi-file format
|
|
132
|
+
if (Array.isArray(files)) {
|
|
133
|
+
const feedback = exportMultiFileFeedback(files)
|
|
134
|
+
const totalCount = files.reduce((sum, f) => sum + (f.annotations?.length || 0), 0)
|
|
135
|
+
res.json(success({ message: 'Feedback submitted' }))
|
|
136
|
+
setTimeout(() => resolveDecision({ approved: false, feedback, annotationCount: totalCount }), 100)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Single-file backward compat
|
|
141
|
+
if (!Array.isArray(annotations) || !Array.isArray(blocks)) {
|
|
142
|
+
return res.status(400).json(failure('Request body must contain "files" array or "annotations" and "blocks" arrays'))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const feedback = exportFeedback(annotations, blocks)
|
|
146
|
+
res.json(success({ message: 'Feedback submitted' }))
|
|
147
|
+
setTimeout(() => resolveDecision({ approved: false, feedback, annotationCount: annotations.length }), 100)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return router
|
|
151
|
+
}
|