diff --git a/.gitignore b/.gitignore index c053bb1b..fa37b356 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tags wiki/docs/current wiki/public/css +wiki/public/dist diff --git a/README.md b/README.md index 16d338ca..e53e00b3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ logotype -Simple Wiki App built on the [Frappe Framework](https://frappeframework.com) +Simple Wiki App built on the [Frappe Framework](https://frappeframework.com). Powers [docs.erpnext.com](http://docs.erpnext.com/) + ## Installation @@ -16,25 +17,31 @@ $ bench --site sitename install-app wiki ## Features 1. Create Wiki Pages -2. Author content in markdown -3. Track page edits with revisions +2. Author content in Markdown or Rich Text +3. Set-up Controlled Wiki Updates +4. Unlimited Sidebar Hierarchy +5. Add attachments +6. Table of Contents +7. Caching +8. Custom Script Support via `Wiki Settings` ## Screenshots ### 1. Rendered Page -wiki-rendered +wiki-rendered -### 2. New Page -wiki-new +### 2. Edit Page +wiki-new -### 3. Edit Page -wiki-edit +### 3. Review Edited Page +wiki-new ### 4. Revisions -wiki-revisions +wiki-revisions ### 5. Compare changes -wiki-compare +wiki-compare + #### License diff --git a/wiki/hooks.py b/wiki/hooks.py index 40049ec4..cd83aebf 100644 --- a/wiki/hooks.py +++ b/wiki/hooks.py @@ -11,6 +11,14 @@ app_email = "developers@frappe.io" app_license = "MIT" +page_renderer = "wiki.wiki.doctype.wiki_page.wiki_renderer.WikiPageRenderer" + +website_route_rules = [ + {"from_route": "//edit", "to_route": "/edit"}, + {"from_route": "//new", "to_route": "/new"}, + {"from_route": "//revisions", "to_route": "/revisions"}, +] + # Includes in # ------------------ diff --git a/wiki/install.py b/wiki/install.py index d0dc180c..4f77b97b 100644 --- a/wiki/install.py +++ b/wiki/install.py @@ -9,31 +9,21 @@ def after_install(): # create the wiki homepage page = frappe.new_doc("Wiki Page") page.title = "Home" + page.route = "home" page.content = "Welcome to the homepage of your wiki!" page.published = True page.insert() # create the wiki sidebar - sidebar = frappe.new_doc("Website Sidebar") - sidebar.title = "Wiki Sidebar" + sidebar = frappe.new_doc("Wiki Sidebar") + sidebar.title = "Wiki" + sidebar.route = "wiki" sidebar.append( - "sidebar_items", {"title": "Home", "route": "/wiki/home", "group": "Pages"} - ) - sidebar.append( - "sidebar_items", - { - "title": "Edit Sidebar", - "route": "/desk#Form/Website Sidebar/Wiki Sidebar", - "group": "Manage Wiki", - }, - ) - sidebar.append( - "sidebar_items", - {"title": "Settings", "route": "/desk#Form/Wiki Settings", "group": "Manage Wiki"}, + "sidebar_items", {"item": page.name} ) sidebar.insert() # set the sidebar in settings settings = frappe.get_single("Wiki Settings") - settings.sidebar = "Wiki Sidebar" + settings.sidebar = sidebar.name settings.save() diff --git a/wiki/patches.txt b/wiki/patches.txt index e23470c5..8ef29f6b 100644 --- a/wiki/patches.txt +++ b/wiki/patches.txt @@ -1 +1,2 @@ wiki.wiki.doctype.wiki_page.patches.set_allow_guest +wiki.wiki.doctype.wiki_page.patches.delete_is_new diff --git a/wiki/public/build.json b/wiki/public/build.json index aad6a21c..ab471142 100644 --- a/wiki/public/build.json +++ b/wiki/public/build.json @@ -1,3 +1,4 @@ { - "wiki/css/wiki.css": ["public/scss/wiki.scss"] -} + "wiki/css/wiki.css": ["public/scss/wiki.scss"], + "wiki/js/wiki.min.js": ["www/editu.js"] +} \ No newline at end of file diff --git a/wiki/public/js/edit_asset.js b/wiki/public/js/edit_asset.js new file mode 100644 index 00000000..a55ad7bf --- /dev/null +++ b/wiki/public/js/edit_asset.js @@ -0,0 +1,468 @@ +window.EditAsset = class EditAsset { + constructor() { + this.make_code_field_group(); + this.add_attachment_popover(); + this.set_code_editor_height(); + this.render_preview(); + this.add_attachment_handler(); + this.set_listeners(); + this.create_comment_box(); + this.make_title_editable(); + } + + make_code_field_group() { + this.code_field_group = new frappe.ui.FieldGroup({ + fields: [ + { + fieldname: "type", + fieldtype: "Select", + default: "Markdown", + options: "Markdown\nRich-Text(Experimental)", + }, + { + fieldtype: "Column Break", + }, + { + fieldname: "attachment_controls", + fieldtype: "HTML", + options: this.get_attachment_controls_html(), + }, + { + fieldtype: "Section Break", + }, + { + fieldname: "code_html", + fieldtype: "Text Editor", + default: $(".wiki-content-html").html(), + depends_on: 'eval:doc.type=="Rich-Text(Experimental)"', + }, + { + fieldname: "code_md", + fieldtype: "Code", + options: "Markdown", + default: $(".wiki-content-md").text(), + depends_on: 'eval:doc.type=="Markdown"', + }, + ], + body: $(".wiki-write").get(0), + }); + this.code_field_group.make(); + $(".wiki-write .form-section:last").removeClass("empty-section"); + } + + get_attachment_controls_html() { + return ` +
+
+ + 0 attachments +
   +
+ +   + Upload Attachment + +
+
+`; + } + + add_attachment_popover() { + let picker_wrapper = $("
skjdfjs
"); + + $(".show-attachments").popover({ + trigger: "click", + placement: "bottom", + + content: () => { + return this.build_attachment_table(); + }, + html: true, + }); + } + + build_attachment_table() { + var wrapper = $('
'); + wrapper.empty(); + + var table = $(this.get_attachment_table_header_html()).appendTo(wrapper); + if (!this.attachments || !this.attachments.length) + return "No attachments uploaded"; + + this.attachments.forEach((f) => { + const row = $("").appendTo(table.find("tbody")); + $(`${f.file_name}`).appendTo(row); + // $(`${f.file_url}`).appendTo(row); + $(` + + Copy Link + + `).appendTo(row); + $(` + + + + + + `).appendTo(row); + }); + return wrapper; + } + + get_attachment_table_header_html() { + return ` + +
`; + } + + set_code_editor_height() { + setTimeout(() => { + // expand_code_editor + const code_md = this.code_field_group.get_field("code_md"); + code_md.expanded = !this.expanded; + code_md.refresh_height(); + code_md.toggle_label(); + }, 120); + } + + raise_patch() { + var side = {}; + + let name = $(".doc-sidebar .web-sidebar").get(0).dataset.name; + side[name] = []; + let items = $($(".doc-sidebar .web-sidebar").get(0)) + .children(".sidebar-items") + .children("ul") + .not(".hidden") + .children("li"); + items.each((item) => { + if (!items[item].dataset.name) return; + side[name].push({ + name: items[item].dataset.name, + type: items[item].dataset.type, + new: items[item].dataset.new, + title: items[item].dataset.title, + group_name: items[item].dataset.groupName, + }); + }); + + $('.doc-sidebar [data-type="Wiki Sidebar"]').each(function () { + let name = $(this).get(0).dataset.groupName; + side[name] = []; + let items = $(this).children("ul").children("li"); + items.each((item) => { + if (!items[item].dataset.name) return; + side[name].push({ + name: items[item].dataset.name, + type: items[item].dataset.type, + new: items[item].dataset.new, + title: items[item].dataset.title, + group_name: items[item].dataset.groupName, + }); + }); + }); + + var me = this; + var dfs = []; + const title_of_page = $('[name="title_of_page"]').val(); + dfs.push( + { + fieldname: "edit_message", + fieldtype: "Text", + label: "Message", + default: $('[name="new"]').val() + ? `Add new page: ${title_of_page}` + : `Edited ${title_of_page}`, + mandatory: 1, + }, + { + fieldname: "sidebar_edited", + fieldtype: "Check", + label: "I Updated the sidebar", + default: $('[name="new"]').val() ? 1 : 0, + } + ); + + let dialog = new frappe.ui.Dialog({ + fields: dfs, + title: __("Please describe your changes"), + primary_action: function () { + frappe.call({ + method: "wiki.wiki.doctype.wiki_page.wiki_page.update", + args: { + name: $('[name="wiki_page"]').val(), + wiki_page_patch: $('[name="wiki_page_patch"]').val(), + message: this.get_value("edit_message"), + sidebar_edited: this.get_value("sidebar_edited"), + content: me.content, + type: me.code_field_group.get_value("type"), + attachments: me.attachments, + new: $('[name="new"]').val(), + title: $('[name="title_of_page"]').val(), + new_sidebar: $(".doc-sidebar").get(0).innerHTML, + new_sidebar_items: side, + }, + callback: () => { + frappe.msgprint({ + message: + "A Change Request has been created. You can track your requests on the contributions page", + indicator: "green", + title: "Change Request Created", + }); + window.location.href = "/contributions"; + }, + freeze: true, + }); + dialog.hide(); + $("#freeze").addClass("show"); + }, + }); + dialog.show(); + } + + render_preview() { + $('a[data-toggle="tab"]').on("click", (e) => { + let activeTab = $(e.target); + + if ( + activeTab.prop("id") === "preview-tab" || + activeTab.prop("id") === "diff-tab" + ) { + let $preview = $(".wiki-preview"); + let $diff = $(".wiki-diff"); + const type = this.code_field_group.get_value("type"); + let content = ""; + if (type == "Markdown") { + content = this.code_field_group.get_value("code_md"); + } else { + content = this.code_field_group.get_value("code_html"); + var turndownService = new TurndownService(); + turndownService = turndownService.keep(["div class", "iframe"]); + content = turndownService.turndown(content); + } + if (!content) { + this.set_empty_message($preview, $diff); + return; + } + this.set_loading_message($preview, $diff); + + frappe.call({ + method: "wiki.wiki.doctype.wiki_page.wiki_page.preview", + args: { + content: content, + type: type, + path: this.route, + name: $('[name="wiki_page"]').val(), + attachments: this.attachments, + new: $('[name="new"]').val(), + }, + callback: (r) => { + if (r.message) { + $preview.html(r.message.html); + if (!$('[name="new"]').val()) { + $diff.html(r.message.diff); + } + } + }, + }); + } + }); + } + + set_empty_message($preview, $diff) { + $preview.html("
Please add some code
"); + $diff.html("
Please add some code
"); + } + + set_loading_message($preview, $diff) { + $preview.html("Loading preview..."); + $diff.html("Loading diff..."); + } + + add_attachment_handler() { + var me = this; + $(".add-attachment-wiki").click(function () { + me.new_attachment(); + }); + $(".submit-wiki-page").click(function () { + me.get_markdown(); + }); + } + + new_attachment() { + if (this.dialog) { + // remove upload dialog + this.dialog.$wrapper.remove(); + } + + new frappe.ui.FileUploader({ + folder: "Home/Attachments", + on_success: (file_doc) => { + if (!this.attachments) this.attachments = []; + if (!this.save_paths) this.save_paths = {}; + this.attachments.push(file_doc); + $(".wiki-attachment").empty().append(this.build_attachment_table()); + $(".attachment-controls").find(".number").text(this.attachments.length); + }, + }); + } + + get_markdown() { + var me = this; + + if (me.code_field_group.get_value("type") == "Markdown") { + this.content = me.code_field_group.get_value("code_md"); + this.raise_patch(); + } else { + this.content = this.code_field_group.get_value("code_html"); + + frappe.call({ + method: + "wiki.wiki.doctype.wiki_page.wiki_page.extract_images_from_html", + args: { + content: this.content, + }, + callback: (r) => { + if (r.message) { + me.content = r.message; + var turndownService = new TurndownService(); + turndownService = turndownService.keep(["div class", "iframe"]); + me.content = turndownService.turndown(me.content); + me.raise_patch(); + } + }, + }); + } + } + + set_listeners() { + var me = this; + + $(`body`).on("click", `.copy-link`, function () { + frappe.utils.copy_to_clipboard($(this).attr("data-link")); + }); + + $(`body`).on("click", `.delete-button`, function () { + frappe.confirm( + `Are you sure you want to delete the file "${$(this).attr( + "data-name" + )}"`, + () => { + me.attachments.forEach((f, index, object) => { + if (f.file_name == $(this).attr("data-name")) { + object.splice(index, 1); + } + }); + $(".wiki-attachment").empty().append(me.build_attachment_table()); + $(".attachment-controls").find(".number").text(me.attachments.length); + } + ); + }); + } + + create_comment_box() { + this.comment_box = frappe.ui.form.make_control({ + parent: $(".comment-box"), + df: { + fieldname: "new_comment", + fieldtype: "Comment", + }, + enable_mentions: false, + render_input: true, + only_input: true, + on_submit: (comment) => { + this.add_comment_to_patch(comment); + }, + }); + } + + add_comment_to_patch(comment) { + if (strip_html(comment).trim() != "") { + this.comment_box.disable(); + + frappe.call({ + method: + "wiki.wiki.doctype.wiki_page_patch.wiki_page_patch.add_comment_to_patch", + args: { + reference_name: $('[name="wiki_page_patch"]').val(), + content: comment, + comment_email: frappe.session.user, + comment_by: frappe.session.user_fullname, + }, + callback: (r) => { + comment = r.message; + + this.display_new_comment(comment, this.comment_box); + }, + always: () => { + this.comment_box.enable(); + }, + }); + } + } + + display_new_comment(comment, comment_box) { + if (comment) { + comment_box.set_value(""); + + const new_comment = this.get_comment_html( + comment.owner, + comment.creation, + comment.timepassed, + comment.content + ); + + $(".timeline-items").prepend(new_comment); + } + } + + get_comment_html(owner, creation, timepassed, content) { + return $(` +
+
+ + + +
+
+
+ + + + ${owner} + + ${timepassed} + + + + +
+ ${content} +
+
+
+
+ `); + } + + make_title_editable() { + const title_span = $(".edit-title>span"); + const title_handle = $(".edit-title>i"); + const title_input = $(".edit-title>input"); + title_handle.click(() => { + title_span.addClass("hide"); + title_handle.addClass("hide"); + title_input.removeClass("hide"); + title_input.val(title_span.text()); + title_input.focus(); + }); + title_input.focusout(() => { + title_span.removeClass("hide"); + title_handle.removeClass("hide"); + title_input.addClass("hide"); + title_span.text(title_input.val()); + }); + } +}; diff --git a/wiki/public/js/edit_wiki.js b/wiki/public/js/edit_wiki.js new file mode 100644 index 00000000..a469a439 --- /dev/null +++ b/wiki/public/js/edit_wiki.js @@ -0,0 +1,230 @@ +window.EditWiki = class EditWiki extends Wiki { + constructor() { + super(); + frappe.provide("frappe.ui.keys"); + $("document").ready(() => { + frappe + .call("wiki.wiki.doctype.wiki_page.wiki_page.get_sidebar_for_page", { + wiki_page: $('[name="wiki_page"]').val(), + }) + .then((result) => { + $(".doc-sidebar").empty().append(result.message); + this.activate_sidebars(); + this.set_active_sidebar(); + this.set_empty_ul(); + this.set_sortable(); + this.set_add_item(); + if ($('[name="new"]').first().val()) { + this.add_new_link(); + } + }); + }); + } + + activate_sidebars() { + $(".sidebar-item").each(function (index) { + const active_class = "active"; + let page_href = window.location.pathname; + if (page_href.indexOf("#") !== -1) { + page_href = page_href.slice(0, page_href.indexOf("#")); + } + if (page_href.includes($(this).data("route"))) { + $(this).addClass(active_class); + $(this).find("a").addClass(active_class); + } + }); + // scroll the active sidebar item into view + let active_sidebar_item = $(".sidebar-item.active"); + if (active_sidebar_item.length > 0) { + active_sidebar_item.get(0).scrollIntoView(true, { + behavior: "smooth", + block: "nearest", + }); + } + } + + set_empty_ul() { + $(".collapsible").each(function () { + if ($(this).parent().find("ul").length == 0) { + $(this) + .parent() + .append( + $(`