Compare commits
2 Commits
feat/forma
...
f545b7cf54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f545b7cf54 | ||
|
|
6173b519db |
@@ -156,11 +156,17 @@ If you have notes in Apple Notes, migrate them now — see
|
||||
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
# --folder takes that folder and everything under it; leave it off for all notes
|
||||
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson
|
||||
wc -l notes.ndjson # one line per note
|
||||
schulcloud note import notes.ndjson --dry-run # look first
|
||||
schulcloud note import notes.ndjson
|
||||
```
|
||||
|
||||
The export runs on the Mac; the import does not have to. The CLI talks to the
|
||||
server over HTTP, so copying the `.ndjson` to whatever machine already has the
|
||||
CLI configured is the shorter path.
|
||||
|
||||
Import **once**. Re-running creates second copies, because the importer cannot
|
||||
tell an edited note from a new one with the same title.
|
||||
|
||||
|
||||
@@ -189,11 +189,22 @@ not the clumsy route to your notes — it is the only one.
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||||
```
|
||||
|
||||
The first run raises a macOS permission dialog ("Terminal wants access to
|
||||
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
|
||||
Notes folder.
|
||||
Notes"); without it every note comes back empty.
|
||||
|
||||
**`--folder Schule` exports one folder and everything under it.** Folder names
|
||||
are matched whole, not as a fragment, and the path is recorded relative to the
|
||||
one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note
|
||||
sitting loose in `Schule` arrives with no subject rather than one called
|
||||
"Schule". Reading a note's body is the expensive part, so filtering here rather
|
||||
than afterwards is also what makes a large library finish.
|
||||
|
||||
The records go to standard output and the progress line to standard error, so
|
||||
`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson`
|
||||
should be the number the script reported.
|
||||
|
||||
Then look at what it would do, and do it:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Run this **on the Mac that has the notes**:
|
||||
*
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||||
*
|
||||
* then hand the file to `schulcloud note import notes.ndjson`.
|
||||
*
|
||||
@@ -18,9 +18,15 @@
|
||||
* JXA and not AppleScript because it can serialise JSON, and because reading
|
||||
* properties one note at a time is what keeps a locked note from aborting the
|
||||
* run rather than a language preference.
|
||||
*
|
||||
* **Nothing here uses `console.log`.** In JXA it writes to standard *error*,
|
||||
* not standard output — so `> notes.ndjson` produced an empty file while every
|
||||
* note scrolled past on the terminal. Both streams are written through
|
||||
* NSFileHandle below, which is the only way to be sure which one you are on.
|
||||
*/
|
||||
|
||||
ObjC.import('stdlib');
|
||||
ObjC.import('Foundation');
|
||||
|
||||
function run(argv) {
|
||||
const options = parseArguments(argv);
|
||||
@@ -41,23 +47,62 @@ function run(argv) {
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const note = items[i];
|
||||
const record = readNote(note);
|
||||
// The folder first. The body is the one expensive property — it is
|
||||
// decompressed per note and is what makes a big library take minutes —
|
||||
// and a note in another folder is not worth reading one for.
|
||||
const folder = folderUnder(safe(function () { return folderPath(note.container()); }, ''), options.folder);
|
||||
if (folder === null) continue;
|
||||
|
||||
const record = readNote(note, folder);
|
||||
if (!record) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
|
||||
// One object per line, so a huge export streams and a bad note costs one line.
|
||||
console.log(JSON.stringify(record));
|
||||
emit(JSON.stringify(record));
|
||||
written++;
|
||||
}
|
||||
|
||||
// stderr, so it never lands in the file being redirected.
|
||||
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
|
||||
log(
|
||||
'Exported ' + written + ' note(s)' +
|
||||
(options.folder ? ' from "' + options.folder + '"' : '') +
|
||||
(skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') +
|
||||
'.',
|
||||
);
|
||||
if (written === 0) {
|
||||
log(
|
||||
options.folder
|
||||
? 'No note is in a folder called "' + options.folder + '". Folder names are matched whole, not as a fragment.'
|
||||
: 'No notes were readable. Grant the terminal access to Notes and try again.',
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNote(note) {
|
||||
/**
|
||||
* A note's folder as seen from the one asked for, or null if it is elsewhere.
|
||||
*
|
||||
* Matched by path segment, not substring: `--folder Schule` takes `Schule` and
|
||||
* everything under it, and leaves `Musikschule` alone.
|
||||
*
|
||||
* The path is recorded *relative* to that folder, because the import reads the
|
||||
* last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and
|
||||
* a note sitting loose in `Schule` has to arrive with no folder at all — a
|
||||
* subject of "Schule" is not a subject. Without `--folder` the full path is
|
||||
* kept, which is the same thing measured from the top.
|
||||
*/
|
||||
function folderUnder(path, wanted) {
|
||||
if (!wanted) return path;
|
||||
const parts = String(path).split('/');
|
||||
const target = wanted.toLowerCase();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].trim().toLowerCase() === target) return parts.slice(i + 1).join('/');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNote(note, folder) {
|
||||
try {
|
||||
// Read the body first: it is the property a locked note refuses, and
|
||||
// there is no point building a record we cannot fill.
|
||||
@@ -66,7 +111,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, ''),
|
||||
body: body || '',
|
||||
folder: safe(function () { return folderPath(note.container()); }, ''),
|
||||
folder: folder,
|
||||
created: safe(function () { return iso(note.creationDate()); }, ''),
|
||||
modified: safe(function () { return iso(note.modificationDate()); }, ''),
|
||||
};
|
||||
@@ -77,6 +122,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, '(unreadable)'),
|
||||
body: '',
|
||||
folder: folder,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
@@ -128,10 +174,18 @@ function parseArguments(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** One line of the export, on standard output — the file being redirected. */
|
||||
function emit(line) {
|
||||
write($.NSFileHandle.fileHandleWithStandardOutput, line);
|
||||
}
|
||||
|
||||
/** Progress and problems, on standard error, so they never land in the file. */
|
||||
function log(message) {
|
||||
$.NSFileHandle.fileHandleWithStandardError.writeData(
|
||||
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
|
||||
);
|
||||
write($.NSFileHandle.fileHandleWithStandardError, message);
|
||||
}
|
||||
|
||||
function write(handle, text) {
|
||||
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
|
||||
@@ -78,6 +78,14 @@ describe('convertAppleNote', () => {
|
||||
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
|
||||
});
|
||||
|
||||
it('gives no subject to a note the export could not place', () => {
|
||||
// What `--folder Schule` produces for a note sitting loose in Schule:
|
||||
// the path relative to the folder asked for, which is nothing. A subject
|
||||
// of "Schule" would file every such note under a subject that is not one.
|
||||
assert.equal(convertAppleNote({ ...note, folder: '' }).subject, undefined);
|
||||
assert.equal(convertAppleNote({ ...note, folder: ' ' }).subject, undefined);
|
||||
});
|
||||
|
||||
it('does not repeat the title as the first line of the body', () => {
|
||||
const converted = convertAppleNote(note);
|
||||
assert.equal(converted.title, 'Erörterung');
|
||||
|
||||
Reference in New Issue
Block a user