import { BehaviorSubject, Subject, filter, ReplaySubject, switchMap, from, map, firstValueFrom, } from 'rxjs' import { OutputsView } from './cell-views' import * as webpm from '@youwol/webpm-client' import { AnyVirtualDOM } from '@youwol/rx-vdom' import { CellCommonAttributes, defaultCellAttributes, Dependencies, DeportedOutputsView, InterpreterCellView, JsCellExecutor, JsCellView, MdCellView, NotebookPage, PyCellExecutor, PyCellView, Views, } from '.' import { Router } from '../router' import { fromFetch } from 'rxjs/fetch' import type { MdParsingOptions } from '../markdown' import { defaultDisplayFactory, DisplayFactory } from './display-utils' import { WorkerCellView } from './worker-cell-view' export type CellStatus = | 'unready' | 'ready' | 'success' | 'error' | 'pending' | 'executing' /** * Represents the scope of a cell. * * This is a work in progress: at least functions and classes are missing. */ export type Scope = { /** * The `let` variables: keyed by their name and mapped to their values. */ let: { [k: string]: unknown } /** * The `const` variables: keyed by their name and mapped to their values. */ const: { [k: string]: unknown } /** * The exported globals of python runtime. */ python: { [k: string]: unknown } } /** * Type of the outputs generated by a cell. * * The type `undefined` is a signal to clear the outputs displayed. */ export type Output = AnyVirtualDOM | undefined /** * Arguments used to execute a cell, see {@link CellTrait.execute}. */ export type ExecArgs = { /** * Cell ID. */ cellId: string /** * Source to execute. */ src: string /** * The function used to load a submodule from a notebook page. * * @param path Navigation path of the submodule. * @returns The exported symbols. */ load: (path: string) => Promise<{ [k: string]: unknown }> /** * Subject in which output (*e.g.* when using ̀display` in a {@link JsCellView}) are sent. */ output$: Subject /** * The factory used to pick up the right mapping between variable and view when `display` is called. */ displayFactory: DisplayFactory /** * Owning state of the cell. */ owningState: State /** * Entering scope of the cell. */ scope: Scope } /** * Trait for a Cell within a {@link NotebookPage}. */ export interface CellTrait { /** * Cell unique ID. */ cellId: string /** * Observable over the source content of the cell. */ content$: BehaviorSubject /** * Define the implementation of cell execution. */ execute: (args: ExecArgs) => Promise } /** * Represents the state of a {@link NotebookPage}. */ export class State { /** * Observables over the cell's entering scopes keyed by the cell's ID. */ public readonly scopes$: { [k: string]: BehaviorSubject } = {} /** * The factory used to pick up the right mapping between variable and view when `display` is called. */ public readonly displayFactory: DisplayFactory = defaultDisplayFactory() /** * Observable that emits the ID of invalidated cells. */ public readonly invalidated$ = new Subject() /** * Observables over the cell's output keyed by the cell's ID. */ public readonly outputs$: { [k: string]: ReplaySubject } = {} /** * Observables over the cell's source keyed by the cell's ID. */ public readonly src$: { [k: string]: BehaviorSubject } = {} /** * Observables over the cell's status keyed by the cell's ID. */ public readonly cellsStatus$: { [k: string]: BehaviorSubject } = {} /** * Observables over the cell IDs included in the associated {@link NotebookPage}. */ public readonly cellIds$ = new BehaviorSubject([]) /** * Observables over whether the cell is currently executing keyed by the cell's ID. */ public readonly executing$: { [k: string]: BehaviorSubject } = {} /** * The deported output views as a list of their associated cell ID. */ public readonly deportedOutputsViews: string[] = [] /** * List of the cell IDs in the page. */ public readonly ids: string[] = [] /** * List of the cells in the page. */ public readonly cells: CellTrait[] = [] /** * The initial scope (provided to the first cell when executed). */ public readonly initialScope: Scope /** * Optional related parent state (*e.g.* {@link MdCellView} own their own executing state created upon execution). */ public readonly parent?: { state: State; cellId: string } /** * The application router, used to import modules from other notebook pages. */ public readonly router: Router public readonly modules: { [k: string]: { state: State; exports: Scope } } = {} /** * Pyodide execution should be namespaced by notebook page, * this variable holds the symbols. * * This is a python dictionary initialized with `pyodide.globals.get("dict")()` * when the notebook page is loaded and reused across python cells. */ private pyNamespace: { get: (key: string) => unknown } constructor(params: { initialScope?: Partial router: Router displayFactory?: DisplayFactory parent?: { state: State; cellId: string } }) { Object.assign(this, { router: params.router, parent: params.parent }) this.displayFactory = [ ...this.displayFactory, ...(params.displayFactory || []), ] this.initialScope = { let: params.initialScope?.let || {}, const: { webpm, Views, ...(params.initialScope?.const || {}), }, python: params.initialScope?.python || {}, } if (params.parent) { params.parent.state.invalidated$ .pipe(filter((cellId) => cellId === params.parent.cellId)) .subscribe(() => { if (this.ids.length === 0) { return } this.unreadyCells({ afterCellId: this.ids[0] }) Object.values(this.outputs$).forEach((output$) => output$.next(undefined), ) }) } } getPyNamespace(pyodide) { this.pyNamespace = this.pyNamespace || pyodide.globals.get('dict')() return this.pyNamespace } appendCell(cell: CellTrait) { this.ids.push(cell.cellId) this.cellIds$.next(this.ids) this.cells.push(cell) if (!this.outputs$[cell.cellId]) { this.outputs$[cell.cellId] = new ReplaySubject() this.executing$[cell.cellId] = new BehaviorSubject(false) this.src$[cell.cellId] = cell.content$ } this.cellsStatus$[cell.cellId] = new BehaviorSubject( this.ids.length === 1 ? 'ready' : 'unready', ) this.scopes$[cell.cellId] = Object.keys(this.scopes$).length == 0 ? new BehaviorSubject(this.initialScope) : new BehaviorSubject(undefined) cell.content$.subscribe((src) => { this.updateSrc({ cellId: cell.cellId, src }) }) } createJsCell(elem: HTMLElement): JsCellView { const cell = JsCellView.FromDom({ elem, state: this }) this.appendCell(cell) return cell } createPyCell(elem: HTMLElement): PyCellView { const cell = PyCellView.FromDom({ elem, state: this }) this.appendCell(cell) return cell } createMdCell( elem: HTMLElement, parserOptions: MdParsingOptions, ): MdCellView { const cell = MdCellView.FromDom({ elem, state: this, parserOptions }) this.appendCell(cell) return cell } createInterpreterCell(elem: HTMLElement): InterpreterCellView { const cell = InterpreterCellView.FromDom({ elem, state: this }) this.appendCell(cell) return cell } createWorkerCell(elem: HTMLElement): WorkerCellView { const cell = WorkerCellView.FromDom({ elem, state: this }) this.appendCell(cell) return cell } createDeportedOutputsView(elem: HTMLElement): OutputsView { const cellId = DeportedOutputsView.FromDomAttributes.cellId(elem) if (!this.outputs$[cellId]) { this.outputs$[cellId] = new ReplaySubject() this.executing$[cellId] = new BehaviorSubject(false) } this.deportedOutputsViews.push(cellId) return DeportedOutputsView.FromDom({ elem, output$: this.outputs$[cellId], }) } updateSrc({ cellId, src }: { cellId: string; src: string }) { if (!this.src$[cellId]) { this.src$[cellId] = new BehaviorSubject(src) } this.cellsStatus$[cellId].next('ready') this.unreadyCells({ afterCellId: cellId }) if (this.parent) { this.parent.state.unreadyCells({ afterCellId: this.parent.cellId }) } } async execute(id: string, rootExecution: boolean = true) { if (this.ids.length === 0) { return this.initialScope } const index = this.ids.indexOf(id) this.cellsStatus$[id].next('pending') if (!this.scopes$[id].value) { await this.execute(this.ids[index - 1], false) } const scope$ = this.scopes$[id] const output$ = this.outputs$[id] output$.next(undefined) this.cellsStatus$[id].next('executing') this.executing$[id].next(true) const scope = await this.cells[index].execute({ src: this.src$[id].value, scope: scope$.getValue(), output$, displayFactory: this.displayFactory, load: this.loadModule(id), cellId: id, owningState: this, }) this.cellsStatus$[id].next('success') const nextId = this.ids[index + 1] const remainingIds = this.ids.slice(index + 2) if (nextId) { this.scopes$[nextId].next(scope) this.cellsStatus$[nextId].next('ready') } nextId && this.scopes$[nextId].next(scope) remainingIds.forEach((id) => { rootExecution && this.cellsStatus$[id].next('unready') this.scopes$[id].next(undefined) this.executing$[id].next(false) }) return scope } private invalidateCells(cellId: string) { this.invalidated$.next(cellId) } private dispose() { this.cells.forEach((cell) => { this.invalidateCells(cell.cellId) }) Object.values(this.modules).forEach(({ state }) => { state.dispose() }) } unreadyCells({ afterCellId }: { afterCellId: string }) { const index = this.ids.indexOf(afterCellId) this.invalidateCells(afterCellId) const remainingIds = this.ids.slice(index + 1) remainingIds.forEach((id) => { this.cellsStatus$[id].next('unready') this.scopes$[id].next(undefined) this.executing$[id].next(false) this.invalidateCells(id) }) } private loadModule(cellId: string) { const components = ({ state, }: { state: State cellOptions: CellCommonAttributes }) => { return { 'js-cell': (elem) => { const id = elem.getAttribute('cell-id') || elem.getAttribute('id') const reactive = elem.getAttribute('reactive') const cell = new JsCellExecutor({ cellId: id, content$: new BehaviorSubject(elem.textContent), state: state, cellAttributes: { reactive, }, }) state.appendCell(cell) return { tag: 'div' as const } }, 'py-cell': (elem) => { const id = elem.getAttribute('cell-id') || elem.getAttribute('id') const cell = new PyCellExecutor({ cellId: id, content$: new BehaviorSubject(elem.textContent), state: state, cellAttributes: {}, }) state.appendCell(cell) return { tag: 'div' as const } }, } } return (path: string) => { const router = this.router if (this.modules[path]) { this.modules[path].state.dispose() } const module$ = router.getNav({ path }).pipe( switchMap((nav) => { const nbPage = nav.html({ router, }) as unknown as NotebookPage return fromFetch(nbPage.url) }), switchMap((resp) => resp.text()), switchMap((src) => { const state = new State({ router, parent: { state: this, cellId }, }) Dependencies.parseMd({ src: extractExportedCode(src), router, views: { ...components({ state, cellOptions: defaultCellAttributes, }), }, }) return from(state.execute(state.ids.slice(-1)[0])).pipe( map((scope) => { this.modules[path] = { exports: scope, state } return this.modules[path] }), ) }), ) return firstValueFrom(module$).then(({ exports }) => ({ ...exports.const, ...exports.let, ...exports.python, })) } } } function extractExportedCode(text: string) { let acc = '' let exported = false text.split('\n').forEach((line) => { if (line.startsWith('') || line.startsWith('')) { exported = false } }) return acc }