• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SNApp-notes / web / 20233079137

15 Dec 2025 01:00PM UTC coverage: 86.171% (-1.2%) from 87.362%
20233079137

push

github

jcubic
fix tests

696 of 850 branches covered (81.88%)

Branch coverage included in aggregate %.

6 of 20 new or added lines in 3 files covered. (30.0%)

30 existing lines in 3 files now uncovered.

1379 of 1558 relevant lines covered (88.51%)

2342.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.0
/src/lib/diff-utils.ts
1
/**
2
 * @module lib/diff-utils
3
 * @description Utilities for creating and applying text diffs for efficient localStorage usage.
4
 *
5
 * @remarks
6
 * **Purpose:**
7
 * - Create minimal diffs between original and edited note content
8
 * - Apply diffs to reconstruct edited content from server version
9
 * - Generate content hashes for base validation (detect server-side changes)
10
 * - Achieve 90-95% storage reduction vs storing full content
11
 *
12
 * **Algorithm:**
13
 * Uses the `diff` library for robust text diffing with insertions, deletions, and modifications.
14
 *
15
 * **Storage Schema:**
16
 * ```typescript
17
 * interface UnsavedNoteDiff {
18
 *   noteId: number;
19
 *   baseHash: string;    // SHA-256 of server content
20
 *   diff: string;        // JSON-serialized diff patch
21
 *   timestamp: number;   // Last edit time
22
 * }
23
 * ```
24
 *
25
 * **Usage:**
26
 * ```typescript
27
 * import { createDiff, applyDiff, createContentHash } from '@/lib/diff-utils';
28
 *
29
 * // User edits a note
30
 * const originalContent = 'Hello world';
31
 * const editedContent = 'Hello beautiful world';
32
 * const baseHash = createContentHash(originalContent);
33
 * const diff = createDiff(originalContent, editedContent);
34
 *
35
 * // Store: { noteId, baseHash, diff, timestamp }
36
 *
37
 * // On page refresh, restore edited content
38
 * const serverContent = await fetchNoteFromServer(noteId);
39
 * const currentHash = createContentHash(serverContent);
40
 *
41
 * if (currentHash === baseHash) {
42
 *   // No conflict - apply diff
43
 *   const restoredContent = applyDiff(serverContent, diff);
44
 * } else {
45
 *   // Conflict - prompt user
46
 *   handleConflict(noteId);
47
 * }
48
 * ```
49
 */
50

51
import { diffLines, type Change } from 'diff';
52

53
/**
54
 * Storage schema for unsaved note diffs.
55
 *
56
 * @interface UnsavedNoteDiff
57
 * @property {number} noteId - Note identifier
58
 * @property {string} baseHash - SHA-256 hash of the base content (server version)
59
 * @property {string} diff - JSON-serialized diff patch
60
 * @property {number} timestamp - Last edit timestamp (ms since epoch)
61
 */
62
export interface UnsavedNoteDiff {
63
  noteId: number;
64
  baseHash: string;
65
  diff: string;
66
  timestamp: number;
67
}
68

69
/**
70
 * Create SHA-256 hash of content for base validation.
71
 *
72
 * @param {string} content - Content to hash
73
 * @returns {Promise<string>} Hex-encoded SHA-256 hash
74
 *
75
 * @remarks
76
 * Uses Web Crypto API for secure hashing.
77
 * Hash is used to detect if server content has changed since diff was created.
78
 *
79
 * @example
80
 * ```typescript
81
 * const hash1 = await createContentHash('Hello world');
82
 * const hash2 = await createContentHash('Hello world');
83
 * expect(hash1).toBe(hash2); // Same content = same hash
84
 *
85
 * const hash3 = await createContentHash('Different content');
86
 * expect(hash1).not.toBe(hash3); // Different content = different hash
87
 * ```
88
 */
89
export async function createContentHash(content: string): Promise<string> {
90
  // Check if crypto.subtle is available (requires secure context)
91
  if (typeof crypto === 'undefined' || !crypto.subtle) {
44!
NEW
92
    console.warn('[diff-utils] crypto.subtle not available, using fallback hash');
×
93
    // Fallback to simple string hash for non-secure contexts (e.g., Docker E2E tests)
94
    // This is NOT cryptographically secure but sufficient for conflict detection
NEW
95
    let hash = 0;
×
NEW
96
    for (let i = 0; i < content.length; i++) {
×
NEW
97
      const char = content.charCodeAt(i);
×
NEW
98
      hash = (hash << 5) - hash + char;
×
NEW
99
      hash = hash & hash; // Convert to 32bit integer
×
100
    }
NEW
101
    return Math.abs(hash).toString(16).padStart(8, '0');
×
102
  }
103

104
  // Use Web Crypto API for hashing (secure contexts)
105
  const encoder = new TextEncoder();
44✔
106
  const data = encoder.encode(content);
44✔
107
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
44✔
108
  const hashArray = Array.from(new Uint8Array(hashBuffer));
44✔
109
  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
1,408✔
110
  return hashHex;
44✔
111
}
112

113
/**
114
 * Create diff patch between original and edited content.
115
 *
116
 * @param {string} original - Original content (base/server version)
117
 * @param {string} edited - Edited content (user's changes)
118
 * @returns {string} JSON-serialized diff patch
119
 *
120
 * @remarks
121
 * Uses line-based diffing for better human readability and smaller diffs for typical edits.
122
 * Returns JSON string for easy storage in localStorage.
123
 *
124
 * @example
125
 * ```typescript
126
 * const original = 'Line 1\nLine 2\nLine 3';
127
 * const edited = 'Line 1\nModified Line 2\nLine 3\nLine 4';
128
 * const diff = createDiff(original, edited);
129
 * // diff contains only the changes (line 2 modified, line 4 added)
130
 * ```
131
 */
132
export function createDiff(original: string, edited: string): string {
133
  // Use diffLines for line-based diffing
134
  const changes: Change[] = diffLines(original, edited);
39✔
135

136
  // Serialize changes to JSON
137
  return JSON.stringify(changes);
39✔
138
}
139

140
/**
141
 * Apply diff patch to original content to reconstruct edited version.
142
 *
143
 * @param {string} original - Original content (base/server version)
144
 * @param {string} diffPatch - JSON-serialized diff patch (from createDiff)
145
 * @returns {string | null} Reconstructed edited content, or null if patch fails
146
 *
147
 * @remarks
148
 * Returns null if:
149
 * - Diff patch is invalid JSON
150
 * - Patch cannot be applied (corrupted data or base mismatch)
151
 *
152
 * Caller should handle null by falling back to server version and clearing invalid diff.
153
 *
154
 * @example
155
 * ```typescript
156
 * const original = 'Line 1\nLine 2\nLine 3';
157
 * const diff = createDiff(original, edited);
158
 *
159
 * // Later, reconstruct edited content
160
 * const restored = applyDiff(original, diff);
161
 * if (restored) {
162
 *   console.log('Successfully restored:', restored);
163
 * } else {
164
 *   console.warn('Failed to apply diff, using server version');
165
 * }
166
 * ```
167
 */
168
export function applyDiff(_original: string, diffPatch: string): string | null {
169
  try {
23✔
170
    // Parse the diff patch
171
    const changes: Change[] = JSON.parse(diffPatch);
23✔
172

173
    // Reconstruct the edited content from changes
174
    let result = '';
23✔
175
    for (const change of changes) {
23✔
176
      if (!change.removed) {
49✔
177
        // Add lines that were added or unchanged
178
        result += change.value;
35✔
179
      }
180
    }
181

182
    return result;
20✔
183
  } catch (error) {
184
    console.warn('Failed to apply diff patch:', error);
3✔
185
    return null;
3✔
186
  }
187
}
188

189
/**
190
 * Calculate diff size and storage savings.
191
 *
192
 * @param {string} original - Original content
193
 * @param {string} edited - Edited content
194
 * @returns {{ originalSize: number; diffSize: number; savings: number }}
195
 *
196
 * @remarks
197
 * Used for testing and monitoring storage efficiency.
198
 * Sizes are in bytes (UTF-16 encoding).
199
 *
200
 * @example
201
 * ```typescript
202
 * const original = 'x'.repeat(50000); // 50KB
203
 * const edited = original + '\nSmall change'; // 50KB + 12 bytes
204
 * const { originalSize, diffSize, savings } = calculateDiffSize(original, edited);
205
 * console.log(`Savings: ${(savings * 100).toFixed(2)}%`);
206
 * // Output: "Savings: 99.97%" (diff is tiny compared to full content)
207
 * ```
208
 */
209
export function calculateDiffSize(
210
  original: string,
211
  edited: string
212
): { originalSize: number; diffSize: number; savings: number } {
213
  const diff = createDiff(original, edited);
6✔
214

215
  // UTF-16 encoding: 2 bytes per character
216
  const originalSize = original.length * 2;
6✔
217
  const diffSize = diff.length * 2;
6✔
218

219
  const savings = originalSize > 0 ? 1 - diffSize / originalSize : 0;
6✔
220

221
  return {
6✔
222
    originalSize,
223
    diffSize,
224
    savings
225
  };
226
}
227

228
/**
229
 * Validate that a diff can be successfully applied.
230
 *
231
 * @param {string} original - Original content
232
 * @param {string} diffPatch - JSON-serialized diff patch
233
 * @returns {boolean} True if diff is valid and can be applied
234
 *
235
 * @remarks
236
 * Used to verify diff integrity before storing or applying.
237
 *
238
 * @example
239
 * ```typescript
240
 * const diff = createDiff(original, edited);
241
 * if (isDiffValid(original, diff)) {
242
 *   // Safe to store and apply later
243
 *   storeDiff(noteId, diff);
244
 * }
245
 * ```
246
 */
247
export function isDiffValid(original: string, diffPatch: string): boolean {
248
  const result = applyDiff(original, diffPatch);
4✔
249
  return result !== null;
4✔
250
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc