md-annotator 0.5.2 → 0.5.4
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 +44 -57
- package/client/dist/index.html +220 -220
- package/client/src/utils/export.js +244 -0
- package/client/src/utils/parser.js +245 -0
- package/client/src/utils/storage.js +44 -0
- package/client/src/utils/textStats.js +8 -0
- package/index.js +12 -3
- package/package.json +3 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export utilities for annotations.
|
|
3
|
+
* Mirrors server/feedback.js format for consistency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format annotations as exportable markdown.
|
|
8
|
+
*/
|
|
9
|
+
export function formatAnnotationsForExport(annotations, blocks, filePath) {
|
|
10
|
+
if (annotations.length === 0) {
|
|
11
|
+
return 'No annotations.'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sorted = [...annotations].sort((a, b) => {
|
|
15
|
+
const blockA = blocks.findIndex(blk => blk.id === a.blockId)
|
|
16
|
+
const blockB = blocks.findIndex(blk => blk.id === b.blockId)
|
|
17
|
+
if (blockA !== blockB) {return blockA - blockB}
|
|
18
|
+
return a.startOffset - b.startOffset
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const globalComments = sorted.filter(a => a.targetType === 'global')
|
|
22
|
+
const regularAnnotations = sorted.filter(a => a.targetType !== 'global')
|
|
23
|
+
|
|
24
|
+
let output = `# Annotation Feedback\n\n`
|
|
25
|
+
output += `**File:** \`${filePath}\`\n\n`
|
|
26
|
+
output += `**Count:** ${annotations.length} annotation${annotations.length > 1 ? 's' : ''}\n\n`
|
|
27
|
+
output += `---\n\n`
|
|
28
|
+
|
|
29
|
+
if (globalComments.length > 0) {
|
|
30
|
+
output += `## General Feedback\n\n`
|
|
31
|
+
globalComments.forEach(ann => {
|
|
32
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
regularAnnotations.forEach((ann, index) => {
|
|
37
|
+
const block = blocks.find(blk => blk.id === ann.blockId)
|
|
38
|
+
const blockStartLine = block?.startLine || 1
|
|
39
|
+
|
|
40
|
+
// Element-level annotations
|
|
41
|
+
if (ann.targetType === 'image') {
|
|
42
|
+
const isDeletion = ann.type === 'DELETION'
|
|
43
|
+
const label = isDeletion ? 'Remove image' : 'Comment on image'
|
|
44
|
+
output += `## ${index + 1}. ${label} (Line ${blockStartLine})\n\n`
|
|
45
|
+
output += `Image: \`${ann.originalText}\`\n\n`
|
|
46
|
+
if (ann.imageAlt) { output += `Alt text: "${ann.imageAlt}"\n\n` }
|
|
47
|
+
if (ann.imageSrc) { output += `Source: ${ann.imageSrc}\n\n` }
|
|
48
|
+
if (isDeletion) {
|
|
49
|
+
output += `> User wants this image removed from the document.\n\n`
|
|
50
|
+
} else {
|
|
51
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (ann.targetType === 'diagram') {
|
|
56
|
+
const isDeletion = ann.type === 'DELETION'
|
|
57
|
+
const label = isDeletion ? 'Remove Mermaid diagram' : 'Comment on Mermaid diagram'
|
|
58
|
+
output += `## ${index + 1}. ${label} (Line ${blockStartLine})\n\n`
|
|
59
|
+
output += `\`\`\`mermaid\n${block?.content || ann.originalText}\n\`\`\`\n\n`
|
|
60
|
+
if (isDeletion) {
|
|
61
|
+
output += `> User wants this diagram removed from the document.\n\n`
|
|
62
|
+
} else {
|
|
63
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
|
|
64
|
+
}
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const blockContent = block?.content || ''
|
|
69
|
+
const textBeforeSelection = blockContent.slice(0, ann.startOffset)
|
|
70
|
+
const linesBeforeSelection = (textBeforeSelection.match(/\n/g) || []).length
|
|
71
|
+
const startLine = blockStartLine + linesBeforeSelection
|
|
72
|
+
const newlinesInSelection = (ann.originalText.match(/\n/g) || []).length
|
|
73
|
+
const endLine = startLine + newlinesInSelection
|
|
74
|
+
|
|
75
|
+
const lineRef = startLine === endLine ? `Line ${startLine}` : `Lines ${startLine}-${endLine}`
|
|
76
|
+
|
|
77
|
+
output += `## ${index + 1}. `
|
|
78
|
+
|
|
79
|
+
if (ann.type === 'DELETION') {
|
|
80
|
+
output += `Remove (${lineRef})\n\n`
|
|
81
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
|
|
82
|
+
} else if (ann.type === 'COMMENT') {
|
|
83
|
+
output += `Comment (${lineRef})\n\n`
|
|
84
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
|
|
85
|
+
output += `> ${ann.text}\n\n`
|
|
86
|
+
} else if (ann.type === 'INSERTION') {
|
|
87
|
+
output += `Insert text (${lineRef})\n\n`
|
|
88
|
+
if (ann.afterContext) {
|
|
89
|
+
output += `After: \`${ann.afterContext}\`\n\n`
|
|
90
|
+
}
|
|
91
|
+
output += `\`\`\`\n${ann.text}\n\`\`\`\n\n`
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return output
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Copy text to clipboard with fallback.
|
|
100
|
+
*/
|
|
101
|
+
export async function copyToClipboard(text) {
|
|
102
|
+
try {
|
|
103
|
+
await navigator.clipboard.writeText(text)
|
|
104
|
+
return true
|
|
105
|
+
} catch {
|
|
106
|
+
const textarea = document.createElement('textarea')
|
|
107
|
+
textarea.value = text
|
|
108
|
+
textarea.style.position = 'fixed'
|
|
109
|
+
textarea.style.opacity = '0'
|
|
110
|
+
document.body.appendChild(textarea)
|
|
111
|
+
textarea.select()
|
|
112
|
+
document.execCommand('copy')
|
|
113
|
+
document.body.removeChild(textarea)
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Trigger a browser file download from a Blob.
|
|
120
|
+
*/
|
|
121
|
+
function downloadBlob(blob, filename) {
|
|
122
|
+
const url = URL.createObjectURL(blob)
|
|
123
|
+
const a = document.createElement('a')
|
|
124
|
+
a.href = url
|
|
125
|
+
a.download = filename
|
|
126
|
+
document.body.appendChild(a)
|
|
127
|
+
a.click()
|
|
128
|
+
document.body.removeChild(a)
|
|
129
|
+
URL.revokeObjectURL(url)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Download text as Markdown file.
|
|
134
|
+
*/
|
|
135
|
+
export function downloadAsFile(content, filename = 'annotations.md') {
|
|
136
|
+
downloadBlob(new Blob([content], { type: 'text/markdown' }), filename)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Format annotations as JSON export with metadata.
|
|
141
|
+
*/
|
|
142
|
+
export function formatAnnotationsForJsonExport(annotations, filePath, contentHash) {
|
|
143
|
+
return {
|
|
144
|
+
version: 1,
|
|
145
|
+
filePath,
|
|
146
|
+
contentHash,
|
|
147
|
+
exportedAt: new Date().toISOString(),
|
|
148
|
+
annotations: annotations.map(ann => {
|
|
149
|
+
const base = {
|
|
150
|
+
id: ann.id,
|
|
151
|
+
blockId: ann.blockId,
|
|
152
|
+
startOffset: ann.startOffset,
|
|
153
|
+
endOffset: ann.endOffset,
|
|
154
|
+
type: ann.type,
|
|
155
|
+
text: ann.text,
|
|
156
|
+
originalText: ann.originalText,
|
|
157
|
+
createdAt: ann.createdAt,
|
|
158
|
+
startMeta: ann.startMeta,
|
|
159
|
+
endMeta: ann.endMeta
|
|
160
|
+
}
|
|
161
|
+
if (ann.targetType) { base.targetType = ann.targetType }
|
|
162
|
+
if (ann.imageAlt !== undefined) { base.imageAlt = ann.imageAlt }
|
|
163
|
+
if (ann.imageSrc !== undefined) { base.imageSrc = ann.imageSrc }
|
|
164
|
+
if (ann.afterContext !== undefined) { base.afterContext = ann.afterContext }
|
|
165
|
+
return base
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Download data as JSON file.
|
|
172
|
+
*/
|
|
173
|
+
export function downloadAsJsonFile(data, filename = 'annotations.json') {
|
|
174
|
+
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), filename)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate imported JSON annotation data.
|
|
179
|
+
*/
|
|
180
|
+
const REQUIRED_ANNOTATION_FIELDS = [
|
|
181
|
+
'id', 'blockId', 'startOffset', 'endOffset',
|
|
182
|
+
'type', 'originalText'
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
export function validateAnnotationImport(data) {
|
|
186
|
+
if (!data || typeof data !== 'object') {
|
|
187
|
+
return { valid: false, error: 'Invalid JSON format' }
|
|
188
|
+
}
|
|
189
|
+
if (data.version !== 1) {
|
|
190
|
+
return { valid: false, error: `Unsupported version: ${data.version}` }
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(data.annotations)) {
|
|
193
|
+
return { valid: false, error: 'Missing annotations array' }
|
|
194
|
+
}
|
|
195
|
+
if (data.annotations.length > 10000) {
|
|
196
|
+
return { valid: false, error: 'Too many annotations (max 10,000)' }
|
|
197
|
+
}
|
|
198
|
+
for (const ann of data.annotations) {
|
|
199
|
+
for (const field of REQUIRED_ANNOTATION_FIELDS) {
|
|
200
|
+
if (ann[field] === undefined) {
|
|
201
|
+
return { valid: false, error: `Annotation missing required field: ${field}` }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (typeof ann.id !== 'string' || typeof ann.blockId !== 'string') {
|
|
205
|
+
return { valid: false, error: 'Annotation id and blockId must be strings' }
|
|
206
|
+
}
|
|
207
|
+
if (typeof ann.startOffset !== 'number' || typeof ann.endOffset !== 'number') {
|
|
208
|
+
return { valid: false, error: 'Annotation offsets must be numbers' }
|
|
209
|
+
}
|
|
210
|
+
if (ann.startOffset < 0 || ann.endOffset < 0 || ann.endOffset < ann.startOffset) {
|
|
211
|
+
return { valid: false, error: 'Invalid annotation offset values' }
|
|
212
|
+
}
|
|
213
|
+
if (ann.type !== 'DELETION' && ann.type !== 'COMMENT' && ann.type !== 'INSERTION') {
|
|
214
|
+
return { valid: false, error: `Invalid annotation type: ${ann.type}` }
|
|
215
|
+
}
|
|
216
|
+
if (typeof ann.originalText !== 'string') {
|
|
217
|
+
return { valid: false, error: 'Annotation originalText must be a string' }
|
|
218
|
+
}
|
|
219
|
+
if (ann.text !== null && ann.text !== undefined && typeof ann.text !== 'string') {
|
|
220
|
+
return { valid: false, error: 'Annotation text must be a string or null' }
|
|
221
|
+
}
|
|
222
|
+
if (ann.targetType && ann.targetType !== 'image' && ann.targetType !== 'diagram' && ann.targetType !== 'global') {
|
|
223
|
+
return { valid: false, error: `Invalid annotation targetType: ${ann.targetType}` }
|
|
224
|
+
}
|
|
225
|
+
// Element annotations (image/diagram/global) and insertions have null startMeta/endMeta
|
|
226
|
+
const isElement = ann.targetType === 'image' || ann.targetType === 'diagram' || ann.targetType === 'global'
|
|
227
|
+
const isInsertion = ann.type === 'INSERTION'
|
|
228
|
+
if (!isElement && !isInsertion) {
|
|
229
|
+
if (!ann.startMeta || typeof ann.startMeta !== 'object') {
|
|
230
|
+
return { valid: false, error: 'Annotation startMeta must be an object' }
|
|
231
|
+
}
|
|
232
|
+
if (!ann.endMeta || typeof ann.endMeta !== 'object') {
|
|
233
|
+
return { valid: false, error: 'Annotation endMeta must be an object' }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
valid: true,
|
|
239
|
+
error: null,
|
|
240
|
+
annotations: data.annotations,
|
|
241
|
+
filePath: data.filePath || null,
|
|
242
|
+
contentHash: data.contentHash || null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const HTML_BLOCK_TAGS = new Set([
|
|
2
|
+
'address', 'article', 'aside', 'blockquote', 'center', 'dd', 'details',
|
|
3
|
+
'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure',
|
|
4
|
+
'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup',
|
|
5
|
+
'hr', 'iframe', 'main', 'menu', 'nav', 'ol', 'p', 'picture', 'pre',
|
|
6
|
+
'section', 'source', 'summary', 'table', 'tbody', 'td', 'template',
|
|
7
|
+
'tfoot', 'th', 'thead', 'tr', 'ul', 'video'
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
const HTML_VOID_TAGS = new Set([
|
|
11
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
12
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr'
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Simplified markdown parser that splits content into linear blocks.
|
|
17
|
+
* Designed for predictable text-anchoring (not AST-based).
|
|
18
|
+
*/
|
|
19
|
+
export function parseMarkdownToBlocks(markdown) {
|
|
20
|
+
const lines = markdown.split('\n')
|
|
21
|
+
const blocks = []
|
|
22
|
+
let currentId = 0
|
|
23
|
+
let buffer = []
|
|
24
|
+
let currentType = 'paragraph'
|
|
25
|
+
const currentLevel = 0
|
|
26
|
+
let bufferStartLine = 1
|
|
27
|
+
|
|
28
|
+
const flush = () => {
|
|
29
|
+
if (buffer.length > 0) {
|
|
30
|
+
const content = buffer.join('\n')
|
|
31
|
+
blocks.push({
|
|
32
|
+
id: `block-${currentId++}`,
|
|
33
|
+
type: currentType,
|
|
34
|
+
content,
|
|
35
|
+
level: currentLevel,
|
|
36
|
+
order: currentId,
|
|
37
|
+
startLine: bufferStartLine
|
|
38
|
+
})
|
|
39
|
+
buffer = []
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i]
|
|
45
|
+
const trimmed = line.trim()
|
|
46
|
+
const currentLineNum = i + 1
|
|
47
|
+
|
|
48
|
+
// Headings
|
|
49
|
+
if (trimmed.startsWith('#')) {
|
|
50
|
+
flush()
|
|
51
|
+
const level = trimmed.match(/^#+/)?.[0].length || 1
|
|
52
|
+
blocks.push({
|
|
53
|
+
id: `block-${currentId++}`,
|
|
54
|
+
type: 'heading',
|
|
55
|
+
content: trimmed.replace(/^#+\s*/, ''),
|
|
56
|
+
level,
|
|
57
|
+
order: currentId,
|
|
58
|
+
startLine: currentLineNum
|
|
59
|
+
})
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Horizontal Rule
|
|
64
|
+
if (trimmed === '---' || trimmed === '***') {
|
|
65
|
+
flush()
|
|
66
|
+
blocks.push({
|
|
67
|
+
id: `block-${currentId++}`,
|
|
68
|
+
type: 'hr',
|
|
69
|
+
content: '',
|
|
70
|
+
order: currentId,
|
|
71
|
+
startLine: currentLineNum
|
|
72
|
+
})
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// List Items
|
|
77
|
+
if (trimmed.match(/^(\*|-|\d+\.)\s/)) {
|
|
78
|
+
flush()
|
|
79
|
+
const leadingWhitespace = line.match(/^(\s*)/)?.[1] || ''
|
|
80
|
+
const spaceCount = leadingWhitespace.replace(/\t/g, ' ').length
|
|
81
|
+
const listLevel = Math.floor(spaceCount / 2)
|
|
82
|
+
|
|
83
|
+
let content = trimmed.replace(/^(\*|-|\d+\.)\s/, '')
|
|
84
|
+
|
|
85
|
+
let checked = undefined
|
|
86
|
+
const checkboxMatch = content.match(/^\[([ xX])\]\s*/)
|
|
87
|
+
if (checkboxMatch) {
|
|
88
|
+
checked = checkboxMatch[1].toLowerCase() === 'x'
|
|
89
|
+
content = content.replace(/^\[([ xX])\]\s*/, '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
blocks.push({
|
|
93
|
+
id: `block-${currentId++}`,
|
|
94
|
+
type: 'list-item',
|
|
95
|
+
content,
|
|
96
|
+
level: listLevel,
|
|
97
|
+
checked,
|
|
98
|
+
order: currentId,
|
|
99
|
+
startLine: currentLineNum
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Blockquotes
|
|
105
|
+
if (trimmed.startsWith('>')) {
|
|
106
|
+
flush()
|
|
107
|
+
blocks.push({
|
|
108
|
+
id: `block-${currentId++}`,
|
|
109
|
+
type: 'blockquote',
|
|
110
|
+
content: trimmed.replace(/^>\s*/, ''),
|
|
111
|
+
order: currentId,
|
|
112
|
+
startLine: currentLineNum
|
|
113
|
+
})
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Code blocks
|
|
118
|
+
if (trimmed.startsWith('```')) {
|
|
119
|
+
flush()
|
|
120
|
+
const codeStartLine = currentLineNum
|
|
121
|
+
const language = trimmed.slice(3).trim() || undefined
|
|
122
|
+
const codeContent = []
|
|
123
|
+
i++
|
|
124
|
+
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
|
125
|
+
codeContent.push(lines[i])
|
|
126
|
+
i++
|
|
127
|
+
}
|
|
128
|
+
blocks.push({
|
|
129
|
+
id: `block-${currentId++}`,
|
|
130
|
+
type: 'code',
|
|
131
|
+
content: codeContent.join('\n'),
|
|
132
|
+
language,
|
|
133
|
+
order: currentId,
|
|
134
|
+
startLine: codeStartLine
|
|
135
|
+
})
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Tables
|
|
140
|
+
if (trimmed.startsWith('|') || (trimmed.includes('|') && trimmed.match(/^\|?.+\|.+\|?$/))) {
|
|
141
|
+
flush()
|
|
142
|
+
const tableStartLine = currentLineNum
|
|
143
|
+
const tableLines = [line]
|
|
144
|
+
|
|
145
|
+
while (i + 1 < lines.length) {
|
|
146
|
+
const nextLine = lines[i + 1].trim()
|
|
147
|
+
if (nextLine.startsWith('|') || (nextLine.includes('|') && nextLine.match(/^\|?.+\|.+\|?$/))) {
|
|
148
|
+
i++
|
|
149
|
+
tableLines.push(lines[i])
|
|
150
|
+
} else {
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
blocks.push({
|
|
156
|
+
id: `block-${currentId++}`,
|
|
157
|
+
type: 'table',
|
|
158
|
+
content: tableLines.join('\n'),
|
|
159
|
+
order: currentId,
|
|
160
|
+
startLine: tableStartLine
|
|
161
|
+
})
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// HTML blocks
|
|
166
|
+
if (trimmed.startsWith('<')) {
|
|
167
|
+
// HTML comments
|
|
168
|
+
if (trimmed.startsWith('<!--')) {
|
|
169
|
+
flush()
|
|
170
|
+
const htmlStartLine = currentLineNum
|
|
171
|
+
const htmlLines = [line]
|
|
172
|
+
if (!trimmed.includes('-->')) {
|
|
173
|
+
i++
|
|
174
|
+
while (i < lines.length) {
|
|
175
|
+
htmlLines.push(lines[i])
|
|
176
|
+
if (lines[i].includes('-->')) { break }
|
|
177
|
+
i++
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
blocks.push({
|
|
181
|
+
id: `block-${currentId++}`,
|
|
182
|
+
type: 'html',
|
|
183
|
+
content: htmlLines.join('\n'),
|
|
184
|
+
order: currentId,
|
|
185
|
+
startLine: htmlStartLine
|
|
186
|
+
})
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Block-level HTML tags
|
|
191
|
+
const tagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)[\s>/]/)
|
|
192
|
+
if (tagMatch && HTML_BLOCK_TAGS.has(tagMatch[1].toLowerCase())) {
|
|
193
|
+
flush()
|
|
194
|
+
const tagName = tagMatch[1].toLowerCase()
|
|
195
|
+
const htmlStartLine = currentLineNum
|
|
196
|
+
const htmlLines = [line]
|
|
197
|
+
|
|
198
|
+
const isSelfClosing = trimmed.endsWith('/>')
|
|
199
|
+
const isVoid = HTML_VOID_TAGS.has(tagName)
|
|
200
|
+
const hasSameLineClose = new RegExp(`</${tagName}\\s*>`, 'i').test(trimmed)
|
|
201
|
+
|
|
202
|
+
if (!isSelfClosing && !isVoid && !hasSameLineClose) {
|
|
203
|
+
const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
|
|
204
|
+
const openPattern = new RegExp(`<${tagName}[\\s>/]`, 'i')
|
|
205
|
+
let depth = 1
|
|
206
|
+
i++
|
|
207
|
+
while (i < lines.length) {
|
|
208
|
+
htmlLines.push(lines[i])
|
|
209
|
+
if (openPattern.test(lines[i])) { depth++ }
|
|
210
|
+
if (closePattern.test(lines[i])) {
|
|
211
|
+
depth--
|
|
212
|
+
if (depth === 0) { break }
|
|
213
|
+
}
|
|
214
|
+
i++
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
blocks.push({
|
|
219
|
+
id: `block-${currentId++}`,
|
|
220
|
+
type: 'html',
|
|
221
|
+
content: htmlLines.join('\n'),
|
|
222
|
+
order: currentId,
|
|
223
|
+
startLine: htmlStartLine
|
|
224
|
+
})
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Empty lines separate paragraphs
|
|
230
|
+
if (trimmed === '') {
|
|
231
|
+
flush()
|
|
232
|
+
currentType = 'paragraph'
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Accumulate paragraph text
|
|
237
|
+
if (buffer.length === 0) {
|
|
238
|
+
bufferStartLine = currentLineNum
|
|
239
|
+
}
|
|
240
|
+
buffer.push(line)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
flush()
|
|
244
|
+
return blocks
|
|
245
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie-based storage utility.
|
|
3
|
+
*
|
|
4
|
+
* Uses cookies instead of localStorage so settings persist across
|
|
5
|
+
* different ports (each session uses a random port).
|
|
6
|
+
* Cookies are scoped by domain, not port, so localhost:54321 and
|
|
7
|
+
* localhost:54322 share the same cookies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365
|
|
11
|
+
|
|
12
|
+
function escapeRegex(str) {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getItem(key) {
|
|
17
|
+
try {
|
|
18
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`))
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setItem(key, value) {
|
|
26
|
+
try {
|
|
27
|
+
const encoded = encodeURIComponent(value)
|
|
28
|
+
document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`
|
|
29
|
+
} catch {
|
|
30
|
+
// Cookie not available
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const AUTO_CLOSE_KEY = 'md-annotator-auto-close'
|
|
35
|
+
|
|
36
|
+
export function getAutoCloseDelay() {
|
|
37
|
+
const val = getItem(AUTO_CLOSE_KEY)
|
|
38
|
+
if (val === '0' || val === '3' || val === '5') {return val}
|
|
39
|
+
return 'off'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setAutoCloseDelay(delay) {
|
|
43
|
+
setItem(AUTO_CLOSE_KEY, delay)
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const WORDS_PER_MINUTE = 200
|
|
2
|
+
|
|
3
|
+
export function getTextStats(text) {
|
|
4
|
+
const lines = text.split('\n').length
|
|
5
|
+
const words = text.split(/\s+/).filter(Boolean).length
|
|
6
|
+
const readingTime = Math.max(1, Math.ceil(words / WORDS_PER_MINUTE))
|
|
7
|
+
return { lines, words, readingTime }
|
|
8
|
+
}
|
package/index.js
CHANGED
|
@@ -18,9 +18,10 @@ Options:
|
|
|
18
18
|
--feedback-notes <json|path> AI notes to display as read-only annotations
|
|
19
19
|
|
|
20
20
|
Environment:
|
|
21
|
-
MD_ANNOTATOR_PORT
|
|
22
|
-
MD_ANNOTATOR_BROWSER
|
|
23
|
-
MD_ANNOTATOR_TIMEOUT
|
|
21
|
+
MD_ANNOTATOR_PORT Base port (default: 3000)
|
|
22
|
+
MD_ANNOTATOR_BROWSER Custom browser app name
|
|
23
|
+
MD_ANNOTATOR_TIMEOUT Heartbeat timeout in ms (default: 30000, range: 5000–300000)
|
|
24
|
+
MD_ANNOTATOR_FEEDBACK_NOTES JSON string or file path for feedback notes
|
|
24
25
|
|
|
25
26
|
Examples:
|
|
26
27
|
md-annotator README.md
|
|
@@ -86,6 +87,14 @@ function parseArgs(argv) {
|
|
|
86
87
|
return { error: `Unknown origin "${origin}". Valid: ${validOrigins.join(', ')}` }
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
if (!feedbackNotes && process.env.MD_ANNOTATOR_FEEDBACK_NOTES) {
|
|
91
|
+
try {
|
|
92
|
+
feedbackNotes = parseFeedbackNotes(process.env.MD_ANNOTATOR_FEEDBACK_NOTES)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { error: `MD_ANNOTATOR_FEEDBACK_NOTES: ${err.message}` }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
return { filePaths, origin, feedbackNotes }
|
|
90
99
|
}
|
|
91
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-annotator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Browser-based Markdown annotator for AI-assisted review",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"server",
|
|
12
|
-
"client/dist"
|
|
12
|
+
"client/dist",
|
|
13
|
+
"client/src/utils"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
15
16
|
"start": "node index.js",
|