@msout/microsoft-onenote-exporter 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.1] - 2026-10-01
10
+
11
+ A **patch**, and an unusual one: it fixes six bugs and changes no documented
12
+ behaviour. 0.1.0 built, installed and passed 102 tests, and could not export a
13
+ single note inside the container. Every bug here was found by running
14
+ `start-container.sh` against a real notebook and looking for Markdown on the
15
+ host — not by reading the code, and not by the test suite.
16
+
17
+ ### Fixed
18
+
19
+ - **The container was never given the subcommand.** The wrapper forwarded the
20
+ caller's flags verbatim, and the CLI takes the step as a subcommand, so the
21
+ container received only `--notebook <name>` and answered
22
+ `error: unknown option '--notebook'`. The wrapper now supplies `export` by
23
+ default and accepts an explicit step.
24
+ - **The wrapper reported success while the export was still running.** The wait
25
+ loop read `State.ExitCode` and stopped as soon as it was non-empty — and that
26
+ field is `0` *while the container is running*. So it printed
27
+ `Exported files are in: ...` for a run that had written nothing. It now uses
28
+ `docker wait`, which blocks until the container stops, with a one-hour
29
+ watchdog that reports rather than hangs.
30
+ - **Notes were written inside the image and lost on exit.** The container's
31
+ working directory is `/app`, so the CLI's own default for `--output-dir` —
32
+ `./output` against the cwd — resolved to `/app/output`. That directory exists
33
+ and is writable, so the export ran to completion and logged
34
+ `Files saved in: /app/output/<notebook>`, and every file died with the
35
+ container: `/app/output` is inside the image, not the mounted volume. The
36
+ entrypoint now points `--output-dir` at `/data/output` whenever a volume is
37
+ mounted, and warns when none is.
38
+ - **The documented way to run could not run.** With the session in `./output`
39
+ there is no second mount to make, so the optional-mount array was empty, and
40
+ expanding an empty array under `set -u` failed with
41
+ `AUTH_MOUNT[@]: unbound variable` before starting any container. The
42
+ `~/.microsoft-webauth` fallback worked and hid it — the bug was invisible from
43
+ the side of the code that happened to work.
44
+ - **The container name collided on every run after the first**, because the
45
+ wrapper never removed what it created and the name is derived from the working
46
+ directory.
47
+ - **The logger swallowed the error it was asked to report.** It formatted only
48
+ its first argument, but the export step reports `logger.error('Export failed:',
49
+ e)`, so a genuine failure printed the word `failed:` and nothing else — no
50
+ message, no stack, and an empty log file. That is what concealed the bug above.
51
+ Errors are now read from any argument position, which is how all three step
52
+ packages call it.
53
+
54
+ ### Changed
55
+
56
+ - **The session now defaults to the one `login` writes**, so "sign in on the
57
+ host, then run this" works with no preparation. It used to default to
58
+ `./output/auth.json` and fail with `no auth file` unless you knew to make a
59
+ copy that no document mentioned.
60
+ - **The Docker section of the README** is written around a single mounted volume
61
+ at `/data/output`, showing the copy step, the resulting directory tree, and the
62
+ separate read-only mount as the alternative for keeping the session out of the
63
+ notes directory.
64
+
65
+ Tests: 102 → 128. Verified by a real export: 18 notes written to
66
+ `./output/NotebookLongSimple`, `logs/app.log` beside them, one image downloaded.
67
+
68
+ ### Release process
69
+
70
+ 0.1.0 was published from a laptop, so it carries no provenance and this
71
+ repository had no tags or releases at all — which is why neither the tag-triggered
72
+ OIDC workflow nor a GitHub Release existed for it. From 0.1.1 the release is
73
+ `v0.1.1` pushed to `main`, so the workflow publishes with a provenance statement
74
+ attached.
75
+
9
76
  ## [0.1.0] - 2026-10-01
10
77
 
11
78
  The first release. It adds a CLI over three existing packages and changes none of
package/README.md CHANGED
@@ -129,31 +129,106 @@ authenticated DOM of a real account: cookies, tenant hostnames, note titles.
129
129
 
130
130
  ## Docker
131
131
 
132
+ One image, one Chromium, serving all five commands. It replaces the three separate
133
+ images these packages used to ship, each carrying its own browser.
134
+
132
135
  ```sh
133
136
  docker build -t microsoft-onenote-exporter .
134
137
  ```
135
138
 
136
- One image, one Chromium, serving all five commands. The auth file is read from
137
- `/data/output/auth.json` unless you pass `--auth-file`.
139
+ ### Mounting
140
+
141
+ The container reads and writes through a single volume mounted at
142
+ `/data/output`. The host directory of your choice — `./output` below — receives
143
+ the exported notes *and* provides the saved session:
144
+
145
+ ```
146
+ host ./output -> /data/output
147
+ ```
148
+
149
+ A login cannot happen inside the container: without credentials the browser has
150
+ to be shown, and there is nobody there to show it to. So you sign in on the host
151
+ first, put the session in the mount, and the container reads it from there:
152
+
153
+ ```sh
154
+ # 1. sign in on the host, once
155
+ microsoft-onenote-exporter login
156
+
157
+ # 2. put the session where the container can see it
158
+ mkdir -p output
159
+ cp ~/.microsoft-webauth/auth-file.json output/auth.json
160
+
161
+ # 3. export, writing notes back into ./output
162
+ docker run --rm --init --shm-size=1g \
163
+ -v "$PWD/output:/data/output" \
164
+ microsoft-onenote-exporter export \
165
+ --notebook "NotebookLongSimple" \
166
+ --non-interactive
167
+ ```
168
+
169
+ That produces:
170
+
171
+ ```
172
+ output/
173
+ ├── auth.json the session (a live credential - see below)
174
+ ├── logs/app.log every step of the run, in order
175
+ └── NotebookLongSimple/
176
+ ├── Section1/
177
+ │ ├── Section1-Note1.md
178
+ │ └── Section1-Note2w2Pic.md
179
+ └── Section2/assets/ images and attachments, beside the notes
180
+ ```
181
+
182
+ Two flags are required and are easy to leave out:
183
+
184
+ - **`--shm-size=1g`** — Chromium crashes on memory-heavy pages with Docker's
185
+ default 64 MB of shared memory.
186
+ - **`--init`** — reaps Chromium's child processes instead of leaving zombies.
187
+
188
+ If you omit the `-v` mount, the entrypoint warns you: the notes would be written
189
+ inside the container and lost when it exits. That is not a hypothetical — it is
190
+ what happens by default, because the CLI's own default for `--output-dir` is
191
+ `./output` relative to a working directory of `/app`, which exists and is
192
+ writable, so the export succeeds and the files vanish. Whenever a volume is
193
+ mounted, the entrypoint points `--output-dir` at it.
194
+
195
+ `output/auth.json` is a full account credential. Keep the directory out of git —
196
+ `.gitignore` covers `output/` — and be careful with anything that syncs it whole,
197
+ such as a cloud backup or an Obsidian vault. If you would rather keep the session
198
+ out of the notes directory, mount it separately instead:
138
199
 
139
200
  ```sh
140
201
  docker run --rm --init --shm-size=1g \
141
- -v "$PWD/out:/data/output" \
142
- microsoft-onenote-exporter export --notebook "Work" --non-interactive
202
+ -v "$PWD/output:/data/output" \
203
+ -v "$HOME/.microsoft-webauth/auth-file.json:/data/auth/session.json:ro" \
204
+ microsoft-onenote-exporter export \
205
+ --notebook "NotebookLongSimple" --non-interactive \
206
+ --auth-file /data/auth/session.json
143
207
  ```
144
208
 
145
- Or use the wrapper, which waits for the run and translates the exit code:
209
+ ### The wrapper
210
+
211
+ `start-container.sh` does the mounting, waits for the run, and translates the
212
+ exit code into an explanation:
146
213
 
147
214
  ```sh
148
- ./start-container.sh --notebook "Work" --output-dir ./out
215
+ cp ~/.microsoft-webauth/auth-file.json ./output/auth.json # once
216
+ ./start-container.sh --notebook "NotebookLongSimple"
149
217
  ```
150
218
 
151
- `--shm-size=1g` is required: Chromium crashes on memory-heavy pages with Docker's
152
- default 64 MB of shared memory. `--init` reaps Chromium's child processes.
219
+ It defaults `OUTPUT_DIR` to `./output`, so that is where the notes and
220
+ `logs/app.log` land. If `./output/auth.json` is absent it falls back to the
221
+ session in `~/.microsoft-webauth`, mounting that read-only instead — so it works
222
+ either way.
223
+
224
+ ```sh
225
+ CONTAINER=other-name ./start-container.sh --notebook "Work" # rename the container
226
+ IMAGE=my-registry/microsoft-onenote-exporter ./start-container.sh --notebook "Work"
227
+ ```
153
228
 
154
- `login` is refused inside the container — without credentials the browser has to
155
- be shown, and there is nobody there to show it to. Log in on the host first and
156
- mount the resulting file.
229
+ The wrapper runs detached and reports the container's exit status. It removes the
230
+ container it created, so it can be run repeatedly; if one is still running it
231
+ says so rather than colliding with it.
157
232
 
158
233
  ## Development
159
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msout/microsoft-onenote-exporter",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Log in, list and export Microsoft OneNote notebooks as Obsidian Markdown - one CLI over @msout/microsoft-webauth, @msout/microsoft-onenote-list-notebooks and @msout/microsoft-onenote-export-notebook.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
package/src/config.js CHANGED
@@ -5,6 +5,35 @@
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
 
8
+ /** Cached so one run cannot write to two different places. */
9
+ let outputDir;
10
+
11
+ /**
12
+ * Where exported notes go when --output-dir is not given.
13
+ *
14
+ * Absolute, and resolved against the working directory rather than against this
15
+ * package's own location. It has to be: the export step's own default is
16
+ * `<its package dir>/output`, which as a dependency lands inside node_modules -
17
+ * and in the container node_modules is root-owned, so a default export died with
18
+ * EACCES after it had already signed in, found the notebook and loaded the
19
+ * editor. Same class of bug as the log directory, and the same fix: resolve the
20
+ * path here rather than letting a dependency resolve it against its own install
21
+ * location.
22
+ *
23
+ * @param {string} [dir] - Explicit override, resolved to an absolute path
24
+ * @returns {string} Absolute path of the output directory
25
+ */
26
+ function defaultOutputDir(dir) {
27
+ if (dir) return path.resolve(dir);
28
+ if (!outputDir) outputDir = path.resolve(process.cwd(), 'output');
29
+ return outputDir;
30
+ }
31
+
32
+ /** Test seam: forgets the cached default so the next call re-resolves it. */
33
+ function resetOutputDir() {
34
+ outputDir = undefined;
35
+ }
36
+
8
37
  /**
9
38
  * Where the three step packages should write their logs.
10
39
  *
@@ -77,4 +106,12 @@ const EXIT = {
77
106
  partial: 3,
78
107
  };
79
108
 
80
- module.exports = { resolveLogDir, shareLogDir, defaultAuthFile, TARGETS, EXIT };
109
+ module.exports = {
110
+ resolveLogDir,
111
+ shareLogDir,
112
+ defaultAuthFile,
113
+ defaultOutputDir,
114
+ resetOutputDir,
115
+ TARGETS,
116
+ EXIT,
117
+ };
package/src/index.js CHANGED
@@ -144,7 +144,7 @@ sharedOptions(
144
144
  )
145
145
  .option('--notebook <name>', 'Notebook to export, by name (skips the interactive picker)')
146
146
  .option('--notebook-link <url>', 'Notebook to export, by URL (skips listing and picking)')
147
- .option('--output-dir <path>', 'Where to write the Markdown (default: ./output)')
147
+ .option('--output-dir <path>', 'Where to write the Markdown (default: ./output, resolved against the working directory)')
148
148
  .option('--nopassasked', 'Skip password-protected sections instead of asking for the password')
149
149
  .option('--non-interactive', 'Run unattended: requires --notebook or --notebook-link, and implies --nopassasked')
150
150
  .action(async (options) => {
package/src/logger.js CHANGED
@@ -16,6 +16,43 @@ const { resolveLogDir } = require('./config');
16
16
  /** Severity order, lowest first. A message is emitted if its level >= the threshold. */
17
17
  const LEVELS = { debug: 10, info: 20, step: 20, success: 20, warn: 30, error: 40 };
18
18
 
19
+ /**
20
+ * JSON.stringify that cannot throw, and that still says something useful.
21
+ *
22
+ * A logger that throws while reporting a failure replaces the failure with its
23
+ * own, and the original is lost - the opposite of what a logger is for.
24
+ * Circular structures are the usual cause, and this logger is handed whatever a
25
+ * deep call site thought was worth mentioning.
26
+ *
27
+ * The fallback walks the object and renders what it can rather than returning
28
+ * String(value), which for a circular object yields "[object Object]" and throws
29
+ * away every field - including the ones outside the cycle, which were the reason
30
+ * for logging it in the first place.
31
+ */
32
+ function safeStringify(value) {
33
+ try {
34
+ return JSON.stringify(value, null, 2);
35
+ } catch {
36
+ try {
37
+ return JSON.stringify(value, circularReplacer(), 2);
38
+ } catch {
39
+ return String(value);
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Marks already-visited objects as "[circular]" instead of recursing forever. */
45
+ function circularReplacer() {
46
+ const seen = new WeakSet();
47
+ return (key, val) => {
48
+ if (val !== null && typeof val === 'object') {
49
+ if (seen.has(val)) return '[circular]';
50
+ seen.add(val);
51
+ }
52
+ return val;
53
+ };
54
+ }
55
+
19
56
  class Logger {
20
57
  constructor() {
21
58
  this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -66,13 +103,37 @@ class Logger {
66
103
  return str.replace(/\u001b\[[0-9;]*m/g, '');
67
104
  }
68
105
 
69
- _write(level, message, color) {
106
+ /**
107
+ * Formats one call's worth of arguments into a printable string.
108
+ *
109
+ * Errors are read out of the argument list rather than being passed whole.
110
+ * This exists because of a real failure: the export step's catch reports
111
+ * `logger.error('Export failed:', e)`, so an Error arrives as the *second*
112
+ * argument, not the first. Formatting only the first argument stringified it
113
+ * and dropped the stack, so a failure that had navigated OneNote, found the
114
+ * notebook and loaded the editor reported nothing but the words
115
+ * "failed:" - no message, no stack, nothing in the log file either.
116
+ *
117
+ * The extra arguments are joined after the first rather than dropped, which
118
+ * is what makes `error('context:', err)` read the way it was written.
119
+ */
120
+ _format(args) {
121
+ return args
122
+ .map((part) => {
123
+ if (part instanceof Error) return part.stack || part.message;
124
+ if (typeof part === 'string') return part;
125
+ return safeStringify(part);
126
+ })
127
+ .join(' ')
128
+ .trim();
129
+ }
130
+
131
+ _write(level, args, color) {
70
132
  if (!this._enabled(level)) return;
71
133
 
72
134
  const stamp = this._timestamp();
73
- const body = message instanceof Error
74
- ? (message.stack || message.message)
75
- : (typeof message === 'string' ? message : JSON.stringify(message, null, 2));
135
+ const body = this._format(args);
136
+ if (!body) return;
76
137
 
77
138
  const plain = body.split('\n').map((line) => `[${level}] ${line}`).join('\n');
78
139
  const colored = body.split('\n').map((line) => `${chalk.gray(stamp)} ${color(`[${level}]`)} ${line}`).join('\n');
@@ -85,12 +146,12 @@ class Logger {
85
146
  stream.write(`${colored}\n`);
86
147
  }
87
148
 
88
- debug(message) { this._write('debug', message, chalk.gray); }
89
- info(message) { this._write('info', message, chalk.blue); }
90
- step(message) { this._write('step', message, chalk.magenta); }
91
- success(message) { this._write('success', message, chalk.green); }
92
- warn(message) { this._write('warn', message, chalk.yellow); }
93
- error(message) { this._write('error', message, chalk.red); }
149
+ debug(...args) { this._write('debug', args, chalk.gray); }
150
+ info(...args) { this._write('info', args, chalk.blue); }
151
+ step(...args) { this._write('step', args, chalk.magenta); }
152
+ success(...args) { this._write('success', args, chalk.green); }
153
+ warn(...args) { this._write('warn', args, chalk.yellow); }
154
+ error(...args) { this._write('error', args, chalk.red); }
94
155
  }
95
156
 
96
157
  module.exports = new Logger();
@@ -3,7 +3,7 @@
3
3
  * @copyright 2026 msout
4
4
  */
5
5
  const logger = require('../logger');
6
- const { EXIT } = require('../config');
6
+ const { EXIT, defaultOutputDir } = require('../config');
7
7
 
8
8
  /**
9
9
  * Loads the package.
@@ -16,6 +16,20 @@ function load() {
16
16
  return require('@msout/microsoft-onenote-export-notebook');
17
17
  }
18
18
 
19
+ /**
20
+ * Where notes are written.
21
+ *
22
+ * Absolute in every case, and never inside a dependency: see the comment where
23
+ * exportDir is passed to runExport for why that package's own default cannot be
24
+ * used here.
25
+ *
26
+ * @param {string} [dir] - What --output-dir was given, if anything
27
+ * @returns {string} Absolute path
28
+ */
29
+ function resolveOutputDir(dir) {
30
+ return defaultOutputDir(dir);
31
+ }
32
+
19
33
  /**
20
34
  * Exports one notebook to Obsidian-flavoured Markdown.
21
35
  *
@@ -44,7 +58,27 @@ async function exportNotebook(options) {
44
58
  authFile: options.authFile,
45
59
  notebook: options.notebook,
46
60
  notebookLink: options.notebookLink,
47
- exportDir: options.outputDir,
61
+ // Always absolute, and never the package's own default. That default is
62
+ // `path.resolve(__dirname, '../output')`, which is correct for a checkout
63
+ // and wrong as a dependency: it resolves to
64
+ //
65
+ // node_modules/@msout/microsoft-onenote-export-notebook/output
66
+ //
67
+ // Inside the container that is root-owned and read-only to the runtime
68
+ // user, so a real export died with
69
+ //
70
+ // EACCES: permission denied, mkdir '.../microsoft-onenote-export-notebook/output'
71
+ //
72
+ // after it had already signed in, found the notebook and loaded the
73
+ // editor. Same class of bug as the log directory, and the same fix: the
74
+ // umbrella resolves the path against the working directory rather than
75
+ // letting a dependency resolve it against its own install location.
76
+ //
77
+ // A relative --output-dir is resolved too, rather than handed through:
78
+ // handed through it would be re-resolved by the export step against
79
+ // whatever its own idea of the base directory is, which is the bug above
80
+ // all over again with an extra step.
81
+ exportDir: resolveOutputDir(options.outputDir),
48
82
  notheadless: options.notheadless,
49
83
  dodump: options.dodump,
50
84
  nopassasked: options.nopassasked,