mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Add Exception helper class for consistent error handling and logging. Replace throw statements and console.error calls with Exception methods. Update service methods to return Error | T instead of mixed success/failure objects. Improve type safety in Claude.extractContents with explicit return type. Add WikiLinks helper to VaultCacheService for managing wiki link references. Update unit tests.
164 lines
No EOL
6.1 KiB
TypeScript
164 lines
No EOL
6.1 KiB
TypeScript
import { normalizePath } from "obsidian";
|
|
|
|
export interface ISanitizeOptions {
|
|
replacement?: string;
|
|
separator?: string;
|
|
}
|
|
|
|
// this service handles edge cases that 'normalizePath()' misses
|
|
|
|
export class SanitiserService {
|
|
// Regular expressions for different character classes
|
|
private readonly illegalRe = /[?<>\\:*|"]/g;
|
|
// eslint-disable-next-line no-control-regex -- Intentionally matching control characters for sanitization
|
|
private readonly controlRe = /[\u0000-\u001f\u0080-\u009f]/g;
|
|
private readonly reservedRe = /^\.+$/;
|
|
private readonly windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
private readonly windowsTrailingRe = /[. ]+$/;
|
|
|
|
constructor() { }
|
|
|
|
/**
|
|
* Sanitizes a file path with directories, removing illegal characters and ensuring cross-platform compatibility
|
|
* @param input - The file path to sanitize
|
|
* @param options - Optional configuration for replacement character and output separator
|
|
* @returns Sanitized file path
|
|
*/
|
|
public sanitize(input: string, options: ISanitizeOptions = {}): string {
|
|
// use obsidian helper first
|
|
input = normalizePath(input);
|
|
|
|
// Normalize empty string to "/" for vault root
|
|
if (input.trim() === "") {
|
|
return "/";
|
|
}
|
|
|
|
// Default options
|
|
const replacement = options.replacement || "";
|
|
const outputSeparator = options.separator || "/";
|
|
|
|
// Detect if this is an absolute path
|
|
const isAbsolute = input.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(input);
|
|
|
|
// Detect Windows drive letter (e.g., C:)
|
|
const driveMatch = input.match(/^([a-zA-Z]:)[\\/]/);
|
|
const driveLetter = driveMatch ? driveMatch[1] : "";
|
|
|
|
// Split by both forward and back slashes
|
|
// Note: Forward slashes and backslashes are treated as path separators,
|
|
// not as illegal characters within segments. This is intentional for cross-platform compatibility.
|
|
let segments = input.split(/[\\/]+/);
|
|
|
|
// Remove empty segments (from leading/trailing slashes or multiple consecutive slashes)
|
|
// But keep track of whether we started with a slash
|
|
segments = segments.filter((seg, index) => {
|
|
// Keep the first segment even if empty (for absolute paths like /home/...)
|
|
if (index === 0 && seg === "" && isAbsolute && !driveLetter) {
|
|
return true;
|
|
}
|
|
return seg !== "";
|
|
});
|
|
|
|
// Sanitize each segment
|
|
const sanitizedSegments = segments.map((segment, index) => {
|
|
// Don"t sanitize the drive letter (first segment if it"s something like "C:")
|
|
if (index === 0 && driveLetter && segment === driveLetter.replace(":", "")) {
|
|
return driveLetter;
|
|
}
|
|
|
|
// For the first empty segment of an absolute path, keep it empty
|
|
if (index === 0 && segment === "" && isAbsolute) {
|
|
return "";
|
|
}
|
|
|
|
return this.sanitizeSegment(segment, replacement);
|
|
});
|
|
|
|
// Rejoin with the desired separator
|
|
let result = sanitizedSegments.join(outputSeparator);
|
|
|
|
// For absolute Unix paths, ensure leading slash
|
|
if (isAbsolute && !driveLetter && !result.startsWith(outputSeparator)) {
|
|
result = outputSeparator + result;
|
|
}
|
|
|
|
// Truncate the entire path if needed (paths can be up to 4096 bytes on most systems)
|
|
// But for individual filenames, most systems have a 255-byte limit
|
|
// We"ll apply the 255-byte limit to the filename part only
|
|
const lastSeparatorIndex = result.lastIndexOf(outputSeparator);
|
|
if (lastSeparatorIndex !== -1) {
|
|
const directory = result.substring(0, lastSeparatorIndex + 1);
|
|
const filename = result.substring(lastSeparatorIndex + 1);
|
|
const truncatedFilename = this.truncateToByteLength(filename, 255);
|
|
result = directory + truncatedFilename;
|
|
} else {
|
|
// If there"s no separator, the whole thing is a filename
|
|
result = this.truncateToByteLength(result, 255);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a single path segment (filename or directory name)
|
|
* @param segment - The path segment to sanitize
|
|
* @param replacement - Character to replace illegal characters with
|
|
* @returns Sanitized segment, or a fallback value if the result would be empty
|
|
*/
|
|
private sanitizeSegment(segment: string, replacement: string): string {
|
|
if (!segment || segment === "") {
|
|
return segment;
|
|
}
|
|
|
|
let sanitized = segment
|
|
.replace(this.illegalRe, replacement)
|
|
.replace(this.controlRe, replacement)
|
|
.replace(this.reservedRe, replacement)
|
|
.replace(this.windowsReservedRe, replacement)
|
|
.replace(this.windowsTrailingRe, replacement);
|
|
|
|
// Handle case where sanitization results in an empty string
|
|
// This can happen with names like "...", "CON", or strings containing only illegal characters
|
|
if (sanitized === "") {
|
|
sanitized = "unnamed";
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Truncates a string to a maximum byte length while preserving UTF-8 character integrity
|
|
* This method ensures that multi-byte UTF-8 characters are not cut in the middle,
|
|
* which would result in invalid UTF-8 sequences.
|
|
*
|
|
* @param str - String to truncate
|
|
* @param maxBytes - Maximum byte length
|
|
* @returns Truncated string with valid UTF-8 encoding
|
|
*/
|
|
private truncateToByteLength(str: string, maxBytes: number): string {
|
|
const encoder = new TextEncoder();
|
|
const encoded = encoder.encode(str);
|
|
|
|
if (encoded.length <= maxBytes) {
|
|
return str;
|
|
}
|
|
|
|
// Truncate at maxBytes, then work backwards to find a valid UTF-8 boundary
|
|
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
|
|
let truncateAt = maxBytes;
|
|
while (truncateAt > 0 && (encoded[truncateAt] & 0xC0) === 0x80) {
|
|
truncateAt--;
|
|
}
|
|
|
|
// Decode with fatal mode to ensure we get a valid UTF-8 string
|
|
// If decoding fails (which shouldn"t happen with our boundary logic), fall back to safe decode
|
|
try {
|
|
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
return decoder.decode(encoded.slice(0, truncateAt));
|
|
} catch {
|
|
// Fallback: use non-fatal mode if something unexpected happens
|
|
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
return decoder.decode(encoded.slice(0, truncateAt));
|
|
}
|
|
}
|
|
} |