|
| 1 | +/** |
| 2 | + * Utility functions for date picker functionality |
| 3 | + * Provides precise date matching using syntax tree and task line detection |
| 4 | + */ |
| 5 | + |
| 6 | +import { EditorState, Text, Line } from "@codemirror/state"; |
| 7 | +// @ts-ignore - This import is necessary but TypeScript can't find it |
| 8 | +import { syntaxTree } from "@codemirror/language"; |
| 9 | + |
| 10 | +/** |
| 11 | + * Whitelist of date emoji markers to prevent false positives |
| 12 | + */ |
| 13 | +export const DATE_EMOJI_WHITELIST = [ |
| 14 | + "📅", // Calendar |
| 15 | + "🚀", // Rocket (start date) |
| 16 | + "✅", // Check mark (completed) |
| 17 | + "❌", // Cross mark (cancelled) |
| 18 | + "🛫", // Airplane departure (scheduled) |
| 19 | + "⏰", // Alarm clock (due) |
| 20 | + "🏁", // Checkered flag (deadline) |
| 21 | + "▶️", // Play button (start) |
| 22 | +]; |
| 23 | + |
| 24 | +/** |
| 25 | + * Interface representing a matched date in the document |
| 26 | + */ |
| 27 | +export interface DateMatch { |
| 28 | + from: number; // Absolute position in document |
| 29 | + to: number; // Absolute position in document |
| 30 | + dateText: string; // YYYY-MM-DD format |
| 31 | + marker: string; // Emoji or [field:: prefix |
| 32 | + fullMatch: string; // Complete matched text |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Interface for widget information tracking |
| 37 | + */ |
| 38 | +export interface WidgetInfo { |
| 39 | + id: string; |
| 40 | + match: DateMatch; |
| 41 | + lineNumber: number; |
| 42 | + offsetInLine: number; |
| 43 | + lastValidated: number; |
| 44 | +} |
| 45 | + |
| 46 | +/** |
| 47 | + * Check if a position is inside a task line using syntax tree |
| 48 | + * @param state Editor state |
| 49 | + * @param pos Position to check |
| 50 | + * @returns true if inside a task line |
| 51 | + */ |
| 52 | +export function isInsideTaskLine(state: EditorState, pos: number): boolean { |
| 53 | + try { |
| 54 | + // Get the line at this position first |
| 55 | + const line = state.doc.lineAt(pos); |
| 56 | + const lineText = line.text; |
| 57 | + |
| 58 | + // Quick check: must match task syntax |
| 59 | + const taskRegex = /^\s*[-*+]\s*\[.]/; |
| 60 | + if (!taskRegex.test(lineText)) { |
| 61 | + return false; |
| 62 | + } |
| 63 | + |
| 64 | + // Use syntax tree for more precise detection |
| 65 | + const tree = syntaxTree(state); |
| 66 | + let node = tree.resolveInner(pos, -1); |
| 67 | + |
| 68 | + // Traverse up to find list-item node |
| 69 | + while (node) { |
| 70 | + if (node.name === "list-item" || node.name.includes("list")) { |
| 71 | + return true; |
| 72 | + } |
| 73 | + node = node.parent; |
| 74 | + } |
| 75 | + |
| 76 | + // Fallback: if syntax tree doesn't help, rely on regex |
| 77 | + return taskRegex.test(lineText); |
| 78 | + } catch (e) { |
| 79 | + console.warn("Error checking task line:", e); |
| 80 | + return false; |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Find the task line at a given position |
| 86 | + * @param state Editor state |
| 87 | + * @param pos Position to check |
| 88 | + * @returns Task line info or null |
| 89 | + */ |
| 90 | +export function findTaskLineAt( |
| 91 | + state: EditorState, |
| 92 | + pos: number |
| 93 | +): { from: number; to: number; text: string; line: Line } | null { |
| 94 | + try { |
| 95 | + if (!isInsideTaskLine(state, pos)) { |
| 96 | + return null; |
| 97 | + } |
| 98 | + |
| 99 | + const line = state.doc.lineAt(pos); |
| 100 | + return { |
| 101 | + from: line.from, |
| 102 | + to: line.to, |
| 103 | + text: line.text, |
| 104 | + line: line, |
| 105 | + }; |
| 106 | + } catch (e) { |
| 107 | + console.warn("Error finding task line:", e); |
| 108 | + return null; |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Validate if a date string is in correct format |
| 114 | + * Prevents malformed dates like "2024-12-25-25" from being matched |
| 115 | + * @param dateStr Date string to validate |
| 116 | + * @returns true if valid |
| 117 | + */ |
| 118 | +function isValidDateFormat(dateStr: string): boolean { |
| 119 | + // Must match YYYY-MM-DD or YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS |
| 120 | + const validDateRegex = /^\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?$/; |
| 121 | + |
| 122 | + if (!validDateRegex.test(dateStr)) { |
| 123 | + return false; |
| 124 | + } |
| 125 | + |
| 126 | + // Additional validation: ensure there are no extra dashes or numbers after the date |
| 127 | + const parts = dateStr.split(/[\s:]/); |
| 128 | + const datePart = parts[0]; |
| 129 | + const dateComponents = datePart.split("-"); |
| 130 | + |
| 131 | + // Must have exactly 3 components for date (year, month, day) |
| 132 | + if (dateComponents.length !== 3) { |
| 133 | + return false; |
| 134 | + } |
| 135 | + |
| 136 | + // Validate ranges |
| 137 | + const year = parseInt(dateComponents[0], 10); |
| 138 | + const month = parseInt(dateComponents[1], 10); |
| 139 | + const day = parseInt(dateComponents[2], 10); |
| 140 | + |
| 141 | + if (year < 1900 || year > 2100) return false; |
| 142 | + if (month < 1 || month > 12) return false; |
| 143 | + if (day < 1 || day > 31) return false; |
| 144 | + |
| 145 | + return true; |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * Find all dates in a task line |
| 150 | + * @param lineText Task line text |
| 151 | + * @param lineStart Absolute start position of the line |
| 152 | + * @param preferDataview Whether to use dataview format |
| 153 | + * @returns Array of date matches |
| 154 | + */ |
| 155 | +export function findDatesInTaskLine( |
| 156 | + lineText: string, |
| 157 | + lineStart: number, |
| 158 | + preferDataview: boolean |
| 159 | +): DateMatch[] { |
| 160 | + const matches: DateMatch[] = []; |
| 161 | + |
| 162 | + try { |
| 163 | + if (preferDataview) { |
| 164 | + // Dataview format: [field:: YYYY-MM-DD] |
| 165 | + // Match common field names for dates |
| 166 | + const dataviewRegex = |
| 167 | + /\[([a-zA-Z]+)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?)\]/g; |
| 168 | + let match: RegExpExecArray | null; |
| 169 | + |
| 170 | + while ((match = dataviewRegex.exec(lineText)) !== null) { |
| 171 | + const dateStr = match[2]; |
| 172 | + |
| 173 | + matches.push({ |
| 174 | + from: lineStart + match.index, |
| 175 | + to: lineStart + match.index + match[0].length, |
| 176 | + dateText: dateStr, |
| 177 | + marker: `[${match[1]}::`, |
| 178 | + fullMatch: match[0], |
| 179 | + }); |
| 180 | + } |
| 181 | + } else { |
| 182 | + // Emoji format: use whitelist to prevent false positives |
| 183 | + for (const emoji of DATE_EMOJI_WHITELIST) { |
| 184 | + const escapedEmoji = escapeRegex(emoji); |
| 185 | + // Strict regex: date must be followed by word boundary or space |
| 186 | + // This prevents matching "2024-12-25-25" |
| 187 | + const emojiRegex = new RegExp( |
| 188 | + `${escapedEmoji}\\s*(\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?)(?=\\s|$|\\])`, |
| 189 | + "g" |
| 190 | + ); |
| 191 | + let match: RegExpExecArray | null; |
| 192 | + |
| 193 | + while ((match = emojiRegex.exec(lineText)) !== null) { |
| 194 | + const dateStr = match[1]; |
| 195 | + |
| 196 | + matches.push({ |
| 197 | + from: lineStart + match.index, |
| 198 | + to: lineStart + match.index + match[0].length, |
| 199 | + dateText: dateStr, |
| 200 | + marker: emoji, |
| 201 | + fullMatch: match[0], |
| 202 | + }); |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + } catch (e) { |
| 207 | + console.warn("Error finding dates in task line:", e); |
| 208 | + } |
| 209 | + |
| 210 | + // Filter out overlapping matches (keep the first one) |
| 211 | + const filtered: DateMatch[] = []; |
| 212 | + for (const match of matches) { |
| 213 | + const overlaps = filtered.some( |
| 214 | + (existing) => |
| 215 | + (match.from >= existing.from && match.from < existing.to) || |
| 216 | + (match.to > existing.from && match.to <= existing.to) |
| 217 | + ); |
| 218 | + if (!overlaps) { |
| 219 | + filtered.push(match); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + return filtered; |
| 224 | +} |
| 225 | + |
| 226 | +/** |
| 227 | + * Generate a unique ID for a widget based on its match |
| 228 | + * @param match Date match |
| 229 | + * @param lineNumber Line number |
| 230 | + * @returns Unique widget ID |
| 231 | + */ |
| 232 | +export function generateWidgetId(match: DateMatch, lineNumber: number): string { |
| 233 | + // Use line number + marker + offset for stable ID |
| 234 | + const offset = match.from; |
| 235 | + return `widget-${lineNumber}-${encodeURIComponent(match.marker)}-${offset}`; |
| 236 | +} |
| 237 | + |
| 238 | +/** |
| 239 | + * Validate that a position range is valid in the document |
| 240 | + * @param state Editor state |
| 241 | + * @param from Start position |
| 242 | + * @param to End position |
| 243 | + * @returns true if valid |
| 244 | + */ |
| 245 | +export function isValidPosition( |
| 246 | + state: EditorState, |
| 247 | + from: number, |
| 248 | + to: number |
| 249 | +): boolean { |
| 250 | + try { |
| 251 | + // Basic range checks |
| 252 | + if (from < 0 || to < 0) { |
| 253 | + return false; |
| 254 | + } |
| 255 | + |
| 256 | + if (from > state.doc.length || to > state.doc.length) { |
| 257 | + return false; |
| 258 | + } |
| 259 | + |
| 260 | + if (from >= to) { |
| 261 | + return false; |
| 262 | + } |
| 263 | + |
| 264 | + // Ensure positions are on the same line |
| 265 | + const fromLine = state.doc.lineAt(from); |
| 266 | + const toLine = state.doc.lineAt(to); |
| 267 | + |
| 268 | + if (fromLine.number !== toLine.number) { |
| 269 | + return false; |
| 270 | + } |
| 271 | + |
| 272 | + return true; |
| 273 | + } catch (e) { |
| 274 | + return false; |
| 275 | + } |
| 276 | +} |
| 277 | + |
| 278 | +/** |
| 279 | + * Safely get a line by number |
| 280 | + * @param doc Document |
| 281 | + * @param lineNumber Line number (1-based) |
| 282 | + * @returns Line or null if invalid |
| 283 | + */ |
| 284 | +export function safeGetLine(doc: Text, lineNumber: number): Line | null { |
| 285 | + try { |
| 286 | + if (lineNumber < 1 || lineNumber > doc.lines) { |
| 287 | + return null; |
| 288 | + } |
| 289 | + return doc.line(lineNumber); |
| 290 | + } catch (e) { |
| 291 | + return null; |
| 292 | + } |
| 293 | +} |
| 294 | + |
| 295 | +/** |
| 296 | + * Escape special regex characters |
| 297 | + * @param str String to escape |
| 298 | + * @returns Escaped string |
| 299 | + */ |
| 300 | +export function escapeRegex(str: string): string { |
| 301 | + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 302 | +} |
| 303 | + |
| 304 | +/** |
| 305 | + * Get affected line numbers from a change set |
| 306 | + * @param state Editor state |
| 307 | + * @param changes Change set |
| 308 | + * @returns Set of affected line numbers |
| 309 | + */ |
| 310 | +export function getAffectedLineNumbers( |
| 311 | + state: EditorState, |
| 312 | + changes: { |
| 313 | + iterChangedRanges: ( |
| 314 | + f: (fromA: number, toA: number, fromB: number, toB: number) => void |
| 315 | + ) => void; |
| 316 | + } |
| 317 | +): Set<number> { |
| 318 | + const lines = new Set<number>(); |
| 319 | + |
| 320 | + try { |
| 321 | + changes.iterChangedRanges((fromA, toA, fromB, toB) => { |
| 322 | + try { |
| 323 | + // Get line numbers in the new document |
| 324 | + const startLine = state.doc.lineAt(fromB).number; |
| 325 | + const endLine = state.doc.lineAt( |
| 326 | + Math.min(toB, state.doc.length) |
| 327 | + ).number; |
| 328 | + |
| 329 | + for (let i = startLine; i <= endLine; i++) { |
| 330 | + lines.add(i); |
| 331 | + } |
| 332 | + } catch (e) { |
| 333 | + // Ignore errors for individual ranges |
| 334 | + } |
| 335 | + }); |
| 336 | + } catch (e) { |
| 337 | + console.warn("Error getting affected lines:", e); |
| 338 | + } |
| 339 | + |
| 340 | + return lines; |
| 341 | +} |
| 342 | + |
| 343 | +/** |
| 344 | + * Check if a node is inside a code block or frontmatter |
| 345 | + * @param state Editor state |
| 346 | + * @param from Start position |
| 347 | + * @param to End position |
| 348 | + * @returns true if should skip rendering |
| 349 | + */ |
| 350 | +export function shouldSkipRendering( |
| 351 | + state: EditorState, |
| 352 | + from: number, |
| 353 | + to: number |
| 354 | +): boolean { |
| 355 | + try { |
| 356 | + const tree = syntaxTree(state); |
| 357 | + const node = tree.resolveInner(from, 1); |
| 358 | + |
| 359 | + // Check node and parent nodes for special contexts |
| 360 | + let current = node; |
| 361 | + while (current) { |
| 362 | + const nodeName = current.name.toLowerCase(); |
| 363 | + const nodeType = current.type.name.toLowerCase(); |
| 364 | + |
| 365 | + // Skip code blocks |
| 366 | + if ( |
| 367 | + nodeName.includes("code") || |
| 368 | + nodeName.includes("fenced") || |
| 369 | + nodeType.includes("code") |
| 370 | + ) { |
| 371 | + return true; |
| 372 | + } |
| 373 | + |
| 374 | + // Skip frontmatter |
| 375 | + if ( |
| 376 | + nodeName.includes("frontmatter") || |
| 377 | + nodeType.includes("frontmatter") |
| 378 | + ) { |
| 379 | + return true; |
| 380 | + } |
| 381 | + |
| 382 | + current = current.parent; |
| 383 | + } |
| 384 | + |
| 385 | + return false; |
| 386 | + } catch (e) { |
| 387 | + // On error, default to rendering (better than breaking) |
| 388 | + return false; |
| 389 | + } |
| 390 | +} |
0 commit comments