From 177a40129fe874c3fc2df33b1b990dd0da6275dc Mon Sep 17 00:00:00 2001 From: kinboy Date: Fri, 28 Nov 2025 15:05:28 +0800 Subject: [PATCH 1/3] fix: improve media file type detection and handle blob URLs correctly - Fix TypeScript error: add null check for previewMode.state - Add detectMediaTypeFromSignature() method to detect image/video types from magic bytes - Improve blob URL handling: detect media type before skipping - Add Content-Type header detection for file types - Improve file type validation: use detected type when URL extension is invalid - Skip blob URLs only if they don't contain recognizable media files This fixes the 415 Unsupported Media Type error when uploading notes with code styler plugin enabled, which generates blob URLs for codeblock icons. Fixes issues with: - Code Styler plugin blob URLs causing 415 errors - Invalid file type detection from blob URLs - Missing file type detection for media files --- src/note.ts | 139 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 125 insertions(+), 14 deletions(-) diff --git a/src/note.ts b/src/note.ts index da34fe8..e1c1a3f 100644 --- a/src/note.ts +++ b/src/note.ts @@ -96,7 +96,9 @@ export default class Note { const startMode = this.leaf.getViewState() const previewMode = this.leaf.getViewState() - previewMode.state.mode = 'preview' + if (previewMode.state) { + previewMode.state.mode = 'preview' + } await this.leaf.setViewState(previewMode) await new Promise(resolve => setTimeout(resolve, 40)) // Scroll the view to the top to ensure we get the default margins for .markdown-preview-pusher @@ -379,6 +381,60 @@ export default class Note { /** * Upload media attachments */ + /** + * Detect image/video file type from file signature (magic bytes) + */ + detectMediaTypeFromSignature (content: ArrayBuffer): string | undefined { + const bytes = new Uint8Array(content, 0, 12) + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { + return 'png' + } + + // JPEG: FF D8 FF + if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { + return 'jpg' + } + + // GIF: 47 49 46 38 (GIF8) + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return 'gif' + } + + // WebP: RIFF...WEBP + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) { + // Check for WEBP at offset 8 + if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { + return 'webp' + } + } + + // BMP: 42 4D + if (bytes[0] === 0x42 && bytes[1] === 0x4D) { + return 'bmp' + } + + // SVG: Check if content starts with XML/SVG markers + const textDecoder = new TextDecoder('utf-8', { fatal: false }) + const textStart = textDecoder.decode(bytes.slice(0, 100)) + if (textStart.trim().startsWith(' el.setAttribute('src', url) - }) + let filetype = parsed.pathname.split('.').pop() + + // If filetype from URL is invalid (e.g., UUID from blob URL), use detected type + // Valid file extensions are typically 2-5 characters and contain only alphanumeric characters + if (!filetype || filetype.length > 10 || !/^[a-z0-9]+$/i.test(filetype)) { + filetype = detectedFiletype } + + // Final check: if we still don't have a valid filetype, skip this file + if (!filetype || !content) { + continue + } + + const hash = await sha1(content) + await this.plugin.api.queueUpload({ + data: { + filetype, + hash, + content, + byteLength: content.byteLength, + expiration: this.expiration + }, + callback: (url) => el.setAttribute('src', url) + }) el.removeAttribute('alt') } return this.plugin.api.processQueue(this.status) From c9635be88e549af049a871898d6bda9220a36ae2 Mon Sep 17 00:00:00 2001 From: kinboy Date: Wed, 17 Dec 2025 23:17:12 +0800 Subject: [PATCH 2/3] feat: Add CSS chunking support and unify CSS file format This commit implements CSS file chunking for large files and unifies the CSS data structure to use an array format for both single files and multiple chunks. Key changes: - Add splitCssIntoChunks() method to split large CSS files (>500KB) at rule boundaries to avoid breaking CSS syntax - Unify CSS data structure: use array format for both single files and multiple chunks in CheckFilesResult and NoteTemplate - Fix CSS byte length calculation using TextEncoder for accurate UTF-8 encoding (fixes ERR_CONTENT_LENGTH_MISMATCH) - Fix empty array check in processCss() to properly handle empty cssResult arrays - Add backward compatibility: auto-convert old CSS format to new array format - Generate unique hash for each chunk (includes chunk index) to ensure different filenames on server Benefits: - Large CSS files are automatically split into smaller chunks, improving page load performance - Unified array format simplifies server-side processing - Each chunk has unique hash for proper server-side validation - Backward compatible with existing shared notes Files changed: - src/api.ts: Update CheckFilesResult interface to use array format - src/NoteTemplate.ts: Update css field to use array format - src/note.ts: Add chunking logic and update CSS processing --- src/NoteTemplate.ts | 4 + src/api.ts | 4 +- src/note.ts | 196 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 192 insertions(+), 12 deletions(-) diff --git a/src/NoteTemplate.ts b/src/NoteTemplate.ts index a21df80..690ac69 100644 --- a/src/NoteTemplate.ts +++ b/src/NoteTemplate.ts @@ -35,4 +35,8 @@ export default class NoteTemplate { encrypted: boolean content: string mathJax: boolean + css?: Array<{ + url: string + hash: string + }> } diff --git a/src/api.ts b/src/api.ts index c6db533..798a7de 100644 --- a/src/api.ts +++ b/src/api.ts @@ -36,10 +36,10 @@ export interface UploadQueueItem { export interface CheckFilesResult { success: boolean files: FileUpload[] - css?: { + css?: Array<{ url: string hash: string - } + }> } export default class API { diff --git a/src/note.ts b/src/note.ts index e1c1a3f..df1cd80 100644 --- a/src/note.ts +++ b/src/note.ts @@ -271,7 +271,35 @@ export default class Note { // Process CSS and images const uploadResult = await this.processMedia() - this.cssResult = uploadResult.css + // Convert old format to new array format for compatibility + if (uploadResult.css) { + // Check if it's already in array format (new format) + if (Array.isArray(uploadResult.css)) { + // Only set if array is not empty + this.cssResult = uploadResult.css.length > 0 ? uploadResult.css : undefined + } else { + // Convert old format { url, hash, urls? } to new array format + const oldCss = uploadResult.css as any + if (oldCss.urls && Array.isArray(oldCss.urls) && oldCss.urls.length > 0) { + // Old format with urls array - convert to new format + // Note: old format didn't have hash for each chunk, so we use the main hash + this.cssResult = oldCss.urls.map((url: string) => ({ + url, + hash: oldCss.hash || '' + })) + } else if (oldCss.url) { + // Old format with single URL - convert to array with single element + this.cssResult = [{ + url: oldCss.url, + hash: oldCss.hash || '' + }] + } else { + this.cssResult = undefined + } + } + } else { + this.cssResult = undefined + } await this.processCss() /* @@ -345,6 +373,11 @@ export default class Note { // Check for MathJax this.template.mathJax = !!this.contentDom.body.innerHTML.match(/ 0) { + this.template.css = this.cssResult + } + // Share the file this.status.setStatus('Uploading note...') let shareLink = await this.plugin.api.createNote(this.template, this.expiration) @@ -533,6 +566,81 @@ export default class Note { return this.plugin.api.processQueue(this.status) } + /** + * Split CSS into chunks at rule boundaries to avoid breaking CSS syntax + * @param css CSS content to split + * @param maxChunkSize Maximum size of each chunk in bytes (default: 500KB) + * @returns Array of CSS chunks + */ + splitCssIntoChunks (css: string, maxChunkSize: number = 500 * 1024): string[] { + const encoder = new TextEncoder() + const cssBytes = encoder.encode(css) + + // If CSS is smaller than maxChunkSize, return as single chunk + if (cssBytes.length <= maxChunkSize) { + return [css] + } + + const chunks: string[] = [] + let currentChunk = '' + let currentChunkSize = 0 + let braceDepth = 0 + let inString = false + let stringChar = '' + let i = 0 + + while (i < css.length) { + const char = css[i] + const charBytes = encoder.encode(char).length + + // Track string boundaries to avoid splitting inside strings + if (!inString && (char === '"' || char === "'")) { + inString = true + stringChar = char + } else if (inString && char === stringChar && css[i - 1] !== '\\') { + inString = false + stringChar = '' + } + + // Track brace depth to find rule boundaries + if (!inString) { + if (char === '{') { + braceDepth++ + } else if (char === '}') { + braceDepth-- + } + } + + currentChunk += char + currentChunkSize += charBytes + + // If current chunk exceeds max size, try to split at a safe point + if (currentChunkSize >= maxChunkSize) { + // Prefer splitting at end of rule (braceDepth === 0, char === '}') + if (braceDepth === 0 && !inString && char === '}') { + chunks.push(currentChunk) + currentChunk = '' + currentChunkSize = 0 + } else if (currentChunkSize >= maxChunkSize * 1.5) { + // If chunk is 1.5x larger than max, force split even if not at perfect boundary + // This prevents extremely large chunks if CSS has very long rules + chunks.push(currentChunk) + currentChunk = '' + currentChunkSize = 0 + } + } + + i++ + } + + // Add remaining chunk + if (currentChunk.length > 0) { + chunks.push(currentChunk) + } + + return chunks.length > 0 ? chunks : [css] + } + /** * Upload theme CSS, unless this file has previously been shared, * or the user has requested a force re-upload @@ -541,7 +649,9 @@ export default class Note { // Upload the main CSS file only if the user has asked for it. // We do it this way to ensure that the CSS the user wants on the server // stays that way, until they ASK to overwrite it. - if (this.isForceUpload || !this.cssResult) { + // Check if cssResult is empty array or doesn't exist + const hasCssResult = this.cssResult && this.cssResult.length > 0 + if (this.isForceUpload || !hasCssResult) { // Extract any attachments from the CSS. // Will use the mime-type whitelist to determine which attachments to extract. this.status.setStatus('Processing CSS...') @@ -607,16 +717,81 @@ export default class Note { await this.plugin.api.processQueue(this.status, 'CSS attachment') this.status.setStatus('Uploading CSS...') const minified = minify(this.css).css + + // Calculate actual byte length for UTF-8 encoded string + const encoder = new TextEncoder() + const cssBytes = encoder.encode(minified) const cssHash = await sha1(minified) + try { - if (cssHash !== this.cssResult?.hash) { - await this.plugin.api.upload({ - filetype: 'css', - hash: cssHash, - content: minified, - byteLength: minified.length, - expiration: this.expiration - }) + // Split CSS into chunks if it's larger than 500KB to avoid blocking page load + const CSS_CHUNK_SIZE = 500 * 1024 // 500KB per chunk + const chunks = this.splitCssIntoChunks(minified, CSS_CHUNK_SIZE) + const needsSplitting = chunks.length > 1 + + const hasExistingCss = this.cssResult && this.cssResult.length > 0 + const hasExistingChunks = hasExistingCss && (this.cssResult?.length || 0) > 1 + const needsResplit = needsSplitting && !hasExistingChunks + const hashChanged = !hasExistingCss || cssHash !== (this.cssResult?.[0]?.hash) + + + // Upload if hash changed, needs resplit, or force upload + if (hashChanged || needsResplit || this.isForceUpload) { + if (needsSplitting) { + // Upload multiple CSS chunks + this.status.setStatus(`Uploading CSS chunks (${chunks.length} files)...`) + const cssFiles: Array<{ url: string; hash: string }> = [] + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + const chunkBytes = encoder.encode(chunk) + + const chunkContentHash = await sha1(chunk) + + // Generate unique hash for filename generation (includes chunk index to ensure uniqueness) + const chunkHashForFilename = await sha1(`${i + 1}-${chunkContentHash}-${chunks.length}`) + + this.status.setStatus(`Uploading CSS chunk ${i + 1} of ${chunks.length}...`) + const chunkUrl = await this.plugin.api.upload({ + filetype: 'css', + hash: chunkHashForFilename, // Unique hash for filename generation + content: chunk, // Original CSS content + byteLength: chunkBytes.length, + expiration: this.expiration + }) + + if (chunkUrl) { + cssFiles.push({ + url: chunkUrl, + hash: chunkContentHash + }) + const chunkSizeKB = (chunkBytes.length / 1024).toFixed(2) + this.status.setStatus(`CSS chunk ${i + 1}/${chunks.length} uploaded: ${chunkUrl} (${chunkSizeKB} KB)`) + } + } + + this.cssResult = cssFiles + } else { + // Single CSS file (small enough, use array with single element) + if (hashChanged) { + const singleCssUrl = await this.plugin.api.upload({ + filetype: 'css', + hash: cssHash, + content: minified, + byteLength: cssBytes.length, + expiration: this.expiration + }) + + if (singleCssUrl) { + const cssSizeKB = (cssBytes.length / 1024).toFixed(2) + this.status.setStatus(`CSS uploaded: ${singleCssUrl} (${cssSizeKB} KB)`) + this.cssResult = [{ + url: singleCssUrl, + hash: cssHash + }] + } + } + } } // Store the CSS theme in the settings @@ -624,6 +799,7 @@ export default class Note { this.plugin.settings.theme = this.plugin.app?.customCss?.theme || '' // customCss is not exposed await this.plugin.saveSettings() } catch (e) { + console.error('Error in processCss:', e) } } } From c496caf5e355141c85176c0cd764e0a0f73b2c99 Mon Sep 17 00:00:00 2001 From: kinboy Date: Thu, 18 Dec 2025 09:55:31 +0800 Subject: [PATCH 3/3] fix: add mise.toml --- mise.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..4cac3f4 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "23"