667ccd70cd
Backend security & correctness: - Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login, register, forgot/reset password endpoints (10/60s or 5/300s per IP) - app.py: add security headers in after_request (X-Frame-Options, CSP, X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers - auth.py: refactor duplicate login_required/admin_required into shared _check_auth() - config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution - routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route - routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates - services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id); reset_password_with_token returns int|None (user_id) instead of bool - services/backup.py: add _security_notice to full backup JSON export - services/assist.py: system prompt explicitly preserves markdown list structure and nested indented sub-items Infrastructure: - docker-compose.yml: add healthcheck on app service (/api/health, 10s interval) - .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build Frontend bug fixes: - TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare) - useAssist.ts: accept() now resets state to idle when document changed since proposal - stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus) - TiptapEditor.vue: selection offset uses closest-match strategy (not first-match) - utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const (DOMPurify expects mutable string[]) New features: - Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when editing an existing dirty record; silent on error, shows "Auto-saved" toast - sectionParser.ts: top-level bullet/numbered list items are now individual sections in the AI Assist panel (previously treated as one undifferentiated block) - editor-shared.css: extracted ~500 lines of CSS duplicated between both editors; includes .inline-assist-btn at global scope (required for teleported elements) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
4.3 KiB
TypeScript
143 lines
4.3 KiB
TypeScript
export interface MarkdownSection {
|
|
heading: string;
|
|
level: number;
|
|
content: string;
|
|
startOffset: number;
|
|
endOffset: number;
|
|
}
|
|
|
|
const HEADING_RE = /^(#{1,6})\s+(.*)$/gm;
|
|
|
|
function parseFallbackSections(body: string): MarkdownSection[] {
|
|
if (!body.trim())
|
|
return [];
|
|
|
|
const isPseudoHeading = (text: string): boolean => {
|
|
const t = text.trim();
|
|
return (
|
|
t.length > 0 && t.length <= 120 &&
|
|
!t.includes('\n') &&
|
|
!/^[-*+>]/.test(t) &&
|
|
!/^\d+\./.test(t) &&
|
|
!/^`{3,}/.test(t)
|
|
);
|
|
};
|
|
|
|
// Build paragraph list with exact start/end offsets
|
|
const separatorRe = /\n{2,}/g;
|
|
const paragraphs: Array<{ text: string; start: number; end: number }> = [];
|
|
let lastEnd = 0;
|
|
let sep: RegExpExecArray | null;
|
|
while ((sep = separatorRe.exec(body)) !== null) {
|
|
if (sep.index > lastEnd)
|
|
paragraphs.push({ text: body.slice(lastEnd, sep.index), start: lastEnd, end: sep.index });
|
|
lastEnd = sep.index + sep[0].length;
|
|
}
|
|
if (lastEnd < body.length)
|
|
paragraphs.push({ text: body.slice(lastEnd), start: lastEnd, end: body.length });
|
|
|
|
if (paragraphs.length === 0)
|
|
return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }];
|
|
|
|
const headingIndices = new Set(
|
|
paragraphs.map((p, i) => isPseudoHeading(p.text) ? i : -1).filter(i => i >= 0)
|
|
);
|
|
if (headingIndices.size === 0) {
|
|
// No pseudo-headings — check if body is a bullet/numbered list.
|
|
// If so, treat each top-level item (no leading spaces) as a section.
|
|
const TOP_BULLET_RE = /^(?:[-*+]|\d+\.) /gm;
|
|
const bulletStarts: number[] = [];
|
|
let bm: RegExpExecArray | null;
|
|
while ((bm = TOP_BULLET_RE.exec(body)) !== null) {
|
|
bulletStarts.push(bm.index);
|
|
}
|
|
if (bulletStarts.length > 1) {
|
|
return bulletStarts.map((startIdx, i) => {
|
|
const endIdx = i + 1 < bulletStarts.length ? bulletStarts[i + 1] : body.length;
|
|
const content = body.slice(startIdx, endIdx);
|
|
const firstLine = content.split('\n')[0].replace(/^(?:[-*+]|\d+\.) /, '').trim();
|
|
return {
|
|
heading: firstLine.length > 50 ? firstLine.slice(0, 50) + '\u2026' : firstLine,
|
|
level: 0,
|
|
content,
|
|
startOffset: startIdx,
|
|
endOffset: endIdx,
|
|
};
|
|
});
|
|
}
|
|
return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }];
|
|
}
|
|
|
|
const sections: MarkdownSection[] = [];
|
|
const headingIdxArray = [...headingIndices].sort((a, b) => a - b);
|
|
const firstHi = headingIdxArray[0];
|
|
|
|
// Preamble (content before first pseudo-heading)
|
|
if (firstHi > 0) {
|
|
sections.push({
|
|
heading: '', level: 0,
|
|
content: body.slice(paragraphs[0].start, paragraphs[firstHi].start),
|
|
startOffset: paragraphs[0].start,
|
|
endOffset: paragraphs[firstHi].start,
|
|
});
|
|
}
|
|
|
|
// One section per pseudo-heading
|
|
for (let h = 0; h < headingIdxArray.length; h++) {
|
|
const hi = headingIdxArray[h];
|
|
const nextHi = h + 1 < headingIdxArray.length ? headingIdxArray[h + 1] : null;
|
|
const sectionEnd = nextHi !== null ? paragraphs[nextHi].start : body.length;
|
|
sections.push({
|
|
heading: paragraphs[hi].text.trim(),
|
|
level: 0,
|
|
content: body.slice(paragraphs[hi].start, sectionEnd),
|
|
startOffset: paragraphs[hi].start,
|
|
endOffset: sectionEnd,
|
|
});
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
export function parseMarkdownSections(body: string): MarkdownSection[] {
|
|
const sections: MarkdownSection[] = [];
|
|
const matches: { index: number; level: number; heading: string }[] = [];
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = HEADING_RE.exec(body)) !== null) {
|
|
matches.push({
|
|
index: match.index,
|
|
level: match[1].length,
|
|
heading: match[0],
|
|
});
|
|
}
|
|
|
|
// Preamble: text before the first heading
|
|
if (matches.length === 0) {
|
|
return parseFallbackSections(body);
|
|
}
|
|
|
|
if (matches[0].index > 0) {
|
|
sections.push({
|
|
heading: "",
|
|
level: 0,
|
|
content: body.slice(0, matches[0].index),
|
|
startOffset: 0,
|
|
endOffset: matches[0].index,
|
|
});
|
|
}
|
|
|
|
for (let i = 0; i < matches.length; i++) {
|
|
const start = matches[i].index;
|
|
const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
|
|
sections.push({
|
|
heading: matches[i].heading,
|
|
level: matches[i].level,
|
|
content: body.slice(start, end),
|
|
startOffset: start,
|
|
endOffset: end,
|
|
});
|
|
}
|
|
|
|
return sections;
|
|
}
|