diff --git a/html_toc_export/handlers.py b/html_toc_export/handlers.py index 4a7e309..207ab65 100644 --- a/html_toc_export/handlers.py +++ b/html_toc_export/handlers.py @@ -5,20 +5,22 @@ import tornado class RouteHandler(APIHandler): - # The following decorator should be present on all verb methods (head, get, post, + # The following ecorator should be present on all verb methods (head, get, post, # patch, put, delete, options) to ensure only authorized user can request the # Jupyter server @tornado.web.authenticated def get(self): - self.finish(json.dumps({ - "data": "This is /html-toc-export/get-example endpoint!" - })) + with open(r"./style/toc.css", "r") as f: + content = f.read() + self.set_header("Content-Type", "text/css") + self.write(content) + def setup_handlers(web_app): host_pattern = ".*$" base_url = web_app.settings["base_url"] - route_pattern = url_path_join(base_url, "html-toc-export", "get-example") + route_pattern = url_path_join(base_url, "html-toc-export", "toc-css") handlers = [(route_pattern, RouteHandler)] web_app.add_handlers(host_pattern, handlers) diff --git a/package.json b/package.json index 1492264..b876bce 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", - "src/**/*.{ts,tsx}" + "src/**/*.{ts,tsx}", + "schema/*.json" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -58,7 +59,8 @@ "dependencies": { "@jupyterlab/application": "^4.0.0", "@jupyterlab/coreutils": "^6.0.0", - "@jupyterlab/services": "^7.0.0" + "@jupyterlab/services": "^7.0.0", + "toc": "^0.4.0" }, "devDependencies": { "@jupyterlab/builder": "^4.0.0", @@ -98,17 +100,18 @@ }, "jupyterlab": { "discovery": { - "server": { - "managers": [ - "pip" - ], - "base": { - "name": "html_toc_export" + "server": { + "managers": [ + "pip" + ], + "base": { + "name": "html_toc_export" + } } - } }, "extension": true, - "outputDir": "html_toc_export/labextension" + "outputDir": "html_toc_export/labextension", + "schemaDir": "schema" }, "eslintIgnore": [ "node_modules", diff --git a/src/index.ts b/src/index.ts index 4ed0047..d588d07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,28 +3,187 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { requestAPI } from './handler'; +import { + INotebookTracker, NotebookPanel +} from '@jupyterlab/notebook'; + +import { + ITableOfContentsTracker, + TableOfContents +} from '@jupyterlab/toc'; + +import { ICommandPalette } from '@jupyterlab/apputils'; + +import { PageConfig, URLExt } from '@jupyterlab/coreutils'; + +import { ServerConnection } from '@jupyterlab/services'; + + + /** - * Initialization data for the html-toc-export extension. + * Initialization data for the myextension extension. */ const plugin: JupyterFrontEndPlugin = { id: 'html-toc-export:plugin', - description: 'Export HTML with table of contents', + description: 'A JupyterLab extension.', autoStart: true, - activate: (app: JupyterFrontEnd) => { - console.log('JupyterLab extension html-toc-export is activated!'); - - requestAPI('get-example') - .then(data => { - console.log(data); - }) - .catch(reason => { - console.error( - `The html_toc_export server extension appears to be missing.\n${reason}` - ); - }); + requires: [ICommandPalette, INotebookTracker, ITableOfContentsTracker], + activate: ( + app: JupyterFrontEnd, + palette: ICommandPalette, + tracker: INotebookTracker, + toc: ITableOfContentsTracker, + ) => { + console.log('JupyterLab extension myextension is activated!'); + console.log('ICommandPalette:', palette); + + + const command: string = 'toc:export'; + app.commands.addCommand( command, { + label: 'Export HTML w/ ToC', + execute: () => { + let current = getCurrent(tracker, app.shell); + getHTML(current.context.path) + .then((response) => { + let toc_model = toc.get(current)!; + let domParser = new DOMParser(); + let doc = domParser.parseFromString(response, "text/html"); + let toc_html = doc.createElement("div"); + toc_html.innerHTML = generateToCHTML(toc_model); + doc.body.prepend(toc_html); + getCSS() + .then((response) => { + let css_node = doc.createElement("style"); + css_node.innerHTML = response; + doc.head.appendChild(css_node); + downloadHtmlDocument(doc, "test.html"); + }) + .catch((error) => { + console.error('Error:', error); + }); + }); + } + }); + palette.addItem({ command, category: 'Tutorial'}); } }; +function generateToCHTML(model: TableOfContents.IModel): string { + // Recursive function to process headings and generate HTML + function processHeadings(headings: TableOfContents.IHeading[], level: number): string { + let html = ''; + let index = 0; + + while (index < headings.length) { + const heading = headings[index]; + if (heading.text.length < 1) { + index++; + continue; + } + + if (heading.level === level) { + html += `
  • ${heading.text}`; + + // Find and process subheadings + const subHeadingsStart = index + 1; + let subHeadingsEnd = headings.findIndex((h, i) => i >= subHeadingsStart && h.level <= level); + subHeadingsEnd = subHeadingsEnd === -1 ? headings.length : subHeadingsEnd; + const subHeadings = headings.slice(subHeadingsStart, subHeadingsEnd); + + if (subHeadings.length > 0) { + html += `
      ${processHeadings(subHeadings, level + 1)}
    `; + } + + html += `
  • `; + index = subHeadingsEnd; + } else { + index++; + } + } + + return html; + } + + return `
    +
    + +
    +
    `; +} + + +function downloadHtmlDocument(htmlDocument: Document, filename: string) { + // Serialize the HTMLDocument to a string + const serializer = new XMLSerializer(); + const htmlString = serializer.serializeToString(htmlDocument); + + // Create a Blob from the HTML string + const blob = new Blob([htmlString], { type: 'text/html' }); + + // Create a URL for the Blob + const url = URL.createObjectURL(blob); + + // Create a temporary link element and trigger the download + const link = document.createElement('a'); + link.href = url; + link.download = filename || 'document.html'; + document.body.appendChild(link); + link.click(); + + // Clean up by removing the link element and revoking the Blob URL + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function getCurrent( + tracker: INotebookTracker, + shell: JupyterFrontEnd.IShell, +): NotebookPanel { + const widget = tracker.currentWidget; + + if (widget) { + shell.activateById(widget.id); + } + + return widget!; +} + +async function getHTML(path: string): Promise{ + const settings = ServerConnection.makeSettings(); + let response: Response; + let url = PageConfig.getNBConvertURL({ + format: 'HTML', + download: false, + path, + }); + try { + response = await ServerConnection.makeRequest(url, {}, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as any); + } + let data: any = await response.text(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + return data +} + +async function getCSS(): Promise{ + const settings = ServerConnection.makeSettings(); + let response: Response; + let baseUrl = PageConfig.getBaseUrl(); + let url = URLExt.join(baseUrl, "html-toc-export", "toc-css"); + try { + response = await ServerConnection.makeRequest(url, {}, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as any); + } + let data: any = await response.text(); + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + return data +} + export default plugin; diff --git a/style/toc.css b/style/toc.css new file mode 100644 index 0000000..a92cd71 --- /dev/null +++ b/style/toc.css @@ -0,0 +1,184 @@ +/* + +originally extracted from https://gist.github.com/magican/5574556 + +Most colors defined here are overridden by javascript which adds css based on +values in the server config file notebook.json, which can be edited directly, +or colors can be selected in the nbextensions_configurator +*/ + + + +/*background color for links when you mouse over it */ +#toc-wrapper li > span:hover { + background-color: #DAA520; + } + + #toc a { + color: #333333; /* default - alterable via nbextension-configurator */ + text-decoration: none; + } + #navigate_menu li > span:hover {background-color: #f1f1f1} + + + /* Move menus and tooolbar to the left, following @Kevin-McIsaac suggestion + This is now done in javascript, if the relevant option is selected + div#menubar-container, div#header-container { + width: auto; + padding-left: 20px; + }*/ + + #navigate_menu { + list-style-type: none; + max-width: 800px; + min-width: 100px; + width: 250px; + overflow: auto; + } + + + #navigate_menu a { + list-style-type: none; + color: #333333; /* default - alterable via nbextension-configurator */ + text-decoration: none; + } + + #navigate_menu li { + padding-left: 0px; + clear: both; + list-style-type: none; + } + + #navigate_menu > .toc-item, + #navigate_menu ul { + padding-left: 0px; + } + + .toc { + padding: 0px; + overflow-y: auto; + font-weight: normal; + color: #333333; /* default - alterable via nbextension-configurator */ + white-space: nowrap; + overflow-x: auto; + } + + .text_cell .toc { + margin-top: 1em; + } + + .toc ul.toc-item { + list-style-type: none; + padding: 0; + margin: 0; + } + + #toc-wrapper { + z-index: 90; + /* position: fixed !important; */ + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 10px; + border-style: solid; + border-width: thin; + background-color: #fff; /* default - alterable via nbextension-configurator */ + } + + #toc-wrapper .toc { + flex-grow: 1; + } + + .float-wrapper { + border-color: rgba(0, 0, 0, 0.38); + border-radius: 5px; + opacity: .8; + } + + .sidebar-wrapper { + top: 10px; + bottom: 0; + width: 212px; + border-color: #eeeeee; /* default - alterable via nbextension-configurator */ + } + + .sidebar-wrapper .ui-resizable-se { + display: none; + } + + .sidebar-wrapper .ui-resizable-e { + position: absolute; + top: calc(50% - 8px); + } + + #toc-wrapper.closed { + min-width: 100px; + width: auto; + transition: width; + } + #toc-wrapper:hover{ + opacity: 1; + } + #toc-wrapper .header { + font-size: 18px; + font-weight: bold; + } + + .sidebar-wrapper .hide-btn { + display:none; + } + + #toc-wrapper .hide-btn:before { + content: "\f147"; + } + + #toc-wrapper.closed .hide-btn:before { + content: "\f196"; + } + + #toc-header .fa { + font-size: 14px; + text-decoration: none; + } + + /* on scroll style */ + .highlight_on_scroll { + border-left: solid 4px blue; + } + + .toc-item li { margin:0; padding:0; color:black } + .toc-item li > span { display:block } + .toc-item li > span { padding-left:0em } + .toc-item li li > span { padding-left:1em } + .toc-item li li li > span { padding-left:2em } + .toc-item li li li li > span { padding-left:3em } + .toc-item li li li li li > span { padding-left:4em } + .toc-item li li li li li li > span { padding-left:5em } + + + #toc-wrapper .toc-item-num { + font-family: Georgia, Times New Roman, Times, serif; + color: black; /* default - alterable via nbextension-configurator */ + } + + /* + These colors are now specified in js, after reading the extension's config stored in system + and updated using the nbextension-configurator + .toc-item-highlight-select {background-color: Gold} + .toc-item-highlight-execute {background-color: red} + .toc-item-highlight-execute.toc-item-highlight-select {background-color: Gold} */ + + #toc-header .fa , + .toc-item .fa-fw:first-child { + cursor: pointer; + } + + #toc-header, + .modal-header { + cursor: move; + } + + .tocSkip { + display: none; + } \ No newline at end of file