@inkdropapp/live-export
Version:
A library for programmatically exoprting notes to local filesystem from Inkdrop
335 lines (331 loc) • 12.7 kB
JavaScript
;
var debug = require('debug');
var fs = require('fs');
var unified = require('unified');
var remarkParse = require('remark-parse');
var remarkFrontmatter = require('remark-frontmatter');
var remarkStringify = require('remark-stringify');
var unistUtilVisit = require('unist-util-visit');
var yaml = require('js-yaml');
const logger = {
debug: debug('inkdrop:export:debug'),
info: debug('inkdrop:export:info'),
error: debug('inkdrop:export:error')
};
const extractDocIdFromUri = (uri) => {
const [, fileId] = uri.match(/inkdrop:\/\/([^\/]*)/) || [];
return fileId;
};
class LiveExporter {
constructor(config) {
this.fileNameMap = {};
this.tagMap = {};
this.config = config;
}
async callApi(path, query = {}) {
const { hostname, username, password, port } = this.config;
const headers = new Headers();
headers.set('Authorization', 'Basic ' + Buffer.from(username + ':' + password).toString('base64'));
const url = new URL(`http://${hostname || '127.0.0.1'}:${port}${path}`);
Object.keys(query).forEach(key => url.searchParams.append(key, query[key]));
const response = await fetch(url, {
method: 'GET',
headers
}).then(response => response.json());
logger.debug('callApi:', path, query, 'response:', JSON.stringify(response, null, 4));
return response;
}
async getLatestSeq() {
const res = await this.callApi('/_changes', {
include_docs: false,
descending: true,
limit: 1
});
return res.last_seq;
}
async getChanges(since) {
return this.callApi('/_changes', {
since,
include_docs: true
});
}
async getNotes(bookId) {
return this.callApi('/notes', {
keyword: `bookId:${bookId}`,
sort: 'updatedAt',
descending: true,
limit: 100
});
}
async getTags() {
return this.callApi('/tags', {});
}
async getTagsWithIds(ids) {
const tags = [];
for (const tagId of ids) {
if (this.tagMap[tagId]) {
tags.push(this.tagMap[tagId]);
}
else {
logger.debug('Fetching a tag:', tagId);
const tag = await this.getDoc(tagId);
if (tag) {
this.tagMap[tagId] = tag;
tags.push(tag);
}
}
}
return tags;
}
async getDoc(docId, options = {}) {
const res = await this.callApi(`/${docId}`, options);
if (typeof res?.ok === 'boolean' && res.ok === false) {
throw new Error(res.error || `Failed to get ${docId}`);
}
return res;
}
getExtensionForFile(file) {
switch (file.contentType) {
case 'image/jpeg':
return '.jpg';
case 'image/svg+xml':
return '.svg';
default:
return '.' + file.contentType.split('/')[1];
}
}
writeFile(toPath, file) {
const oldPath = this.fileNameMap[file._id];
if (oldPath && oldPath !== toPath) {
fs.unlinkSync(oldPath);
}
const base64Data = file._attachments.index.data;
const data = Buffer.from(base64Data, 'base64');
fs.writeFileSync(toPath, data);
this.fileNameMap[file._id] = toPath;
}
writeNote(toPath, noteId, md) {
const oldPath = this.fileNameMap[noteId];
if (oldPath && oldPath !== toPath) {
fs.unlinkSync(oldPath);
}
fs.writeFileSync(toPath, md);
this.fileNameMap[noteId] = toPath;
}
removeExportedFile(docId) {
const filePath = this.fileNameMap[docId];
if (filePath) {
fs.unlinkSync(filePath);
delete this.fileNameMap[docId];
}
}
async parseNote(note, params) {
const md = note.body;
const tree = unified.unified().use(remarkParse).use(remarkFrontmatter).parse(md);
const yamlNode = tree.children.find(child => child.type === 'yaml');
const yamlData = yaml.load(yamlNode?.value || '') || {};
const tags = await this.getTagsWithIds(note.tags || []);
if (params.preProcessNote) {
await params.preProcessNote({
note,
frontmatter: yamlData,
mdast: tree,
tags
});
}
logger.debug('tree:', JSON.stringify(tree, null, 4));
logger.debug('yaml data:', yamlData);
return {
note: note,
tree,
yamlNode,
yamlData,
tags
};
}
async exportNote(note, params) {
logger.info('Exporting note:', note._id, note.title);
let md = note.body;
const { tree, yamlNode, yamlData, tags } = await this.parseNote(note, params);
const fnNote = await params.pathForNote({
note,
frontmatter: yamlData,
tags
});
if (fnNote) {
const nodes = [];
unistUtilVisit.visit(tree, [{ type: 'image' }, { type: 'link' }], el => {
if (el.type === 'image' && el.url.startsWith('inkdrop://file:')) {
nodes.push(el);
}
else if (el.type === 'link' &&
el.url.startsWith('inkdrop://note/')) {
nodes.push(el);
}
}, true // reverse
);
for (const node of nodes) {
/*
* Process internal images
*/
if (node.type === 'image') {
const fileId = extractDocIdFromUri(node.url);
if (fileId) {
try {
logger.info('Exporting image:', fileId);
const idFile = await this.getDoc(fileId, {
attachments: true
});
const ext = this.getExtensionForFile(idFile);
const { filePath: fnFile, url: urlFile } = (await params.pathForFile({
mdastNode: node,
note,
file: idFile,
extension: ext,
frontmatter: yamlData,
tags
})) || {};
logger.debug('file:', idFile);
logger.debug('destF:', fnFile);
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
if (fnFile &&
urlFile &&
typeof start === 'number' &&
typeof end === 'number') {
this.writeFile(fnFile, idFile);
const mdImage = {
...node,
url: urlFile
};
const mdImageStr = unified.unified()
.use(remarkStringify)
.stringify({ type: 'root', children: [mdImage] });
md = md.substring(0, start) + mdImageStr + md.substring(end + 1);
}
else {
this.removeExportedFile(fileId);
}
}
catch (e) {
logger.error('Failed to get a file:', fileId, node);
logger.error(e);
}
}
}
else if (node.type === 'link') {
/*
* Process internal links
*/
const [, noteIdPre] = node.url.match(/inkdrop:\/\/(note\/([^\/]*))/) || [];
if (noteIdPre && params.urlForNote) {
const linkDestNoteId = noteIdPre.replace('/', ':');
const linkDestNote = await this.getDoc(linkDestNoteId);
if (linkDestNote) {
const { yamlData } = await this.parseNote(linkDestNote, params);
logger.debug('Found an internal link:', node, linkDestNoteId, yamlData);
const url = await params.urlForNote({
note: linkDestNote,
frontmatter: yamlData,
tags
});
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
if (url && start && end) {
const mdLink = {
...node,
url
};
const mdLinkStr = unified.unified()
.use(remarkStringify)
.stringify({ type: 'root', children: [mdLink] });
md = md.substring(0, start) + mdLinkStr + md.substring(end + 1);
}
}
else {
logger.error('Failed to get a note:', linkDestNoteId);
}
}
}
}
if (yamlNode) {
md =
md.substring(0, yamlNode.position?.start.offset || 0) +
`---\n` +
yaml.dump(yamlData) +
`---` +
md.substring(yamlNode.position?.end.offset || 0 + 1);
}
md = params.postProcessNote
? await params.postProcessNote({
md,
frontmatter: yamlData,
tags
})
: md;
this.writeNote(fnNote, note._id, md);
}
else {
this.removeExportedFile(note._id);
}
}
async watchChanges(params) {
logger.info('Watching changes..');
let since = params.since ?? (await this.getLatestSeq());
const timer = setInterval(async () => {
try {
const { results, last_seq } = await this.getChanges(since);
for (const change of results) {
if (change.id.startsWith('note:') &&
change.doc.bookId === params.bookId &&
change.seq > since) {
const note = change.doc;
await this.exportNote(note, params);
}
if (change.doc._deleted) {
this.removeExportedFile(change.id);
}
}
since = Number(last_seq);
}
catch (e) {
logger.error(e);
clearInterval(timer);
}
}, 500);
return {
stop: () => {
clearInterval(timer);
}
};
}
async start(params) {
// const tags = await this.getTags()
const tags = [];
this.tagMap = tags.reduce((map, t) => ({ ...map, [t._id]: t }), {});
const notes = await this.getNotes(params.bookId);
for (const n of notes) {
await this.exportNote({ ...n }, params);
}
if (params.live) {
return this.watchChanges(params);
}
else {
return true;
}
}
}
const kebabCaseToPascalCase = (string = '') => {
return string.replace(/(^\w|-\w)/g, replaceString => replaceString.replace(/-/, '').toUpperCase());
};
const toKebabCase = (str) => {
return str
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
?.map(x => x.toLowerCase())
?.join('-');
};
exports.LiveExporter = LiveExporter;
exports.extractDocIdFromUri = extractDocIdFromUri;
exports.kebabCaseToPascalCase = kebabCaseToPascalCase;
exports.toKebabCase = toKebabCase;
//# sourceMappingURL=index.cjs.map