@msout/microsoft-onenote-exporter 0.1.0 → 0.1.2
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 +97 -0
- package/README.md +86 -11
- package/entrypoint.sh +106 -0
- package/package.json +3 -1
- package/src/config.js +38 -1
- package/src/index.js +1 -1
- package/src/logger.js +71 -10
- package/src/steps/export.js +36 -2
- package/start-container.sh +301 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.2] - 2026-10-02
|
|
10
|
+
|
|
11
|
+
A **patch**. It changes what is published, not what the package does.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`entrypoint.sh` and `start-container.sh` are published again.** The `files`
|
|
16
|
+
whitelist listed `src/` and the four documents, so the tarball had eleven files
|
|
17
|
+
and neither container script — 0.1.0 and 0.1.1 both shipped that way. It
|
|
18
|
+
mattered because six of the fixes in 0.1.1 live inside those two files, and
|
|
19
|
+
because the README's Docker section is built on them: a consumer who installed
|
|
20
|
+
the package and then tried to run a container from it had no entrypoint to
|
|
21
|
+
build an image from and no wrapper to call.
|
|
22
|
+
|
|
23
|
+
The publish gate missed it because it only ever checked what must *not* ship —
|
|
24
|
+
no test suites, no auth state, no workflow files — and never what must. Two
|
|
25
|
+
tests now cover that direction, and one of them checks the executable bit too,
|
|
26
|
+
since a tarball carrying the scripts as `0644` would fail at the `ENTRYPOINT`
|
|
27
|
+
line with `Cannot exec: permission denied`.
|
|
28
|
+
|
|
29
|
+
Thirteen files, up from eleven.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`docker-output/` is gitignored.** A container run pointed at that directory
|
|
34
|
+
leaves `auth.json` beside the exported notes, and `git add -A` would have staged
|
|
35
|
+
a live full-account session.
|
|
36
|
+
|
|
37
|
+
Tests: 128 → 130.
|
|
38
|
+
|
|
39
|
+
## [0.1.1] - 2026-10-01
|
|
40
|
+
|
|
41
|
+
A **patch**, and an unusual one: it fixes six bugs and changes no documented
|
|
42
|
+
behaviour. 0.1.0 built, installed and passed 102 tests, and could not export a
|
|
43
|
+
single note inside the container. Every bug here was found by running
|
|
44
|
+
`start-container.sh` against a real notebook and looking for Markdown on the
|
|
45
|
+
host — not by reading the code, and not by the test suite.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **The container was never given the subcommand.** The wrapper forwarded the
|
|
50
|
+
caller's flags verbatim, and the CLI takes the step as a subcommand, so the
|
|
51
|
+
container received only `--notebook <name>` and answered
|
|
52
|
+
`error: unknown option '--notebook'`. The wrapper now supplies `export` by
|
|
53
|
+
default and accepts an explicit step.
|
|
54
|
+
- **The wrapper reported success while the export was still running.** The wait
|
|
55
|
+
loop read `State.ExitCode` and stopped as soon as it was non-empty — and that
|
|
56
|
+
field is `0` *while the container is running*. So it printed
|
|
57
|
+
`Exported files are in: ...` for a run that had written nothing. It now uses
|
|
58
|
+
`docker wait`, which blocks until the container stops, with a one-hour
|
|
59
|
+
watchdog that reports rather than hangs.
|
|
60
|
+
- **Notes were written inside the image and lost on exit.** The container's
|
|
61
|
+
working directory is `/app`, so the CLI's own default for `--output-dir` —
|
|
62
|
+
`./output` against the cwd — resolved to `/app/output`. That directory exists
|
|
63
|
+
and is writable, so the export ran to completion and logged
|
|
64
|
+
`Files saved in: /app/output/<notebook>`, and every file died with the
|
|
65
|
+
container: `/app/output` is inside the image, not the mounted volume. The
|
|
66
|
+
entrypoint now points `--output-dir` at `/data/output` whenever a volume is
|
|
67
|
+
mounted, and warns when none is.
|
|
68
|
+
- **The documented way to run could not run.** With the session in `./output`
|
|
69
|
+
there is no second mount to make, so the optional-mount array was empty, and
|
|
70
|
+
expanding an empty array under `set -u` failed with
|
|
71
|
+
`AUTH_MOUNT[@]: unbound variable` before starting any container. The
|
|
72
|
+
`~/.microsoft-webauth` fallback worked and hid it — the bug was invisible from
|
|
73
|
+
the side of the code that happened to work.
|
|
74
|
+
- **The container name collided on every run after the first**, because the
|
|
75
|
+
wrapper never removed what it created and the name is derived from the working
|
|
76
|
+
directory.
|
|
77
|
+
- **The logger swallowed the error it was asked to report.** It formatted only
|
|
78
|
+
its first argument, but the export step reports `logger.error('Export failed:',
|
|
79
|
+
e)`, so a genuine failure printed the word `failed:` and nothing else — no
|
|
80
|
+
message, no stack, and an empty log file. That is what concealed the bug above.
|
|
81
|
+
Errors are now read from any argument position, which is how all three step
|
|
82
|
+
packages call it.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **The session now defaults to the one `login` writes**, so "sign in on the
|
|
87
|
+
host, then run this" works with no preparation. It used to default to
|
|
88
|
+
`./output/auth.json` and fail with `no auth file` unless you knew to make a
|
|
89
|
+
copy that no document mentioned.
|
|
90
|
+
- **The Docker section of the README** is written around a single mounted volume
|
|
91
|
+
at `/data/output`, showing the copy step, the resulting directory tree, and the
|
|
92
|
+
separate read-only mount as the alternative for keeping the session out of the
|
|
93
|
+
notes directory.
|
|
94
|
+
|
|
95
|
+
Tests: 102 → 128. Verified by a real export: 18 notes written to
|
|
96
|
+
`./output/NotebookLongSimple`, `logs/app.log` beside them, one image downloaded.
|
|
97
|
+
|
|
98
|
+
### Release process
|
|
99
|
+
|
|
100
|
+
0.1.0 was published from a laptop, so it carries no provenance and this
|
|
101
|
+
repository had no tags or releases at all — which is why neither the tag-triggered
|
|
102
|
+
OIDC workflow nor a GitHub Release existed for it. From 0.1.1 the release is
|
|
103
|
+
`v0.1.1` pushed to `main`, so the workflow publishes with a provenance statement
|
|
104
|
+
attached.
|
|
105
|
+
|
|
9
106
|
## [0.1.0] - 2026-10-01
|
|
10
107
|
|
|
11
108
|
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
|
-
|
|
137
|
-
|
|
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/
|
|
142
|
-
microsoft-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
cp ~/.microsoft-webauth/auth-file.json ./output/auth.json # once
|
|
216
|
+
./start-container.sh --notebook "NotebookLongSimple"
|
|
149
217
|
```
|
|
150
218
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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/entrypoint.sh
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Container entrypoint for microsoft-onenote-exporter.
|
|
3
|
+
#
|
|
4
|
+
# One image, five commands. The old images took a session GUID and a notebook
|
|
5
|
+
# name as positional arguments, which was tied to one pipeline's idea of what a
|
|
6
|
+
# session is; this dispatches on the subcommand instead, so the same image can
|
|
7
|
+
# also be used to log in or to list.
|
|
8
|
+
|
|
9
|
+
set -e
|
|
10
|
+
|
|
11
|
+
if [ $# -eq 0 ]; then
|
|
12
|
+
echo "Usage: microsoft-onenote-exporter <command> [options]"
|
|
13
|
+
echo ""
|
|
14
|
+
echo "Commands:"
|
|
15
|
+
echo " login Sign in to Microsoft and save the session"
|
|
16
|
+
echo " check Report whether the saved session is still valid"
|
|
17
|
+
echo " logout Delete the saved session"
|
|
18
|
+
echo " list List the notebooks on the account"
|
|
19
|
+
echo " export Export one notebook to Markdown"
|
|
20
|
+
echo ""
|
|
21
|
+
echo "Example:"
|
|
22
|
+
echo " docker run -v ./out:/data/output microsoft-onenote-exporter \\"
|
|
23
|
+
echo " export --auth-file /data/output/auth.json --notebook 'Work' --non-interactive"
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# A login cannot run headless: without an email and a password the browser has to
|
|
28
|
+
# be shown, and there is nobody at a terminal inside a container to type into it.
|
|
29
|
+
case "$1" in
|
|
30
|
+
login)
|
|
31
|
+
echo "ERROR: 'login' needs a visible browser, which a container has no way to show." >&2
|
|
32
|
+
echo " Log in on the host first, then mount the resulting auth file:" >&2
|
|
33
|
+
echo " microsoft-onenote-exporter login" >&2
|
|
34
|
+
echo " docker run -v \$HOME/.microsoft-webauth:/data/auth ..." >&2
|
|
35
|
+
exit 2
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
# Allow a shell in the container for debugging, and node for poking at the CLI.
|
|
40
|
+
if [ "$1" = "/bin/sh" ] || [ "$1" = "sh" ]; then
|
|
41
|
+
exec "$@"
|
|
42
|
+
fi
|
|
43
|
+
# `shift` first, then exec node with what is left. Without the shift this runs
|
|
44
|
+
# `node node <script>`, which node reads as a module path named "node" and fails
|
|
45
|
+
# with MODULE_NOT_FOUND - so the documented debugging route
|
|
46
|
+
#
|
|
47
|
+
# docker run --rm -it microsoft-onenote-exporter node /app/src/index.js list
|
|
48
|
+
#
|
|
49
|
+
# never worked. Found by running the built image rather than by reading it.
|
|
50
|
+
if [ "$1" = "node" ]; then
|
|
51
|
+
shift
|
|
52
|
+
exec node "$@"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Where the notes go, unless the caller said otherwise.
|
|
56
|
+
#
|
|
57
|
+
# The container's working directory is /app, so the CLI's own default - ./output
|
|
58
|
+
# against the cwd - resolves to /app/output. That directory exists and is
|
|
59
|
+
# writable, so nothing fails: the export runs to completion and reports
|
|
60
|
+
# "Files saved in: /app/output/<notebook>". The notes are then destroyed with the
|
|
61
|
+
# container, because /app/output is inside the image rather than the mounted
|
|
62
|
+
# volume. A run that looked entirely successful and produced nothing on the host.
|
|
63
|
+
#
|
|
64
|
+
# /data/output is the volume mount, so that is the only default that survives.
|
|
65
|
+
# /app/output stays in the image as a fallback for anyone running the CLI with a
|
|
66
|
+
# working directory of their own choosing.
|
|
67
|
+
if [ ! -d /data/output ]; then
|
|
68
|
+
# No volume mounted: warn rather than silently writing into the image, since
|
|
69
|
+
# that is the failure this line exists to prevent.
|
|
70
|
+
echo "WARNING: no volume is mounted at /data/output." >&2
|
|
71
|
+
echo " Exported notes will be written inside the container and lost" >&2
|
|
72
|
+
echo " when it exits. Mount one, for example:" >&2
|
|
73
|
+
echo " -v \"\$PWD/output:/data/output\"" >&2
|
|
74
|
+
else
|
|
75
|
+
# Appending --output-dir rather than exporting a variable, because the CLI has
|
|
76
|
+
# no environment variable for it and its default resolves against a cwd of
|
|
77
|
+
# /app. Only when the caller did not pass one, for the same reason as
|
|
78
|
+
# --auth-file below: an explicit choice must never be overridden.
|
|
79
|
+
has_output_dir=false
|
|
80
|
+
for arg in "$@"; do
|
|
81
|
+
if [ "$arg" = "--output-dir" ]; then
|
|
82
|
+
has_output_dir=true
|
|
83
|
+
break
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
if [ "$has_output_dir" = false ]; then
|
|
87
|
+
set -- "$@" --output-dir /data/output
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# Only injected when the caller did not pass --auth-file themselves: appending it
|
|
92
|
+
# unconditionally would silently override an explicit choice, and the container
|
|
93
|
+
# would read a different session than the one asked for.
|
|
94
|
+
AUTH_FILE="${AUTH_FILE:-/data/output/auth.json}"
|
|
95
|
+
for arg in "$@"; do
|
|
96
|
+
if [ "$arg" = "--auth-file" ]; then
|
|
97
|
+
AUTH_FILE=""
|
|
98
|
+
break
|
|
99
|
+
fi
|
|
100
|
+
done
|
|
101
|
+
|
|
102
|
+
if [ -n "$AUTH_FILE" ]; then
|
|
103
|
+
exec node /app/src/index.js "$@" --auth-file "$AUTH_FILE"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
exec node /app/src/index.js "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msout/microsoft-onenote-exporter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"src/",
|
|
17
|
+
"entrypoint.sh",
|
|
18
|
+
"start-container.sh",
|
|
17
19
|
"README.md",
|
|
18
20
|
"LICENSE",
|
|
19
21
|
"NOTICE.md",
|
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 = {
|
|
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
|
-
|
|
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 =
|
|
74
|
-
|
|
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(
|
|
89
|
-
info(
|
|
90
|
-
step(
|
|
91
|
-
success(
|
|
92
|
-
warn(
|
|
93
|
-
error(
|
|
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();
|
package/src/steps/export.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Runs one export in a container.
|
|
3
|
+
#
|
|
4
|
+
# Usage: ./start-container.sh --notebook "Work" [--output-dir ./out] [more flags...]
|
|
5
|
+
#
|
|
6
|
+
# Everything is overridable through the environment, because the previous script
|
|
7
|
+
# pointed at a sibling checkout that only existed on one machine and used a fixed
|
|
8
|
+
# image name, so it could not be used anywhere else.
|
|
9
|
+
#
|
|
10
|
+
# IMAGE image to run (default: microsoft-onenote-exporter)
|
|
11
|
+
# CONTAINER container name (default: ms_onenote_export)
|
|
12
|
+
# OUTPUT_DIR host dir for the export (default: ./output)
|
|
13
|
+
# AUTH_FILE session to use (default: the one `login` wrote)
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SESSION="$(basename "${PWD}")"
|
|
18
|
+
IMAGE="${IMAGE:-microsoft-onenote-exporter}"
|
|
19
|
+
CONTAINER="${CONTAINER:-ms_onenote_export_${SESSION}}"
|
|
20
|
+
OUTPUT_DIR="${OUTPUT_DIR:-./output}"
|
|
21
|
+
|
|
22
|
+
# The session defaults to where `microsoft-onenote-exporter login` writes it.
|
|
23
|
+
# Spelled out rather than read from @msout/microsoft-webauth/config at runtime,
|
|
24
|
+
# because this script runs before any node_modules is guaranteed to be present -
|
|
25
|
+
# it has to work in a fresh checkout. test/docker.test.js asserts this path
|
|
26
|
+
# matches what the CLI reports, so the two cannot drift apart silently.
|
|
27
|
+
#
|
|
28
|
+
# This used to default to $OUTPUT_DIR/auth.json, which meant the documented
|
|
29
|
+
# sequence - log in on the host, then run this script - always failed with "no
|
|
30
|
+
# auth file", because the copy step it demanded was never written down
|
|
31
|
+
# anywhere. Logging in on the host is the supported way to get a session into a
|
|
32
|
+
# container, since a container cannot run an interactive login, so the default
|
|
33
|
+
# simply follows it and no copy is needed.
|
|
34
|
+
# Which session to use, in order of preference:
|
|
35
|
+
#
|
|
36
|
+
# 1. $AUTH_FILE, if the caller set it
|
|
37
|
+
# 2. ./output/auth.json, the convention the README documents - the session sits
|
|
38
|
+
# beside the notes in the one mounted volume, so there is nothing else to
|
|
39
|
+
# mount and no copy step on every run
|
|
40
|
+
# 3. ~/.microsoft-webauth/auth-file.json, where `login` writes it, so the
|
|
41
|
+
# "log in on the host, then run this" sequence works with no preparation
|
|
42
|
+
#
|
|
43
|
+
# The default is NOT just (3): a check for (2) has to come first, because with the
|
|
44
|
+
# default set to (3) the script looks for a file named `auth-file.json` in the
|
|
45
|
+
# output directory and silently ignores the `auth.json` a user following the
|
|
46
|
+
# README actually placed there.
|
|
47
|
+
if [ -z "${AUTH_FILE:-}" ]; then
|
|
48
|
+
if [ -f "${OUTPUT_DIR}/auth.json" ]; then
|
|
49
|
+
AUTH_FILE="${OUTPUT_DIR}/auth.json"
|
|
50
|
+
else
|
|
51
|
+
AUTH_FILE="$HOME/.microsoft-webauth/auth-file.json"
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# The subcommand, when the caller did not give one.
|
|
56
|
+
#
|
|
57
|
+
# This script forwards "$@" to the CLI verbatim, so it has to supply `export`
|
|
58
|
+
# itself - the CLI takes it as a subcommand, not as a flag. Without this the
|
|
59
|
+
# container received only `--notebook <name> --non-interactive` and answered
|
|
60
|
+
#
|
|
61
|
+
# error: unknown option '--notebook'
|
|
62
|
+
#
|
|
63
|
+
# which is commander refusing the first flag it saw because the subcommand that
|
|
64
|
+
# should have preceded it was missing. Fixed here rather than by rewriting the
|
|
65
|
+
# caller's arguments, so `./start-container.sh export --notebook X` also works.
|
|
66
|
+
SUBCOMMAND="${1:-}"
|
|
67
|
+
case "$SUBCOMMAND" in
|
|
68
|
+
login | check | logout | list | export)
|
|
69
|
+
shift
|
|
70
|
+
;;
|
|
71
|
+
*)
|
|
72
|
+
SUBCOMMAND="export"
|
|
73
|
+
;;
|
|
74
|
+
esac
|
|
75
|
+
|
|
76
|
+
if [ $# -eq 0 ] && [ "$SUBCOMMAND" = "export" ]; then
|
|
77
|
+
echo "Usage: $0 [--export] --notebook <name> | --notebook-link <url> [options...]" >&2
|
|
78
|
+
echo "" >&2
|
|
79
|
+
echo "Example:" >&2
|
|
80
|
+
echo " $0 --notebook 'Work' --output-dir ./out" >&2
|
|
81
|
+
echo "" >&2
|
|
82
|
+
echo "Or name the step explicitly:" >&2
|
|
83
|
+
echo " $0 list $0 check" >&2
|
|
84
|
+
exit 1
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
if [ $# -eq 0 ]; then
|
|
88
|
+
echo "Usage: $0 <$SUBCOMMAND> [options...]" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# One of these two must be present for a non-interactive export, and finding out
|
|
93
|
+
# here rather than inside the container is the difference between an explanation
|
|
94
|
+
# and `error: unknown option '--notebook-link'`.
|
|
95
|
+
if [ "$SUBCOMMAND" = "export" ]; then
|
|
96
|
+
has_notebook=false
|
|
97
|
+
for arg in "$@"; do
|
|
98
|
+
case "$arg" in
|
|
99
|
+
--notebook | --notebook-link)
|
|
100
|
+
has_notebook=true
|
|
101
|
+
;;
|
|
102
|
+
esac
|
|
103
|
+
done
|
|
104
|
+
if [ "$has_notebook" = false ]; then
|
|
105
|
+
echo "ERROR: an export needs --notebook <name> or --notebook-link <url>." >&2
|
|
106
|
+
echo " This script always runs unattended, so there is no interactive picker" >&2
|
|
107
|
+
echo " to fall back on." >&2
|
|
108
|
+
exit 2
|
|
109
|
+
fi
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
# The container name is derived from the working directory, so it is stable across
|
|
113
|
+
# runs on the same machine - which means the second run collides with the first.
|
|
114
|
+
# Docker refuses to reuse a name, and the error it gives ("Conflict ... already in
|
|
115
|
+
# use by container 302415178a51") names a hash rather than telling you what to do
|
|
116
|
+
# about it, so a script that is meant to be run repeatedly only worked once.
|
|
117
|
+
#
|
|
118
|
+
# A leftover container here is always from a previous run of this script: it exits
|
|
119
|
+
# with `docker run --detach`, so it is never expected to still be running. Removing
|
|
120
|
+
# it is safe for the current run and is what makes the next one possible. Named
|
|
121
|
+
# volumes are untouched; only the container is removed.
|
|
122
|
+
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
|
|
123
|
+
state="$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null || echo unknown)"
|
|
124
|
+
if [ "$state" = "running" ]; then
|
|
125
|
+
echo "ERROR: container '$CONTAINER' is already running." >&2
|
|
126
|
+
echo " Stop it first if that is expected, or set CONTAINER=<other name>." >&2
|
|
127
|
+
exit 1
|
|
128
|
+
fi
|
|
129
|
+
docker rm "$CONTAINER" >/dev/null
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
133
|
+
echo "ERROR: image '$IMAGE' is not built." >&2
|
|
134
|
+
echo " Build it: docker build -t $IMAGE ." >&2
|
|
135
|
+
# The message this replaces only said "build it", which is unhelpful when the
|
|
136
|
+
# image exists under another tag - and "docker build -t foo ." produces
|
|
137
|
+
# foo:latest, while a build tagged foo:test leaves foo:latest missing. So a
|
|
138
|
+
# script looking for an untagged name would report "not built" next to a
|
|
139
|
+
# perfectly good image, with nothing to suggest the retag.
|
|
140
|
+
# Match on the part of the name that survives a tag difference. The tag is
|
|
141
|
+
# stripped because that is the whole point - `foo` and `foo:test` are the
|
|
142
|
+
# same image, and the tag is what the user got wrong. Non-alphanumerics are
|
|
143
|
+
# left in place: stripping them turns `ms-onenote-exporter` into
|
|
144
|
+
# `msonenoteexporter`, which matches no image at all and made this branch
|
|
145
|
+
# silently dead.
|
|
146
|
+
similar="$(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \
|
|
147
|
+
| grep -i -- "$(printf '%s' "$IMAGE" | tr '[:upper:]' '[:lower:]' | cut -d: -f1)" || true)"
|
|
148
|
+
if [ -n "$similar" ]; then
|
|
149
|
+
echo "" >&2
|
|
150
|
+
echo " But these exist:" >&2
|
|
151
|
+
printf ' %s\n' $similar >&2
|
|
152
|
+
echo "" >&2
|
|
153
|
+
echo " Use one of them, or retag:" >&2
|
|
154
|
+
echo " docker tag ${similar%%:*}:${similar##*:} $IMAGE" >&2
|
|
155
|
+
fi
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
# The auth file has to exist before the container starts, because the mount is
|
|
160
|
+
# created from this path and Docker creates a directory when the source is
|
|
161
|
+
# missing - which produces a confusing "not a storage state" error from inside
|
|
162
|
+
# the container rather than an obvious one here.
|
|
163
|
+
if [ ! -f "$AUTH_FILE" ]; then
|
|
164
|
+
echo "ERROR: no auth file at $AUTH_FILE" >&2
|
|
165
|
+
echo " A container cannot run an interactive login, so sign in on the host first:" >&2
|
|
166
|
+
echo " microsoft-onenote-exporter login" >&2
|
|
167
|
+
echo " Then either copy the session into the output directory:" >&2
|
|
168
|
+
echo " cp ~/.microsoft-webauth/auth-file.json ./output/auth.json" >&2
|
|
169
|
+
echo " or point this at it directly:" >&2
|
|
170
|
+
echo " AUTH_FILE=~/.microsoft-webauth/auth-file.json \$0 --notebook 'Work'" >&2
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
mkdir -p "$OUTPUT_DIR"
|
|
175
|
+
|
|
176
|
+
# Resolve so the -v argument is valid even when the path has not been created yet.
|
|
177
|
+
OUTPUT_DIR_ABS="$(cd "$OUTPUT_DIR" && pwd)"
|
|
178
|
+
AUTH_FILE_ABS="$(cd "$(dirname "$AUTH_FILE")" && pwd)/$(basename "$AUTH_FILE")"
|
|
179
|
+
|
|
180
|
+
# The session is mounted separately, read-only, rather than being required to sit
|
|
181
|
+
# inside the output directory. It lives in ~/.microsoft-webauth and is a live
|
|
182
|
+
# credential: it does not belong in the directory the exported notes are
|
|
183
|
+
# collected into, and mounting it read-only means a bug in the container cannot
|
|
184
|
+
# rewrite or delete it. Mounting the file rather than its directory also means
|
|
185
|
+
# the container cannot see the other sessions sitting beside it.
|
|
186
|
+
AUTH_MOUNT=()
|
|
187
|
+
if [ ! -f "${OUTPUT_DIR_ABS}/$(basename "$AUTH_FILE_ABS")" ]; then
|
|
188
|
+
AUTH_MOUNT=(-v "${AUTH_FILE_ABS}:/data/auth/session.json:ro")
|
|
189
|
+
CONTAINER_AUTH_FILE="/data/auth/session.json"
|
|
190
|
+
else
|
|
191
|
+
# Already inside the output directory: mounting it twice would be redundant,
|
|
192
|
+
# and the export should write its logs and notes beside it as documented.
|
|
193
|
+
#
|
|
194
|
+
# This leaves AUTH_MOUNT empty, which is the documented primary path, and an
|
|
195
|
+
# empty array expanded as "${AUTH_MOUNT[@]}" under `set -u` is an error on some
|
|
196
|
+
# bash builds: the wrapper died with
|
|
197
|
+
#
|
|
198
|
+
# line 222: AUTH_MOUNT[@]: unbound variable
|
|
199
|
+
#
|
|
200
|
+
# before starting any container at all - so the documented way to run an
|
|
201
|
+
# export could not run, while the fallback path worked and hid the bug. The
|
|
202
|
+
# expansion below is guarded so an empty array contributes no arguments.
|
|
203
|
+
CONTAINER_AUTH_FILE="/data/output/$(basename "$AUTH_FILE_ABS")"
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# Logs go beside the notes, not inside the image. Without this the run's
|
|
207
|
+
# app.log - the only record of what an export actually did - died with the
|
|
208
|
+
# container, and the script's own "see logs/app.log" advice pointed at a path
|
|
209
|
+
# that never existed on the host.
|
|
210
|
+
ONENOTE_EXPORT_LOG_DIR=/data/output/logs
|
|
211
|
+
|
|
212
|
+
echo "Container : $CONTAINER"
|
|
213
|
+
echo "Image : $IMAGE"
|
|
214
|
+
echo "Auth file : $AUTH_FILE_ABS"
|
|
215
|
+
echo "Output : $OUTPUT_DIR_ABS"
|
|
216
|
+
echo ""
|
|
217
|
+
# Echoed to match what is really executed below. This line used to print
|
|
218
|
+
# "microsoft-onenote-exporter $* --non-interactive", which was missing the
|
|
219
|
+
# subcommand that the invocation adds - so the most reassuring line in the script
|
|
220
|
+
# was describing a command that was never run.
|
|
221
|
+
echo "Running: microsoft-onenote-exporter $SUBCOMMAND $* --auth-file $CONTAINER_AUTH_FILE"
|
|
222
|
+
echo ""
|
|
223
|
+
|
|
224
|
+
# Chromium needs more than Docker's default 64 MB of shared memory or it crashes
|
|
225
|
+
# on memory-heavy pages, hence --shm-size. --init runs a tiny PID-1 reaper so
|
|
226
|
+
# Chromium's child processes are cleaned up instead of accumulating as zombies
|
|
227
|
+
# when the export ends.
|
|
228
|
+
#
|
|
229
|
+
# The container runs detached and the exit status is polled below, so the script
|
|
230
|
+
# can report the result and the image can be reused for another run without
|
|
231
|
+
# rebuilding it.
|
|
232
|
+
docker run --detach \
|
|
233
|
+
--name "$CONTAINER" \
|
|
234
|
+
--init \
|
|
235
|
+
--shm-size=1g \
|
|
236
|
+
-e ONENOTE_EXPORT_LOG_DIR="$ONENOTE_EXPORT_LOG_DIR" \
|
|
237
|
+
-v "${OUTPUT_DIR_ABS}:/data/output" \
|
|
238
|
+
${AUTH_MOUNT[@]+"${AUTH_MOUNT[@]}"} \
|
|
239
|
+
"$IMAGE" \
|
|
240
|
+
"$SUBCOMMAND" "$@" --auth-file "$CONTAINER_AUTH_FILE" >/dev/null
|
|
241
|
+
|
|
242
|
+
# Wait for the export to finish.
|
|
243
|
+
#
|
|
244
|
+
# `docker wait` is used rather than a poll loop over `docker inspect`. A loop
|
|
245
|
+
# written the obvious way reads State.ExitCode first and stops as soon as it is
|
|
246
|
+
# non-empty - and that field is 0 *while the container is still running*, so the
|
|
247
|
+
# loop exited on its first iteration and reported success for an export that had
|
|
248
|
+
# not written a single file. `docker wait` blocks until the container actually
|
|
249
|
+
# stops and then returns its status, which is the one question being asked here.
|
|
250
|
+
#
|
|
251
|
+
# It has no timeout, so a separate watchdog decides when to stop waiting and say
|
|
252
|
+
# so. The cap is generous - a large notebook over a slow connection can
|
|
253
|
+
# legitimately take tens of minutes - but past it the script reports rather than
|
|
254
|
+
# appearing to still be working. The container is left running so it can be
|
|
255
|
+
# inspected with `docker logs`.
|
|
256
|
+
WAIT_LIMIT=$((60 * 60)) # one hour
|
|
257
|
+
( sleep "$WAIT_LIMIT"
|
|
258
|
+
if docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q true; then
|
|
259
|
+
echo "WARNING: the export is still running after $((WAIT_LIMIT / 60)) minutes." >&2
|
|
260
|
+
echo " It has been left running; watch it with:" >&2
|
|
261
|
+
echo " docker logs -f $CONTAINER" >&2
|
|
262
|
+
# 124 is the conventional timeout status, and is reported as a failure
|
|
263
|
+
# rather than a success so a pipeline cannot mistake it for a clean run.
|
|
264
|
+
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
|
265
|
+
fi
|
|
266
|
+
) &
|
|
267
|
+
WATCHDOG_PID=$!
|
|
268
|
+
|
|
269
|
+
# Wait for it, then propagate the status. The container runs the export as PID 1
|
|
270
|
+
# under the reaper, so this is the container's exit status - which is the export's,
|
|
271
|
+
# because the CLI exits with the code the export step reported.
|
|
272
|
+
EXIT_CODE="$(docker wait "$CONTAINER")"
|
|
273
|
+
|
|
274
|
+
kill "$WATCHDOG_PID" 2>/dev/null || true
|
|
275
|
+
wait "$WATCHDOG_PID" 2>/dev/null || true
|
|
276
|
+
|
|
277
|
+
echo ""
|
|
278
|
+
if [ "$EXIT_CODE" -eq 0 ]; then
|
|
279
|
+
echo "Exported files are in: $OUTPUT_DIR_ABS"
|
|
280
|
+
else
|
|
281
|
+
case "$EXIT_CODE" in
|
|
282
|
+
1)
|
|
283
|
+
echo "WARNING: the export failed and produced nothing usable." >&2
|
|
284
|
+
;;
|
|
285
|
+
2)
|
|
286
|
+
echo "WARNING: the arguments were wrong - check --notebook or --notebook-link." >&2
|
|
287
|
+
;;
|
|
288
|
+
3)
|
|
289
|
+
echo "NOTE: the export finished but some pages, sections or groups are missing." >&2
|
|
290
|
+
echo " The notes that were written are complete and name any asset they" >&2
|
|
291
|
+
echo " could not download. Re-run to try again." >&2
|
|
292
|
+
;;
|
|
293
|
+
*)
|
|
294
|
+
echo "WARNING: the container exited with status $EXIT_CODE." >&2
|
|
295
|
+
;;
|
|
296
|
+
esac
|
|
297
|
+
echo "Anything already written to $OUTPUT_DIR_ABS has been kept." >&2
|
|
298
|
+
echo "See logs/app.log inside the output directory for the full run." >&2
|
|
299
|
+
fi
|
|
300
|
+
|
|
301
|
+
exit "$EXIT_CODE"
|