UNPKG

@inkdropapp/live-export

Version:

A library for programmatically exoprting notes to local filesystem from Inkdrop

335 lines (331 loc) 12.7 kB
'use strict'; 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