latex-stickies 1.3.8 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,8 @@ keeps a copy of the runtime carrying this app's name and icon in
29
29
  `~/Library/Application Support/latex-stickies/` -- on APFS that is a
30
30
  copy-on-write clone, so it costs almost no disk. On a Mac you can
31
31
  download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
32
- instead and skip Node entirely.
32
+ instead and skip Node entirely. It is not signed by Apple, so the first launch
33
+ needs a right-click on the app and **Open** rather than a double-click.
33
34
 
34
35
  ## Writing in a note
35
36
 
@@ -91,7 +92,7 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
91
92
  | | |
92
93
  |---|---|
93
94
  | `Cmd+N` | New note |
94
- | `Cmd+W` | Close note (it stays closed until you reopen it) |
95
+ | `Cmd+W` | Close note (it stays closed until you reopen it; an empty one is discarded) |
95
96
  | `Cmd+Shift+Backspace` | Delete note, permanently |
96
97
  | `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
97
98
  | `Cmd+K` | Link |
@@ -162,6 +163,7 @@ node scripts/smoke.js --lifecycle # closing every note behaves per platform
162
163
  node scripts/render-check.js # the first-run note renders correctly
163
164
  node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
164
165
  node scripts/snapshot-check.js # a long note is captured whole
166
+ node scripts/conflict-check.js # the changed-on-disk banner behaves
165
167
  node scripts/verify-install.js # the install path, end to end (macOS)
166
168
  ```
167
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -30,7 +30,8 @@
30
30
  "zip"
31
31
  ],
32
32
  "icon": "build/icon.icns"
33
- }
33
+ },
34
+ "afterPack": "scripts/after-pack.js"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@codemirror/commands": "^6.11.0",
package/src/main.js CHANGED
@@ -68,7 +68,13 @@ async function deleteFocusedNote() {
68
68
 
69
69
  const note = store.get(id);
70
70
  if (note && note.body.trim()) {
71
- const { response } = await dialog.showMessageBox(win, {
71
+ // Deliberately not attached to the note's window. A sheet hangs off a
72
+ // title bar, and these windows are frameless, transparent and often on
73
+ // top -- so the prompt rendered invisibly and the app sat waiting for an
74
+ // answer nobody could give. Delete Note looked like it did nothing at all.
75
+ // The same trap took window.confirm() out of use here.
76
+ win.show();
77
+ const { response } = await dialog.showMessageBox({
72
78
  type: 'warning',
73
79
  buttons: ['Delete', 'Cancel'],
74
80
  defaultId: 1,
@@ -163,7 +169,22 @@ function openNote(note) {
163
169
  // Closing a note is a decision that should survive a restart. Quitting is
164
170
  // not: an app that shuts down closes every window, and treating that as
165
171
  // "the user closed them all" would greet them with an empty desk.
166
- if (!quitting) store.upsert({ id: note.id, open: false });
172
+ // Nothing to record if the note is already gone: this fires during a
173
+ // delete too, and writing "open: false" for an id the store no longer
174
+ // knows created a fresh empty note in its place -- the note came straight
175
+ // back as "Untitled note".
176
+ if (!quitting && store.get(note.id)) {
177
+ const closed = store.get(note.id);
178
+ if (closed && !(closed.body || '').trim()) {
179
+ // An emptied note closed is a note thrown away, as in Stickies.
180
+ // Otherwise clearing a note's text leaves an "Untitled note" in the
181
+ // list for ever, with no way to be rid of it short of opening it
182
+ // again to delete it -- which is not where anyone looks.
183
+ store.remove(note.id);
184
+ } else {
185
+ store.upsert({ id: note.id, open: false });
186
+ }
187
+ }
167
188
 
168
189
  // macOS apps outlive their windows; the Dock icon is the way back.
169
190
  // Elsewhere there is no way back, so an app with no windows is just an
@@ -493,11 +514,16 @@ function prepareToExit() {
493
514
  }
494
515
 
495
516
  function watchNotesFolder() {
496
- stopWatchingNotes = store.watch((changed) => {
497
- for (const note of changed) {
517
+ stopWatchingNotes = store.watch((changes) => {
518
+ for (const { note, body, conflict } of changes) {
498
519
  const win = windows.get(note.id);
499
- if (win && !win.isDestroyed()) win.webContents.send('note-changed', note.body);
520
+ if (!win || win.isDestroyed()) continue;
521
+ // A note with unsaved edits is never overwritten from disk. The window
522
+ // is told there is a conflict and offers the choice, because only the
523
+ // person typing knows which copy they want.
524
+ win.webContents.send(conflict ? 'note-conflict' : 'note-changed', body);
500
525
  }
526
+ refreshNoteTitles();
501
527
  });
502
528
  }
503
529
 
package/src/preload.js CHANGED
@@ -43,7 +43,8 @@ contextBridge.exposeInMainWorld('sticky', {
43
43
  on: (channel, fn) => {
44
44
  const allowed = [
45
45
  'font-size', 'always-on-top-changed',
46
- 'copy-note-image', 'ai-settings-changed', 'note-changed', 'find-in-note', 'describe-latex',
46
+ 'copy-note-image', 'ai-settings-changed', 'note-changed', 'note-conflict',
47
+ 'find-in-note', 'describe-latex',
47
48
  ];
48
49
  if (!allowed.includes(channel)) return;
49
50
  ipcRenderer.on(channel, (_e, payload) => fn(payload));
@@ -362,3 +362,38 @@ body.capturing-note #preview [data-math].capturing {
362
362
 
363
363
  ::-webkit-scrollbar { width: 8px; }
364
364
  ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.16); border-radius: 4px; }
365
+
366
+ /* The file changed underneath unsaved edits. Sits over the note rather than
367
+ interrupting it: the text stays editable while the choice is on screen. */
368
+ #conflict {
369
+ /* Fixed to the window, not the scrolling content: the choice has to stay
370
+ visible while you scroll the note it is about. */
371
+ position: fixed;
372
+ left: 8px;
373
+ right: 8px;
374
+ bottom: 8px;
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 8px;
378
+ padding: 7px 10px;
379
+ border-radius: 7px;
380
+ background: rgba(0, 0, 0, 0.82);
381
+ color: #fff;
382
+ font-size: 12px;
383
+ z-index: 5;
384
+ }
385
+ /* An author display rule beats the [hidden] attribute, which only sets
386
+ display:none in the browser's own stylesheet -- so without this the banner
387
+ is on screen from launch and cannot be dismissed. */
388
+ #conflict[hidden] { display: none; }
389
+ #conflict span { flex: 1; }
390
+ #conflict button {
391
+ font: inherit;
392
+ color: inherit;
393
+ padding: 3px 9px;
394
+ border: 1px solid rgba(255, 255, 255, 0.35);
395
+ border-radius: 5px;
396
+ background: transparent;
397
+ cursor: pointer;
398
+ }
399
+ #conflict button:hover { background: rgba(255, 255, 255, 0.16); }
@@ -21,6 +21,13 @@
21
21
  </header>
22
22
  <main id="surface">
23
23
  <div id="host"></div>
24
+ <!-- Shown only when the file changed underneath unsaved edits. Not a
25
+ modal: the note stays editable while the choice is on screen. -->
26
+ <div id="conflict" hidden>
27
+ <span>Changed on disk.</span>
28
+ <button class="reload" type="button">Use theirs</button>
29
+ <button class="mine" type="button">Keep mine</button>
30
+ </div>
24
31
  </main>
25
32
  <script src="vendor/katex.min.js"></script>
26
33
  <script src="vendor/codemirror.js"></script>
@@ -14,12 +14,41 @@ let view = null;
14
14
 
15
15
  /* ---------- state ---------- */
16
16
 
17
+ // Trailing debounce, but with a ceiling. A debounce that resets on every
18
+ // keystroke never fires while you are typing, so the file could trail the
19
+ // screen by as long as the burst lasted -- and anything that then read the
20
+ // file got text seconds out of date. VS Code's autosave delay is 1s; this
21
+ // keeps the quick 250ms settle and caps the wait at 1s.
22
+ const SAVE_SETTLE_MS = 250;
23
+ const SAVE_MAX_MS = 1000;
24
+
17
25
  let saveTimer = null;
26
+ let pendingSince = 0;
27
+
28
+ function saveNow() {
29
+ clearTimeout(saveTimer);
30
+ saveTimer = null;
31
+ pendingSince = 0;
32
+ if (note) window.sticky.update({ body: note.body });
33
+ }
34
+
18
35
  function scheduleSave() {
36
+ if (!pendingSince) pendingSince = Date.now();
37
+ if (Date.now() - pendingSince >= SAVE_MAX_MS) {
38
+ saveNow();
39
+ return;
40
+ }
19
41
  clearTimeout(saveTimer);
20
- saveTimer = setTimeout(() => window.sticky.update({ body: note.body }), 250);
42
+ saveTimer = setTimeout(saveNow, SAVE_SETTLE_MS);
21
43
  }
22
44
 
45
+ // Losing focus is the moment to be certain: the file is what another editor,
46
+ // or a sync engine, is about to read. VS Code ships the same behaviour as
47
+ // files.autoSave onFocusChange.
48
+ window.addEventListener('blur', () => {
49
+ if (saveTimer) saveNow();
50
+ });
51
+
23
52
  // Typing is saved on a debounce, which Cmd+W would otherwise outrun: the window
24
53
  // is closed from the main process, so the pending timer dies with the renderer
25
54
  // and the last few words typed are lost. beforeunload is the last point the
@@ -114,16 +143,73 @@ window.sticky.on('describe-latex', async () => {
114
143
  // The file changed underneath us -- another editor, Dropbox, a git checkout.
115
144
  // Replace the text but keep the caret where it was, so a sync landing while
116
145
  // you are typing does not throw you back to the top of the note.
117
- window.sticky.on('note-changed', (body) => {
118
- if (!view || body === view.state.doc.toString()) return;
119
- const caret = Math.min(view.state.selection.main.head, body.length);
146
+ /**
147
+ * Applies an edit that happened outside this window.
148
+ *
149
+ * As a splice of the part that actually differs, not a replacement of the
150
+ * whole document. Replacing everything maps every position to the end of the
151
+ * insertion, so the caret jumps, the selection is lost and the undo history
152
+ * becomes one opaque blob -- CodeMirror maps positions through a change set
153
+ * for you, but only if the change describes what really changed.
154
+ *
155
+ * Common prefix and suffix is enough here: an outside edit is nearly always
156
+ * one contiguous region, and it costs no dependency.
157
+ */
158
+ function applyExternal(body) {
159
+ const doc = view.state.doc.toString();
160
+ if (body === doc) return;
161
+
162
+ let start = 0;
163
+ const max = Math.min(doc.length, body.length);
164
+ while (start < max && doc[start] === body[start]) start += 1;
165
+
166
+ let end = 0;
167
+ while (
168
+ end < max - start
169
+ && doc[doc.length - 1 - end] === body[body.length - 1 - end]
170
+ ) end += 1;
171
+
120
172
  note.body = body;
121
173
  view.dispatch({
122
- changes: { from: 0, to: view.state.doc.length, insert: body },
123
- selection: { anchor: caret },
174
+ changes: { from: start, to: doc.length - end, insert: body.slice(start, body.length - end) },
124
175
  });
176
+ }
177
+
178
+ window.sticky.on('note-changed', (body) => {
179
+ if (!view) return;
180
+ applyExternal(body);
181
+ });
182
+
183
+ /**
184
+ * The file changed while this note had edits that were not saved yet.
185
+ *
186
+ * Nothing is overwritten: the two versions are offered as a choice, because
187
+ * only the person typing knows which one they want. Silently reloading over
188
+ * unsaved input is the one thing every editor of this kind refuses to do.
189
+ */
190
+ window.sticky.on('note-conflict', (body) => {
191
+ if (!view) return;
192
+ showConflict(body);
125
193
  });
126
194
 
195
+ const conflict = document.getElementById('conflict');
196
+
197
+ function showConflict(body) {
198
+ conflict.hidden = false;
199
+ conflict.querySelector('.reload').onclick = () => {
200
+ applyExternal(body);
201
+ saveNow();
202
+ conflict.hidden = true;
203
+ };
204
+ conflict.querySelector('.mine').onclick = () => {
205
+ // Writing our copy over theirs is what makes this stick: the next save
206
+ // carries it, and the file stops disagreeing.
207
+ note.body = view.state.doc.toString();
208
+ saveNow();
209
+ conflict.hidden = true;
210
+ };
211
+ }
212
+
127
213
  // Right-click a rendered equation to copy it as an image or as LaTeX.
128
214
  host.addEventListener('contextmenu', async (e) => {
129
215
  const slot = e.target.closest('[data-tex]');
package/src/store.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * the previous file intact rather than a truncated one.
18
18
  */
19
19
  const { app } = require('electron');
20
+ const crypto = require('crypto');
20
21
  const fs = require('fs');
21
22
  const path = require('path');
22
23
 
@@ -98,6 +99,7 @@ function load() {
98
99
  try {
99
100
  body = fs.readFileSync(path.join(DIR, file), 'utf8');
100
101
  } catch (_) { /* unreadable file: show it empty rather than vanish */ }
102
+ seen.set(file, digest(body));
101
103
  return {
102
104
  ...DEFAULTS,
103
105
  ...saved,
@@ -154,7 +156,6 @@ function migrateLegacy() {
154
156
  /* ---------- writing ---------- */
155
157
 
156
158
  function writeFileAtomic(target, contents) {
157
- justWrote.set(path.basename(target), Date.now());
158
159
  const tmp = `${target}.${process.pid}.tmp`;
159
160
  const fd = fs.openSync(tmp, 'w');
160
161
  try {
@@ -164,6 +165,7 @@ function writeFileAtomic(target, contents) {
164
165
  fs.closeSync(fd);
165
166
  }
166
167
  fs.renameSync(tmp, target);
168
+ seen.set(path.basename(target), digest(contents));
167
169
  }
168
170
 
169
171
  // A kill between the temp write and the rename strands a .tmp file.
@@ -204,6 +206,22 @@ function flushIndex() {
204
206
  function flush() {
205
207
  timer = null;
206
208
  for (const note of all()) {
209
+ // Only the notes that actually changed. Rewriting every note on every
210
+ // keystroke multiplied the mtimes, the events and the chances of
211
+ // misattributing one -- four notes turned one save into twenty events --
212
+ // and churned any sync engine watching the folder.
213
+ if (!isDirty(note)) continue;
214
+
215
+ // Never write over a file that moved underneath us. The watcher usually
216
+ // gets there first, but a save already pending can land in between --
217
+ // and overwriting then is silent loss, with no event left to notice it.
218
+ // Skipping leaves the file alone; the watcher reports the conflict, and
219
+ // if it somehow missed the event, the next save checks again.
220
+ try {
221
+ const onDisk = fs.readFileSync(path.join(DIR, note.file), 'utf8');
222
+ if (digest(onDisk) !== seen.get(note.file)) continue;
223
+ } catch (_) { /* not there yet: writing it is exactly right */ }
224
+
207
225
  try {
208
226
  writeFileAtomic(path.join(DIR, note.file), note.body || '');
209
227
  } catch (err) {
@@ -213,6 +231,11 @@ function flush() {
213
231
  flushIndex();
214
232
  }
215
233
 
234
+ /** Has this note been edited since it was last written to disk? */
235
+ function isDirty(note) {
236
+ return digest(note.body || '') !== seen.get(note.file);
237
+ }
238
+
216
239
  // Writes are frequent (every keystroke, every window drag), so coalesce them.
217
240
  function save() {
218
241
  if (timer) return;
@@ -235,6 +258,11 @@ function upsert(note) {
235
258
  const i = list.findIndex((n) => n.id === note.id);
236
259
 
237
260
  if (i === -1) {
261
+ // A patch for an id nobody has, carrying no text, is not a new note: it is
262
+ // a stray message about one that has gone -- a window closing after its
263
+ // note was deleted, or bounds arriving as it goes. Creating a note from it
264
+ // resurrects what the user just deleted, empty.
265
+ if (note.body === undefined) return;
238
266
  const taken = new Set(list.map((n) => n.file));
239
267
  list.push({ ...DEFAULTS, ...note, file: note.file || slugFor(note.body, taken) });
240
268
  save();
@@ -254,11 +282,8 @@ function upsert(note) {
254
282
  const next = slugFor(list[i].body, taken, path.extname(before.file) || '.md');
255
283
  try {
256
284
  const from = path.join(DIR, before.file);
257
- // Both names count as our own writing, or the rename looks like an
258
- // outside edit and the watcher reloads over the top of it.
259
- justWrote.set(before.file, Date.now());
260
- justWrote.set(next, Date.now());
261
285
  if (fs.existsSync(from)) fs.renameSync(from, path.join(DIR, next));
286
+ seen.delete(before.file);
262
287
  list[i].file = next;
263
288
  // Write this note's body now too. The rename is immediate but the body
264
289
  // is on the 400ms timer, so a reload in between would read the new file
@@ -284,6 +309,11 @@ function remove(id) {
284
309
  try {
285
310
  fs.unlinkSync(path.join(DIR, note.file));
286
311
  } catch (_) { /* already gone */ }
312
+ // Forget what that filename held. Otherwise a later note that takes the
313
+ // same name -- "note.md" is handed out again the moment an untitled note
314
+ // is deleted -- is compared against the dead file's content, matches, and
315
+ // is judged already saved. It then never reaches disk.
316
+ seen.delete(note.file);
287
317
  }
288
318
  notes = all().filter((n) => n.id !== id);
289
319
  save();
@@ -296,34 +326,106 @@ function saveNow() {
296
326
 
297
327
  /* ---------- watching the folder ---------- */
298
328
 
299
- /** Files we wrote ourselves, so our own saves do not look like outside edits. */
300
- const justWrote = new Map();
301
- const SETTLE_MS = 1200;
329
+ /**
330
+ * The content we last wrote or read, per file, as a hash.
331
+ *
332
+ * This is how an event is attributed. A file whose bytes hash to what we last
333
+ * put there is our own save coming back, whenever it arrives; anything else is
334
+ * somebody else's edit. What it replaces was a 1200ms window after each write,
335
+ * which is not what mature editors do and was wrong in both directions: a save
336
+ * echoed back late -- measured at 2.5s when the main process stalls -- looked
337
+ * like an outside edit and reverted the note being typed in.
338
+ *
339
+ * VS Code uses mtime+size for the same job and has an open issue about content
340
+ * changing without the length changing; notes are small enough to just hash.
341
+ */
342
+ const seen = new Map();
343
+ const digest = (text) => crypto.createHash('sha256').update(text || '').digest('hex');
302
344
 
303
345
  /**
304
346
  * Calls back when a note file changes underneath us.
305
347
  *
306
348
  * This is the point of keeping notes as files: edit one in Vim, or let Dropbox
307
349
  * bring down a change from another machine, and the open note follows along.
308
- * Our own writes are filtered out, or every keystroke would echo back.
350
+ *
351
+ * Two rules, both taken from how VS Code and Zed handle this:
352
+ *
353
+ * - Only the file the event names is touched. Fanning one event out into a
354
+ * reload of every note is what let a change to one note revert the text
355
+ * being typed into another.
356
+ * - A note with unsaved edits is never overwritten. It is reported as a
357
+ * conflict instead, for the window to offer as a choice. "Do not resolve a
358
+ * model that is dirty" is the invariant every editor of this kind keeps.
309
359
  */
310
360
  function watch(onChanged) {
311
- let timer = null;
361
+ const timers = new Map();
312
362
  let watcher = null;
363
+
364
+ const handle = (filename) => {
365
+ timers.delete(filename);
366
+ const full = path.join(DIR, filename);
367
+
368
+ let body;
369
+ try {
370
+ body = fs.readFileSync(full, 'utf8');
371
+ } catch (_) {
372
+ // Deleted or moved away. The window keeps what it has: a file vanishing
373
+ // underneath an open note is not a reason to blank it.
374
+ seen.delete(filename);
375
+ return;
376
+ }
377
+
378
+ const before = seen.get(filename);
379
+ const now = digest(body);
380
+
381
+ // Set LATEX_STICKIES_DEBUG_WATCH=1 to see why an event was attributed the
382
+ // way it was. Guessing at this from the outside cost a day: the question
383
+ // is always whether the bytes on disk are the bytes we last wrote.
384
+ if (process.env.LATEX_STICKIES_DEBUG_WATCH) {
385
+ const held = all().find((n) => n.file === filename);
386
+ console.log(
387
+ `WATCH ${filename} disk=${now.slice(0, 8)} lastWrote=${String(before).slice(0, 8)}`
388
+ + ` memory=${digest(held ? held.body : '').slice(0, 8)}`
389
+ + ` ours=${now === before} diskLen=${body.length}`
390
+ + ` memLen=${held ? (held.body || '').length : -1}`
391
+ );
392
+ }
393
+
394
+ if (now === before) return; // our own save, however late it arrives
395
+
396
+ const note = all().find((n) => n.file === filename);
397
+ if (!note) {
398
+ // A file that appeared from outside. Added on its own rather than by
399
+ // reloading the folder: a reload drops every note's unsaved edits on the
400
+ // floor, so one new file would cost the words being typed in another.
401
+ const meta = readIndex()[filename] || {};
402
+ const added = {
403
+ ...DEFAULTS, ...meta, id: meta.id || filename, file: filename, body,
404
+ };
405
+ all().push(added);
406
+ seen.set(filename, now);
407
+ onChanged([{ note: added, body, conflict: false }]);
408
+ return;
409
+ }
410
+
411
+ // Whether the note was edited since we last wrote it has to be judged
412
+ // against the hash from before this event, which is what our copy came
413
+ // from -- not against the disk we have just read.
414
+ const dirty = digest(note.body || '') !== before;
415
+ seen.set(filename, now); // seen it now, so a repeat event says nothing new
416
+
417
+ if (!dirty) note.body = body;
418
+ onChanged([{ note, body, conflict: dirty }]);
419
+ };
420
+
313
421
  try {
314
422
  watcher = fs.watch(DIR, (_event, filename) => {
315
423
  if (!filename || filename.startsWith('.') || filename.endsWith('.tmp')) return;
316
- const wrote = justWrote.get(filename);
317
- if (wrote && Date.now() - wrote < SETTLE_MS) return;
318
-
319
- // Editors save in bursts; wait for the dust to settle.
320
- clearTimeout(timer);
321
- timer = setTimeout(() => {
322
- const before = new Map(all().map((n) => [n.file, n.body]));
323
- const after = reload();
324
- const changed = after.filter((n) => before.get(n.file) !== n.body);
325
- if (changed.length) onChanged(changed);
326
- }, 150);
424
+ if (!/\.(md|tex|txt)$/i.test(filename)) return;
425
+
426
+ // Editors save in bursts; wait for the dust to settle, per file.
427
+ clearTimeout(timers.get(filename));
428
+ timers.set(filename, setTimeout(() => handle(filename), 150));
327
429
  });
328
430
  } catch (err) {
329
431
  console.error('could not watch the notes folder', err);
@@ -333,8 +435,8 @@ function watch(onChanged) {
333
435
  // watcher left open is enough to stop the process exiting after its last
334
436
  // window closes -- which on Windows leaves the app running invisibly.
335
437
  return () => {
336
- clearTimeout(timer);
337
- timer = null;
438
+ for (const timer of timers.values()) clearTimeout(timer);
439
+ timers.clear();
338
440
  if (watcher) watcher.close();
339
441
  watcher = null;
340
442
  };
@@ -371,5 +473,6 @@ function saveSettings(patch) {
371
473
  }
372
474
 
373
475
  module.exports = {
374
- all, get, upsert, remove, saveNow, reload, watch, settings, saveSettings, DIR,
476
+ all, get, upsert, remove, saveNow, reload, watch, isDirty,
477
+ settings, saveSettings, DIR,
375
478
  };