UNPKG

@mieweb/wikigdrive

Version:

Google Drive to MarkDown synchronization

324 lines (279 loc) 12 kB
import { Buffer } from 'node:buffer'; import winston from 'winston'; import * as htmlparser2 from 'htmlparser2'; import {ElementType} from 'htmlparser2'; import * as domutils from 'domutils'; import {Element, Text} from 'domhandler'; import render from 'dom-serializer'; import {FileId} from '../../model/model.ts'; import {MimeTypes} from '../../model/GoogleFile.ts'; import {LocalFile} from '../../model/LocalFile.ts'; import {Container, ContainerConfig, ContainerConfigArr, ContainerEngine} from '../../ContainerEngine.ts'; import {GoogleDriveService} from '../../google/GoogleDriveService.ts'; import {UserConfigService} from './UserConfigService.ts'; import {FileContentService} from '../../utils/FileContentService.ts'; import {getContentFileService} from '../transform/utils.ts'; import {DirectoryScanner} from '../transform/DirectoryScanner.ts'; import {getDesiredPath} from '../transform/LocalFilesGenerator.ts'; import {markdownToHtml} from '../../google/markdownToHtml.ts'; import {convertToAbsolutePath} from '../../LinkTranslator.ts'; const __filename = import.meta.filename; interface FileToUpload { performRewrite?: string; path: string; file: LocalFile; parent: FileId; } export class UploadContainer extends Container { private progressNotifyCallback: ({total, completed}: { total?: number; completed?: number }) => void; private logger: winston.Logger; private googleDriveService: GoogleDriveService; private userConfigService: UserConfigService; private generatedFileService: FileContentService; private pathToIdMap = {}; constructor(public readonly params: ContainerConfig, public readonly paramsArr: ContainerConfigArr = {}) { super(params, paramsArr); } async mount2(fileService: FileContentService, destFileService: FileContentService): Promise<void> { this.filesService = fileService; this.generatedFileService = destFileService; this.userConfigService = new UserConfigService(this.filesService); await this.userConfigService.load(); } async init(engine: ContainerEngine): Promise<void> { await super.init(engine); this.logger = engine.logger.child({ filename: __filename, driveId: this.params.name, jobId: this.params.jobId }); this.googleDriveService = new GoogleDriveService(this.logger, null); // this.auth = googleApiContainer.getAuth(); } // eslint-disable-next-line @typescript-eslint/no-empty-function async destroy(): Promise<void> { } async addIds(driveIdId: FileId, dirFileService: FileContentService, files: FileToUpload[]) { const access_token = this.params.access_token; let cnt = 0; for (const entry of files) { const file = entry.file; switch (file.mimeType) { case MimeTypes.IMAGE_SVG: case MimeTypes.MARKDOWN: if (file.id === 'TO_FILL') { cnt++; } break; } } if (cnt > 0) { const ids = await this.googleDriveService.generateIds(access_token, cnt); for (const entry of files) { const file = entry.file; switch (file.mimeType) { case MimeTypes.IMAGE_SVG: case MimeTypes.MARKDOWN: if (file.id === 'TO_FILL') { file.id = ids.splice(0, 1)[0]; file['isNewId'] = true; } break; } } } } async updateLinks(driveIdId: FileId, dirFileService: FileContentService, files: FileToUpload[]) { // Second pass because of error: Generated IDs are not supported for Docs Editors formats const access_token = this.params.access_token; for (const entry of files) { const file = entry.file; try { switch (file.mimeType) { case MimeTypes.MARKDOWN: if (file.id && file.id !== 'TO_FILL' && entry.performRewrite) { const rewrittenHtml = await this.rewriteLinks(entry.performRewrite, entry.path); const response = await this.googleDriveService.update(access_token, entry.parent, file.title, MimeTypes.HTML, rewrittenHtml, file.id); if (response) { this.logger.info('[' + entry.path + ']: updated links'); } } break; } } catch (err) { this.logger.error('[' + entry.path + ']: ' + err.message); } } } async uploadFiles(driveIdId: FileId, dirFileService: FileContentService, files: FileToUpload[]) { const access_token = this.params.access_token; for (const entry of files) { const file = entry.file; try { switch (file.mimeType) { case MimeTypes.FOLDER_MIME: break; case MimeTypes.MARKDOWN: if (file.id && file.id !== 'TO_FILL') { const content = await dirFileService.readBuffer(entry.path); const html = await markdownToHtml(content); entry.performRewrite = html; const buffer = Buffer.from(new TextEncoder().encode(html)); if (file['isNewId']) { this.logger.info(`Uploading new document: ${file.fileName} to folder: ${entry.parent}`); const response = await this.googleDriveService.upload(access_token, entry.parent, file.title, MimeTypes.HTML, buffer); // generatedIds not supported to gdocs file.id = response.id; delete file['isNewId']; } else { this.logger.info(`Updating document: ${file.fileName} to folder: ${entry.parent}`); const response = await this.googleDriveService.update(access_token, entry.parent, file.title, MimeTypes.HTML, buffer, file.id); file.id = response.id; } } break; case MimeTypes.IMAGE_SVG: if (file.id && file.id !== 'TO_FILL') { const content = await dirFileService.readBuffer(entry.path); if (file['isNewId']) { this.logger.info(`Uploading new diagram: ${file.fileName} to folder: ${entry.parent}`); const response = await this.googleDriveService.upload(access_token, entry.parent, file.title, file.mimeType, content, file.id); file.id = response.id; delete file['isNewId']; } else { this.logger.info(`Updating diagram: ${file.fileName} to folder: ${entry.parent}`); const response = await this.googleDriveService.update(access_token, entry.parent, file.title, file.mimeType, content, file.id); file.id = response.id; } } break; } } catch (err) { this.logger.error('[' + entry.path + ']: ' + err.message); } if (file.id && file.id !== 'TO_FILL') { this.pathToIdMap[entry.path] = file.id; } } } async uploadDir(folderId: FileId, dirFileService: FileContentService, parentPath: string): Promise<FileToUpload[]> { const retVal: FileToUpload[] = []; const scanner = new DirectoryScanner(); const files = await scanner.scan(dirFileService); const access_token = this.params.access_token; const auth = { async getAccessToken(): Promise<string> { return access_token; } }; const gdocFiles = await this.googleDriveService.listFiles(auth, { folderId }); const map = {}; for (const gdocFile of gdocFiles) { const name = getDesiredPath(gdocFile.name); map[name] = gdocFile; } for (const file of Object.values(files)) { const fullPath = parentPath + '/' + file.fileName; if (fullPath === '/toc.md') { continue; } if (!file.title) { this.logger.warn(`Skipping upload: ${fullPath}. No title, check frontmatter.`); continue; } this.logger.info(`Scheduled upload: ${fullPath}`); switch (file.mimeType) { case MimeTypes.FOLDER_MIME: if (file.id === 'TO_FILL') { if (!map[getDesiredPath(file.title)]) { const response = await this.googleDriveService.createDir(access_token, folderId, file.title); file.id = response.id; } else { file.id = map[file.title].id; } } retVal.push(...await this.uploadDir(file.id, await dirFileService.getSubFileService(file.fileName), fullPath)); break; case MimeTypes.MARKDOWN: if (map[getDesiredPath(file.title)]) { file.id = map[getDesiredPath(file.title)].id; } else { // Upload only missing file.id = 'TO_FILL'; retVal.push({ path: parentPath + '/' + file.fileName, file, parent: folderId }); } break; case MimeTypes.IMAGE_SVG: if (map[getDesiredPath(file.title)]) { file.id = map[getDesiredPath(file.title)].id; } else { // Upload only missing file.id = 'TO_FILL'; retVal.push({ path: parentPath + '/' + file.fileName, file, parent: folderId }); } break; } if (file.id && file.id !== 'TO_FILL') { this.pathToIdMap[fullPath] = file.id; } } return retVal; } async run() { const config = this.userConfigService.config; if (!config.transform_subdir) { throw new Error('Content subdirectory must be set and start with /'); } this.pathToIdMap = {}; const contentFileService = await getContentFileService(this.generatedFileService, this.userConfigService); const files = await this.uploadDir(this.params.folderId, contentFileService, ''); await this.addIds(this.params.folderId, contentFileService, files); await this.uploadFiles(this.params.folderId, contentFileService, files); await this.updateLinks(this.params.folderId, contentFileService, files); this.logger.info('Upload finished'); } onProgressNotify(callback: ({total, completed, warnings}: { total?: number; completed?: number; warnings?: number }) => void) { this.progressNotifyCallback = callback; } private async rewriteLinks(html: string, entryPath: string): Promise<Buffer> { const dom = htmlparser2.parseDocument(html); const links = domutils.findAll((elem: Element) => { return ['a'].includes(elem.tagName) && !!elem.attribs?.href; }, dom.childNodes); const images = domutils.findAll((elem: Element) => { return ['img'].includes(elem.tagName) && !!elem.attribs?.src; }, dom.childNodes); for (const elem of links) { const targetPath = convertToAbsolutePath(entryPath, elem.attribs.href); if (!targetPath) { continue; } if (this.pathToIdMap[targetPath]) { elem.attribs.href = 'https://drive.google.com/open?id=' + this.pathToIdMap[targetPath]; } } for (const elem of images) { const targetPath = convertToAbsolutePath(entryPath, elem.attribs.src); if (this.pathToIdMap[targetPath]) { elem.attribs.src = 'https://drive.google.com/open?id=' + this.pathToIdMap[targetPath]; } const alt = elem.attribs.alt || ''; elem.tagName = 'span'; // Google Drive import ignores <code>sth</code>. Use: <span class="class_with_courier_font">sth</span>. elem.attribs.class = 'code'; const txt = new Text(`{{markdown}}![${alt}](${elem.attribs.src}){{/markdown}}`); elem.children.push(txt); if (elem.parentNode.type === ElementType.Tag) { const el: Element = <Element>elem.parentNode; if (el.tagName === 'a') { const href = el.attribs.href || ''; const txt = new Text(`{{markdown}}[![${alt}](${elem.attribs.src})](${href}){{/markdown}}`); const code = new Element('span', { 'class': 'code' }, [ txt ]); domutils.replaceElement(el, code); } } } const rewrittenHtml = render(dom); return Buffer.from(new TextEncoder().encode(rewrittenHtml)); } }