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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }