This commit is contained in:
parent
c4bd5107eb
commit
5cc4e65638
15
backref.go
15
backref.go
|
@ -16,13 +16,6 @@ type Reference struct {
|
|||
Name string
|
||||
}
|
||||
|
||||
// ListItem is a simplification of the information that was saved by the editor
|
||||
type ListItem struct {
|
||||
ID string
|
||||
Indented int
|
||||
Text string
|
||||
}
|
||||
|
||||
type Refs map[string][]Reference
|
||||
|
||||
func processBackrefs(dirname string, page Page) error {
|
||||
|
@ -131,11 +124,11 @@ func getBackrefs(fp *FilePages, p string) (map[string][]Backref, error) {
|
|||
}
|
||||
|
||||
result[ref.Name] = append(result[ref.Name], Backref{
|
||||
Name: ref.Name,
|
||||
Title: title,
|
||||
LineHTML: template.HTML(pageText),
|
||||
Name: ref.Name,
|
||||
Title: title,
|
||||
LineHTML: template.HTML(pageText),
|
||||
LineEditHTML: template.HTML(editPageText),
|
||||
Line: strings.Map(removeBrackets, ref.Link.Line),
|
||||
Line: strings.Map(removeBrackets, ref.Link.Line),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
434
editor/src/editor.js
Normal file
434
editor/src/editor.js
Normal file
|
@ -0,0 +1,434 @@
|
|||
import listEditor from "wiki-list-editor";
|
||||
import menu from "./menu";
|
||||
import createPageSearch from "./fuse";
|
||||
import util from "./util";
|
||||
import search from "./search";
|
||||
import axios from 'axios';
|
||||
import qs from 'querystring'
|
||||
import $ from 'jquery';
|
||||
import Mustache from 'mustache';
|
||||
import getCaretCoordinates from './caret-position'
|
||||
import moment from 'moment'
|
||||
import mermaid from 'mermaid'
|
||||
import {Network, parseDOTNetwork} from "vis-network/peer";
|
||||
import PrismJS from 'prismjs'
|
||||
import 'prismjs/plugins/filter-highlight-all/prism-filter-highlight-all'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-go'
|
||||
import 'prismjs/components/prism-perl'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-jq'
|
||||
import MD from './markdown'
|
||||
import he from 'he'
|
||||
|
||||
function isMultiline(input) {
|
||||
return input.value.startsWith("```", 0)
|
||||
|| input.value.startsWith("$$", 0)
|
||||
}
|
||||
|
||||
function addSaver(editor, saveUrl, page, beforeSave) {
|
||||
return {
|
||||
save() {
|
||||
return editor.save().then(outputData => {
|
||||
beforeSave()
|
||||
let data = {
|
||||
'json': 1,
|
||||
'p': page,
|
||||
'summary': "",
|
||||
'content': JSON.stringify(outputData),
|
||||
};
|
||||
return axios.post(saveUrl, qs.encode(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Indicator(element, timeout) {
|
||||
let timeoutId;
|
||||
|
||||
return {
|
||||
done() {
|
||||
timeoutId = setTimeout(() => {
|
||||
element.classList.add('hide')
|
||||
}, timeout * 1000);
|
||||
},
|
||||
|
||||
setText(text) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
element.innerText = text;
|
||||
element.classList.remove('hide')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function addIndicator(editor, indicator) {
|
||||
return {
|
||||
save() {
|
||||
editor.save().then(() => {
|
||||
indicator.setText('saved!');
|
||||
indicator.done();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showSearchResults(searchTool, query, input, value, resultType) {
|
||||
return showSearchResultsExtended('#link-complete', 'link-template', searchTool, query, input, value, resultType, {
|
||||
showOnlyResults: true,
|
||||
belowCursor: true
|
||||
})
|
||||
}
|
||||
|
||||
function showSearchResultsExtended(element, template, searchTool, query, input, value, resultType, options) {
|
||||
const $lc = $(element)
|
||||
return searchTool(query).then(results => {
|
||||
let opt = options || {};
|
||||
if (opt.showOnlyResults && (query.length === 0 || !results.length)) {
|
||||
$lc.fadeOut()
|
||||
return
|
||||
}
|
||||
$lc.data('result-type', resultType)
|
||||
|
||||
if (opt.belowCursor) {
|
||||
let pos = getCaretCoordinates(input, value.selectionEnd, {})
|
||||
let off = $(input).offset()
|
||||
pos.top += off.top + pos.height
|
||||
pos.left += off.left
|
||||
$lc.offset(pos)
|
||||
}
|
||||
|
||||
var templateText = he.decode(document.getElementById(template).innerHTML);
|
||||
var rendered = Mustache.render(templateText, {
|
||||
page: value.trim().replace(/\s+/g, '_'),
|
||||
results: results
|
||||
}, {}, ['[[', ']]']);
|
||||
let selected = $lc.find('li.selected');
|
||||
if (selected) {
|
||||
let selectedPos = $lc.find('li').index(selected[0])
|
||||
rendered = $(rendered)
|
||||
const $lis = $lc.find('li')
|
||||
if ($lis.length >= 1) {
|
||||
selectedPos = Math.min(selectedPos, $lis.length - 1)
|
||||
rendered.find('li')[selectedPos].classList.add('selected')
|
||||
}
|
||||
}
|
||||
$lc.html(rendered).fadeIn()
|
||||
return results
|
||||
})
|
||||
}
|
||||
|
||||
function renderGraphs() {
|
||||
$('code.language-dot').each(function (i, code) {
|
||||
if (!code.innerText) {
|
||||
return true
|
||||
}
|
||||
|
||||
let data = parseDOTNetwork(code.innerText)
|
||||
let network = new Network(code, data, {
|
||||
layout: {
|
||||
randomSeed: 1239043
|
||||
}
|
||||
});
|
||||
$(code).on('click', function () {
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function Editor(holder, input) {
|
||||
let scope = {}
|
||||
|
||||
const options = {
|
||||
transform(text, element) {
|
||||
if (text === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let converted = text
|
||||
|
||||
if (converted.startsWith("```", 0) || converted.startsWith("$$", 0)) {
|
||||
converted = MD.render(converted)
|
||||
// } else if (converted.startsWith("=", 0)) {
|
||||
// try {
|
||||
// converted = math.evaluate(converted.substring(1), scope).toString()
|
||||
// } catch (e) {
|
||||
// converted = converted + ' <span style="background: red; color: white;">' + e.message + '</span>';
|
||||
// }
|
||||
} else {
|
||||
if (text.match(/^(\w+):: (.+)$/)) {
|
||||
converted = converted.replace(/^(\w+):: (.*)$/, '**[[$1]]**: $2')
|
||||
} else if (text.match(/#\[\[TODO]]/)) {
|
||||
converted = converted.replace('#[[TODO]]', '<input class="checkbox" type="checkbox" />')
|
||||
} else if (text.match(/#\[\[DONE]]/)) {
|
||||
converted = converted.replace('#[[DONE]]', '<input class="checkbox" type="checkbox" checked />')
|
||||
}
|
||||
MD.options.html = true
|
||||
converted = MD.renderInline(converted)
|
||||
MD.options.html = false
|
||||
}
|
||||
|
||||
element.html(converted)
|
||||
}
|
||||
}
|
||||
|
||||
let inputData = input ? input : JSON.parse(holder.dataset.input)
|
||||
|
||||
let editor = listEditor(holder, inputData, options);
|
||||
holder.$listEditor = editor
|
||||
|
||||
$(holder).find('.content input[type="checkbox"]').on('click', function (event) {
|
||||
let that = this
|
||||
let id = $(this).closest('.list-item').data('id')
|
||||
editor.update(id, function (item, prev, next) {
|
||||
if (that.checked) {
|
||||
item.text = item.text.replace('#[[TODO]]', '#[[DONE]]')
|
||||
} else {
|
||||
item.text = item.text.replace('#[[DONE]]', '#[[TODO]]')
|
||||
}
|
||||
return item
|
||||
});
|
||||
event.stopPropagation()
|
||||
return false
|
||||
})
|
||||
|
||||
editor.on('change', function () {
|
||||
let element = holder
|
||||
let indicator = Indicator(document.getElementById('save-indicator'), 2);
|
||||
let saveUrl = element.dataset.saveurl;
|
||||
let page = element.dataset.page;
|
||||
|
||||
indicator.setText('has changes...');
|
||||
addIndicator(
|
||||
addSaver(editor, saveUrl, page, () => indicator.setText('saving...')),
|
||||
indicator
|
||||
).save()
|
||||
})
|
||||
|
||||
editor.on('rendered', function () {
|
||||
PrismJS.highlightAll()
|
||||
mermaid.init()
|
||||
renderGraphs();
|
||||
})
|
||||
|
||||
menu.connectContextMenu(editor)
|
||||
|
||||
return createPageSearch().then(function ({titleSearch, commandSearch, commands}) {
|
||||
editor.on('start-editing', function (input) {
|
||||
const $lc = $('#link-complete');
|
||||
|
||||
$(input).parents('.list-item').addClass('active');
|
||||
|
||||
$lc.on('popup:selected', function (event, linkName, resultType, element) {
|
||||
let value = input.value
|
||||
let end = input.selectionEnd
|
||||
if (resultType === 'link') {
|
||||
let start = value.lastIndexOf("[[", end)
|
||||
end += 2
|
||||
let startAndLink = value.substring(0, start) + "[[" + linkName + "]]"
|
||||
input.value = startAndLink + value.substring(end)
|
||||
input.selectionStart = startAndLink.length
|
||||
input.selectionEnd = startAndLink.length
|
||||
input.focus()
|
||||
} else if (resultType === 'command') {
|
||||
let start = value.lastIndexOf("/", end)
|
||||
let commandResult = ""
|
||||
let replace = ""
|
||||
let prefix = false
|
||||
let adjustment = 0
|
||||
|
||||
let now = moment()
|
||||
|
||||
if (linkName === "Current Time") {
|
||||
commandResult = now.format('HH:mm')
|
||||
} else if (linkName === "Today") {
|
||||
commandResult = "[[" + now.format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "Tomorrow") {
|
||||
commandResult = "[[" + now.add(1, 'day').format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "Yesterday") {
|
||||
commandResult = "[[" + now.add(-1, 'day').format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "TODO") {
|
||||
commandResult = "#[[TODO]] "
|
||||
replace = "#[[DONE]] "
|
||||
prefix = true
|
||||
} else if (linkName === "DONE") {
|
||||
commandResult = "#[[DONE]] "
|
||||
replace = "#[[TODO]] "
|
||||
prefix = true
|
||||
} else if (linkName === "Page Reference") {
|
||||
commandResult = "[[]]"
|
||||
adjustment = -2
|
||||
} else if (linkName === "Code Block") {
|
||||
commandResult = "```\n\n```"
|
||||
adjustment = -5
|
||||
}
|
||||
|
||||
let startAndLink = prefix
|
||||
? commandResult + value.substring(0, start).replace(replace, "")
|
||||
: value.substring(0, start) + commandResult
|
||||
|
||||
input.value = startAndLink + value.substring(end)
|
||||
|
||||
input.selectionStart = startAndLink.length + adjustment
|
||||
input.selectionEnd = startAndLink.length + adjustment
|
||||
|
||||
input.focus()
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
$lc.on('popup:leave', function (event) {
|
||||
input.focus()
|
||||
})
|
||||
|
||||
$(input).on('keydown', function (event) {
|
||||
const isVisible = $('#link-complete:visible').length > 0;
|
||||
|
||||
if (event.key === 'Escape' && isVisible) {
|
||||
$lc.fadeOut()
|
||||
return false
|
||||
} else if (event.key === 'Enter' && isVisible) {
|
||||
const element = $lc.find('li.selected')
|
||||
const linkName = element.text()
|
||||
$lc.trigger('popup:selected', [linkName, $lc.data('result-type'), element])
|
||||
$lc.fadeOut()
|
||||
return false
|
||||
} else if (event.key === 'ArrowUp' && isVisible) {
|
||||
const selected = $lc.find('li.selected')
|
||||
const prev = selected.prev('li')
|
||||
if (prev.length) {
|
||||
prev.addClass('selected')
|
||||
selected.removeClass('selected')
|
||||
prev[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
||||
} else {
|
||||
// move back from dropdown to input
|
||||
$lc.trigger('popup:leave')
|
||||
selected.removeClass('selected')
|
||||
}
|
||||
return false
|
||||
} else if (event.key === 'ArrowDown' && isVisible) {
|
||||
const selected = $lc.find('li.selected')
|
||||
if (!selected.length) {
|
||||
$lc.find('li:not(.selected):first-child').addClass('selected')
|
||||
return false
|
||||
}
|
||||
|
||||
const next = selected.next('li');
|
||||
if (next.length) {
|
||||
next.addClass('selected')
|
||||
selected.removeClass('selected')
|
||||
next[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let mirror = {
|
||||
'[': ']',
|
||||
'(': ')',
|
||||
'{': '}',
|
||||
}
|
||||
|
||||
if (!isMultiline(input) && mirror.hasOwnProperty(event.key)) {
|
||||
let input = this
|
||||
let val = input.value
|
||||
let prefix = val.substring(0, input.selectionStart)
|
||||
let selection = val.substring(input.selectionStart, input.selectionEnd)
|
||||
let suffix = val.substring(input.selectionEnd)
|
||||
input.value = prefix + event.key + selection + mirror[event.key] + suffix
|
||||
input.selectionStart = prefix.length + event.key.length
|
||||
input.selectionEnd = input.selectionStart + selection.length
|
||||
$(input).trigger('input')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
let searchEnabled = false
|
||||
|
||||
$(input).on('keyup', function (event) {
|
||||
if (event.key === '/') {
|
||||
searchEnabled = true
|
||||
}
|
||||
if (searchEnabled && event.key === 'Escape') {
|
||||
searchEnabled = false
|
||||
return false;
|
||||
}
|
||||
const ignoreKeys = {
|
||||
'ArrowUp': true,
|
||||
'ArrowDown': true,
|
||||
'Enter': true,
|
||||
}
|
||||
|
||||
if (event.key in ignoreKeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
let value = input.value
|
||||
let end = input.selectionEnd
|
||||
|
||||
let [start, insideLink] = util.cursorInsideLink(value, end)
|
||||
let insideSearch = false
|
||||
|
||||
if (searchEnabled && !insideLink) {
|
||||
start = value.lastIndexOf("/", end)
|
||||
insideSearch = start >= 0
|
||||
}
|
||||
|
||||
if (insideSearch) {
|
||||
let query = value.substring(start + 1, end);
|
||||
showSearchResults(commandSearch, query, input, value, 'command').then(results => {
|
||||
if (query.length > 0 && result.length === 0) {
|
||||
searchEnabled = false
|
||||
}
|
||||
})
|
||||
return true
|
||||
} else if (insideLink) {
|
||||
let query = value.substring(start + 2, end);
|
||||
showSearchResults(titleSearch, query, input, value, 'link');
|
||||
return true
|
||||
} else {
|
||||
$('#link-complete').fadeOut();
|
||||
}
|
||||
})
|
||||
})
|
||||
editor.on('stop-editing', function (input) {
|
||||
$(input).parents('.list-item').removeClass('active');
|
||||
$('#link-complete').off()
|
||||
PrismJS.highlightAll()
|
||||
mermaid.init()
|
||||
renderGraphs();
|
||||
})
|
||||
return editor
|
||||
})
|
||||
}
|
||||
|
||||
let timeout = null;
|
||||
let searchInput = document.getElementById('search-input');
|
||||
search(searchInput).then(searcher => {
|
||||
let showSearch = _.debounce(function (searcher) {
|
||||
let query = $(searchInput).val()
|
||||
if (query === '') {
|
||||
let autocomplete = document.getElementById('autocomplete');
|
||||
$(autocomplete).hide()
|
||||
return false
|
||||
}
|
||||
|
||||
showSearchResultsExtended('#autocomplete', 'result-template', query => searcher.search(query), query, searchInput, query, 'search-result')
|
||||
return true;
|
||||
}, 200)
|
||||
|
||||
$(searchInput).on('keyup', function (event) {
|
||||
showSearch.cancel()
|
||||
showSearch(searcher)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
export default Editor;
|
|
@ -1,19 +1,6 @@
|
|||
import listEditor from 'wiki-list-editor';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import MarkdownItWikilinks from './wikilinks';
|
||||
import MarkdownItMark from 'markdown-it-mark';
|
||||
import MarkdownItKatex from 'markdown-it-katex';
|
||||
import axios from 'axios';
|
||||
import qs from 'querystring'
|
||||
import $ from 'jquery';
|
||||
import search from './search';
|
||||
import createPageSearch from './fuse';
|
||||
import util from './util';
|
||||
import Mustache from 'mustache';
|
||||
import getCaretCoordinates from './caret-position'
|
||||
import moment from 'moment'
|
||||
import mermaid from 'mermaid'
|
||||
import {Network, parseDOTNetwork} from "vis-network/peer";
|
||||
import PrismJS from 'prismjs'
|
||||
import 'prismjs/plugins/filter-highlight-all/prism-filter-highlight-all'
|
||||
import 'prismjs/components/prism-php'
|
||||
|
@ -22,13 +9,10 @@ import 'prismjs/components/prism-perl'
|
|||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-jq'
|
||||
import menu from './menu.js'
|
||||
import './styles.scss'
|
||||
import wikiGraph from './graph'
|
||||
import { create, all } from 'mathjs'
|
||||
|
||||
const math = create(all)
|
||||
|
||||
import Editor from './editor'
|
||||
import MD from './markdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
moment.locale('nl')
|
||||
mermaid.initialize({startOnLoad: true})
|
||||
|
@ -40,122 +24,6 @@ PrismJS.plugins.filterHighlightAll.reject.addSelector('.language-dot')
|
|||
// wikiGraph('.graph-network')
|
||||
// })
|
||||
|
||||
function isMultiline(input) {
|
||||
return input.value.startsWith("```", 0)
|
||||
}
|
||||
|
||||
function addSaver(editor, saveUrl, page, beforeSave) {
|
||||
return {
|
||||
save() {
|
||||
return editor.save().then(outputData => {
|
||||
beforeSave()
|
||||
let data = {
|
||||
'json': 1,
|
||||
'p': page,
|
||||
'summary': "",
|
||||
'content': JSON.stringify(outputData),
|
||||
};
|
||||
return axios.post(saveUrl, qs.encode(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Indicator(element, timeout) {
|
||||
let timeoutId;
|
||||
|
||||
return {
|
||||
done() {
|
||||
timeoutId = setTimeout(() => {
|
||||
element.classList.add('hide')
|
||||
}, timeout * 1000);
|
||||
},
|
||||
|
||||
setText(text) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
element.innerText = text;
|
||||
element.classList.remove('hide')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function addIndicator(editor, indicator) {
|
||||
return {
|
||||
save() {
|
||||
editor.save().then(() => {
|
||||
indicator.setText('saved!');
|
||||
indicator.done();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showSearchResults(searchTool, query, input, value, resultType) {
|
||||
return showSearchResultsExtended('#link-complete', 'link-template', searchTool, query, input, value, resultType, {
|
||||
showOnlyResults: true,
|
||||
belowCursor: true
|
||||
})
|
||||
}
|
||||
|
||||
function showSearchResultsExtended(element, template, searchTool, query, input, value, resultType, options) {
|
||||
const $lc = $(element)
|
||||
return searchTool(query).then(results => {
|
||||
let opt = options || {};
|
||||
if (opt.showOnlyResults && (query.length === 0 || !results.length)) {
|
||||
$lc.fadeOut()
|
||||
return
|
||||
}
|
||||
$lc.data('result-type', resultType)
|
||||
|
||||
if (opt.belowCursor) {
|
||||
let pos = getCaretCoordinates(input, value.selectionEnd, {})
|
||||
let off = $(input).offset()
|
||||
pos.top += off.top + pos.height
|
||||
pos.left += off.left
|
||||
$lc.offset(pos)
|
||||
}
|
||||
|
||||
var templateText = document.getElementById(template).innerHTML;
|
||||
var rendered = Mustache.render(templateText, {
|
||||
page: value.trim().replace(/\s+/g, '_'),
|
||||
results: results
|
||||
}, {}, ['[[', ']]']);
|
||||
let selected = $lc.find('li.selected');
|
||||
if (selected) {
|
||||
let selectedPos = $lc.find('li').index(selected[0])
|
||||
rendered = $(rendered)
|
||||
const $lis = $lc.find('li')
|
||||
if ($lis.length >= 1) {
|
||||
selectedPos = Math.min(selectedPos, $lis.length - 1)
|
||||
rendered.find('li')[selectedPos].classList.add('selected')
|
||||
}
|
||||
}
|
||||
$lc.html(rendered).fadeIn()
|
||||
return results
|
||||
})
|
||||
}
|
||||
|
||||
function renderGraphs() {
|
||||
$('code.language-dot').each(function (i, code) {
|
||||
if (!code.innerText) {
|
||||
return true
|
||||
}
|
||||
|
||||
let data = parseDOTNetwork(code.innerText)
|
||||
let network = new Network(code, data, {
|
||||
layout: {
|
||||
randomSeed: 1239043
|
||||
}
|
||||
});
|
||||
$(code).on('click', function () {
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* EVENTS
|
||||
*/
|
||||
|
@ -196,318 +64,17 @@ $(document).on('popup:selected', '#autocomplete', function (event, linkName, res
|
|||
}
|
||||
})
|
||||
|
||||
const MD = new MarkdownIt({
|
||||
linkify: true,
|
||||
highlight: function (str, lang) {
|
||||
if (lang === 'mermaid') {
|
||||
return '<div class="mermaid">' + str + '</div>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
})
|
||||
MD.use(MarkdownItWikilinks({
|
||||
baseURL: document.querySelector('body').dataset.baseUrl,
|
||||
uriSuffix: '',
|
||||
relativeBaseURL: '/edit/',
|
||||
htmlAttributes: {
|
||||
class: 'wiki-link'
|
||||
},
|
||||
})).use(MarkdownItMark).use(MarkdownItKatex)
|
||||
|
||||
let holders = document.getElementsByClassName('wiki-list-editor');
|
||||
|
||||
_.forEach(holders, async (item, i) => {
|
||||
new Editor(item).then(editor => editor.start());
|
||||
})
|
||||
|
||||
function Editor(holder) {
|
||||
let scope = {}
|
||||
|
||||
const options = {
|
||||
transform(text, element) {
|
||||
let converted = text
|
||||
if (converted.startsWith("```", 0) || converted.startsWith("$$", 0)) {
|
||||
converted = MD.render(converted)
|
||||
} else if (converted.startsWith("=", 0)) {
|
||||
try {
|
||||
converted = math.evaluate(converted.substring(1), scope).toString()
|
||||
} catch (e) {
|
||||
converted = converted + ' <span style="background: red; color: white;">' + e.message + '</span>';
|
||||
}
|
||||
} else {
|
||||
if (text.match(/^(\w+):: (.+)$/)) {
|
||||
converted = converted.replace(/^(\w+):: (.*)$/, '**[[$1]]**: $2')
|
||||
} else if (text.match(/#\[\[TODO]]/)) {
|
||||
converted = converted.replace('#[[TODO]]', '<input class="checkbox" type="checkbox" />')
|
||||
} else if (text.match(/#\[\[DONE]]/)) {
|
||||
converted = converted.replace('#[[DONE]]', '<input class="checkbox" type="checkbox" checked />')
|
||||
}
|
||||
MD.options.html = true
|
||||
converted = MD.renderInline(converted)
|
||||
MD.options.html = false
|
||||
}
|
||||
|
||||
element.html(converted)
|
||||
}
|
||||
}
|
||||
|
||||
let inputData = JSON.parse(holder.dataset.input)
|
||||
|
||||
let editor = listEditor(holder, inputData, options);
|
||||
holder.$listEditor = editor
|
||||
|
||||
$(holder).on('click', '.content input[type="checkbox"]', function (event) {
|
||||
let that = this
|
||||
let id = $(this).closest('.list-item').data('id')
|
||||
editor.update(id, function (item, prev, next) {
|
||||
if (that.checked) {
|
||||
item.text = item.text.replace('#[[TODO]]', '#[[DONE]]')
|
||||
} else {
|
||||
item.text = item.text.replace('#[[DONE]]', '#[[TODO]]')
|
||||
}
|
||||
return item
|
||||
});
|
||||
event.stopPropagation()
|
||||
return true
|
||||
})
|
||||
|
||||
editor.on('change', function () {
|
||||
let element = holder
|
||||
let indicator = Indicator(document.getElementById('save-indicator'), 2);
|
||||
let saveUrl = element.dataset.saveurl;
|
||||
let page = element.dataset.page;
|
||||
|
||||
indicator.setText('has changes...');
|
||||
addIndicator(
|
||||
addSaver(editor, saveUrl, page, () => indicator.setText('saving...')),
|
||||
indicator
|
||||
).save()
|
||||
})
|
||||
|
||||
editor.on('rendered', function () {
|
||||
PrismJS.highlightAll()
|
||||
mermaid.init()
|
||||
renderGraphs();
|
||||
})
|
||||
|
||||
menu.connectContextMenu(editor)
|
||||
|
||||
return createPageSearch().then(function ({titleSearch, commandSearch, commands}) {
|
||||
editor.on('start-editing', function (input) {
|
||||
const $lc = $('#link-complete');
|
||||
|
||||
$(input).parents('.list-item').addClass('active');
|
||||
|
||||
$lc.on('popup:selected', function (event, linkName, resultType, element) {
|
||||
let value = input.value
|
||||
let end = input.selectionEnd
|
||||
if (resultType === 'link') {
|
||||
let start = value.lastIndexOf("[[", end)
|
||||
end += 2
|
||||
let startAndLink = value.substring(0, start) + "[[" + linkName + "]]"
|
||||
input.value = startAndLink + value.substring(end)
|
||||
input.selectionStart = startAndLink.length
|
||||
input.selectionEnd = startAndLink.length
|
||||
input.focus()
|
||||
} else if (resultType === 'command') {
|
||||
let start = value.lastIndexOf("/", end)
|
||||
let commandResult = ""
|
||||
let replace = ""
|
||||
let prefix = false
|
||||
let adjustment = 0
|
||||
|
||||
let now = moment()
|
||||
|
||||
if (linkName === "Current Time") {
|
||||
commandResult = now.format('HH:mm')
|
||||
} else if (linkName === "Today") {
|
||||
commandResult = "[[" + now.format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "Tomorrow") {
|
||||
commandResult = "[[" + now.add(1, 'day').format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "Yesterday") {
|
||||
commandResult = "[[" + now.add(-1, 'day').format('D MMMM YYYY') + "]]"
|
||||
} else if (linkName === "TODO") {
|
||||
commandResult = "#[[TODO]] "
|
||||
replace = "#[[DONE]] "
|
||||
prefix = true
|
||||
} else if (linkName === "DONE") {
|
||||
commandResult = "#[[DONE]] "
|
||||
replace = "#[[TODO]] "
|
||||
prefix = true
|
||||
} else if (linkName === "Page Reference") {
|
||||
commandResult = "[[]]"
|
||||
adjustment = -2
|
||||
} else if (linkName === "Code Block") {
|
||||
commandResult = "```\n\n```"
|
||||
adjustment = -5
|
||||
}
|
||||
|
||||
let startAndLink = prefix
|
||||
? commandResult + value.substring(0, start).replace(replace, "")
|
||||
: value.substring(0, start) + commandResult
|
||||
|
||||
input.value = startAndLink + value.substring(end)
|
||||
|
||||
input.selectionStart = startAndLink.length + adjustment
|
||||
input.selectionEnd = startAndLink.length + adjustment
|
||||
|
||||
input.focus()
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
$lc.on('popup:leave', function (event) {
|
||||
input.focus()
|
||||
})
|
||||
|
||||
$(input).on('keydown', function (event) {
|
||||
const isVisible = $('#link-complete:visible').length > 0;
|
||||
|
||||
if (event.key === 'Escape' && isVisible) {
|
||||
$lc.fadeOut()
|
||||
return false
|
||||
} else if (event.key === 'Enter' && isVisible) {
|
||||
const element = $lc.find('li.selected')
|
||||
const linkName = element.text()
|
||||
$lc.trigger('popup:selected', [linkName, $lc.data('result-type'), element])
|
||||
$lc.fadeOut()
|
||||
return false
|
||||
} else if (event.key === 'ArrowUp' && isVisible) {
|
||||
const selected = $lc.find('li.selected')
|
||||
const prev = selected.prev('li')
|
||||
if (prev.length) {
|
||||
prev.addClass('selected')
|
||||
selected.removeClass('selected')
|
||||
prev[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
||||
} else {
|
||||
// move back from dropdown to input
|
||||
$lc.trigger('popup:leave')
|
||||
selected.removeClass('selected')
|
||||
}
|
||||
return false
|
||||
} else if (event.key === 'ArrowDown' && isVisible) {
|
||||
const selected = $lc.find('li.selected')
|
||||
if (!selected.length) {
|
||||
$lc.find('li:not(.selected):first-child').addClass('selected')
|
||||
return false
|
||||
}
|
||||
|
||||
const next = selected.next('li');
|
||||
if (next.length) {
|
||||
next.addClass('selected')
|
||||
selected.removeClass('selected')
|
||||
next[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let mirror = {
|
||||
'[': ']',
|
||||
'(': ')',
|
||||
'{': '}',
|
||||
}
|
||||
|
||||
if (!isMultiline(input) && mirror.hasOwnProperty(event.key)) {
|
||||
let input = this
|
||||
let val = input.value
|
||||
let prefix = val.substring(0, input.selectionStart)
|
||||
let selection = val.substring(input.selectionStart, input.selectionEnd)
|
||||
let suffix = val.substring(input.selectionEnd)
|
||||
input.value = prefix + event.key + selection + mirror[event.key] + suffix
|
||||
input.selectionStart = prefix.length + event.key.length
|
||||
input.selectionEnd = input.selectionStart + selection.length
|
||||
$(input).trigger('input')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
let searchEnabled = false
|
||||
|
||||
$(input).on('keyup', function (event) {
|
||||
if (event.key === '/') {
|
||||
searchEnabled = true
|
||||
}
|
||||
if (searchEnabled && event.key === 'Escape') {
|
||||
searchEnabled = false
|
||||
return false;
|
||||
}
|
||||
const ignoreKeys = {
|
||||
'ArrowUp': true,
|
||||
'ArrowDown': true,
|
||||
'Enter': true,
|
||||
}
|
||||
|
||||
if (event.key in ignoreKeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
let value = input.value
|
||||
let end = input.selectionEnd
|
||||
|
||||
let [start, insideLink] = util.cursorInsideLink(value, end)
|
||||
let insideSearch = false
|
||||
|
||||
if (searchEnabled && !insideLink) {
|
||||
start = value.lastIndexOf("/", end)
|
||||
insideSearch = start >= 0
|
||||
}
|
||||
|
||||
if (insideSearch) {
|
||||
let query = value.substring(start + 1, end);
|
||||
showSearchResults(commandSearch, query, input, value, 'command').then(results => {
|
||||
if (query.length > 0 && result.length === 0) {
|
||||
searchEnabled = false
|
||||
}
|
||||
})
|
||||
return true
|
||||
} else if (insideLink) {
|
||||
let query = value.substring(start + 2, end);
|
||||
showSearchResults(titleSearch, query, input, value, 'link');
|
||||
return true
|
||||
} else {
|
||||
$('#link-complete').fadeOut();
|
||||
}
|
||||
})
|
||||
})
|
||||
editor.on('stop-editing', function (input) {
|
||||
$(input).parents('.list-item').removeClass('active');
|
||||
$('#link-complete').off()
|
||||
PrismJS.highlightAll()
|
||||
mermaid.init()
|
||||
renderGraphs();
|
||||
})
|
||||
return editor
|
||||
})
|
||||
}
|
||||
|
||||
let timeout = null;
|
||||
let searchInput = document.getElementById('search-input');
|
||||
search(searchInput).then(searcher => {
|
||||
let showSearch = _.debounce(function (searcher) {
|
||||
let query = $(searchInput).val()
|
||||
if (query === '') {
|
||||
let autocomplete = document.getElementById('autocomplete');
|
||||
$(autocomplete).hide()
|
||||
return false
|
||||
}
|
||||
|
||||
showSearchResultsExtended('#autocomplete', 'result-template', query => searcher.search(query), query, searchInput, query, 'search-result')
|
||||
return true;
|
||||
}, 200)
|
||||
|
||||
$(searchInput).on('keyup', function (event) {
|
||||
showSearch.cancel()
|
||||
showSearch(searcher)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll(".page-loader")
|
||||
.forEach((el, key, parent) => {
|
||||
fetch('/' + el.dataset.page + '?format=markdown').then(res => res.text()).then(text => {
|
||||
el.innerHTML = MD.render(text)
|
||||
})
|
||||
let format = el.dataset.format
|
||||
let edit = el.dataset.edit
|
||||
fetch('/' + el.dataset.page + '?format=' + format)
|
||||
.then(res => edit ? res.json() : res.text())
|
||||
.then(text => {
|
||||
if (edit) {
|
||||
new Editor(el, text).then(editor => editor.start());
|
||||
} else {
|
||||
el.innerHTML = MD.render(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
25
editor/src/markdown.js
Normal file
25
editor/src/markdown.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import MarkdownIt from "markdown-it";
|
||||
import MarkdownItWikilinks from "./wikilinks";
|
||||
import MarkdownItMark from "markdown-it-mark";
|
||||
import MarkdownItKatex from "markdown-it-katex";
|
||||
|
||||
const MD = new MarkdownIt({
|
||||
linkify: true,
|
||||
highlight: function (str, lang) {
|
||||
if (lang === 'mermaid') {
|
||||
return '<div class="mermaid">' + str + '</div>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
})
|
||||
|
||||
MD.use(MarkdownItWikilinks({
|
||||
baseURL: document.querySelector('body').dataset.baseUrl,
|
||||
uriSuffix: '',
|
||||
relativeBaseURL: '/edit/',
|
||||
htmlAttributes: {
|
||||
class: 'wiki-link'
|
||||
},
|
||||
})).use(MarkdownItMark).use(MarkdownItKatex)
|
||||
|
||||
export default MD;
|
|
@ -44,6 +44,14 @@ function connectContextMenu(editor) {
|
|||
})
|
||||
},
|
||||
className: 'action-copy-line'
|
||||
},
|
||||
zoom: {
|
||||
name: 'Zoom in',
|
||||
callback: function (key, opt) {
|
||||
editor.zoomin(this).then(id => {
|
||||
location.href = '/edit/'+id;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -13,11 +13,13 @@ function search(element) {
|
|||
let actualResult = [];
|
||||
$.each(data.hits, (key, value) => {
|
||||
actualResult.push({
|
||||
ref: value.id,
|
||||
title: value.id.replace(/_/g, ' '),
|
||||
ref: value.fields.page,
|
||||
title: value.fields.title,
|
||||
text: value.fragments.text[0]
|
||||
})
|
||||
})
|
||||
element.classList.remove('is-loading')
|
||||
console.log(actualResult)
|
||||
return actualResult
|
||||
})
|
||||
}
|
||||
|
|
|
@ -32,7 +32,9 @@
|
|||
@import "~bulma/sass/layout/_all";
|
||||
|
||||
@import '~jquery-contextmenu/dist/jquery.contextMenu.css';
|
||||
@import '~vis-network/styles/vis-network.css';
|
||||
//@import '~vis-network/styles/vis-network.css';
|
||||
|
||||
@import url('https://rsms.me/inter/inter.css');
|
||||
|
||||
html {
|
||||
font-family: 'Inter', sans-serif;
|
||||
|
@ -237,3 +239,205 @@ mark {
|
|||
.wiki-list-editor {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.column a + a :before {
|
||||
content: '>';
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
min-height: 24px;
|
||||
}
|
||||
#autocomplete {
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
width: 640px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 600px;
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#autocomplete li > a {
|
||||
display: block;
|
||||
}
|
||||
#autocomplete li div {
|
||||
font-size: 0.8em;
|
||||
display: block;
|
||||
color: black;
|
||||
}
|
||||
#autocomplete li {
|
||||
padding: 4px 16px;
|
||||
max-height: 5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
#autocomplete li:hover {
|
||||
background: #fefefe;
|
||||
}
|
||||
#autocomplete li.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
#link-complete {
|
||||
z-index: 1;
|
||||
width: 217px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#link-complete li {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
#link-complete li.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: "Fira Code Retina", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.content input[type="checkbox"] {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.lighter {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ins {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.checklist--item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.checklist--item-text {
|
||||
align-self: center;
|
||||
}
|
||||
html {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
input.input-line {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.root .list-item {
|
||||
padding: 3px;
|
||||
padding-left: 12px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
margin-left: 32px;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.list-item .content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
.selected .marker {
|
||||
border-color: lightblue;
|
||||
}
|
||||
#editor {
|
||||
width: 750px;
|
||||
}
|
||||
.editor.selected .marker {
|
||||
/*border-color: white;*/
|
||||
}
|
||||
|
||||
.editor.selected {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
input.input-line, input.input-line:active {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.gu-mirror {
|
||||
position: fixed !important;
|
||||
margin: 0 !important;
|
||||
z-index: 9999 !important;
|
||||
opacity: 0.8;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)";
|
||||
filter: alpha(opacity=80);
|
||||
}
|
||||
|
||||
.gu-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gu-unselectable {
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.gu-transit {
|
||||
opacity: 0.2;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)";
|
||||
filter: alpha(opacity=20);
|
||||
}
|
||||
|
||||
.backrefs {
|
||||
padding: 24px;
|
||||
background: #deeeee;
|
||||
border-top: 3px solid #acc;
|
||||
}
|
||||
|
||||
.breadcrumb li {
|
||||
max-width: 200px;
|
||||
}
|
||||
.breadcrumb li > a {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
|
537
file.go
537
file.go
|
@ -9,9 +9,11 @@ import (
|
|||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -20,8 +22,9 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
DocumentsFile = "_documents.json"
|
||||
LinksFile = "_links.json"
|
||||
DocumentsFile = "_documents.json"
|
||||
LinksFile = "_links.json"
|
||||
BlocksDirectory = "_blocks"
|
||||
)
|
||||
|
||||
type saveMessage struct {
|
||||
|
@ -31,6 +34,75 @@ type saveMessage struct {
|
|||
author string
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Text string
|
||||
Children []string
|
||||
Parent string
|
||||
}
|
||||
|
||||
type ID struct {
|
||||
StrID string
|
||||
WasInt bool
|
||||
}
|
||||
|
||||
func (id *ID) UnmarshalJSON(data []byte) error {
|
||||
var intID int
|
||||
err := json.Unmarshal(data, &intID)
|
||||
if err == nil {
|
||||
*id = ID{strconv.FormatInt(int64(intID), 10), true}
|
||||
return nil
|
||||
}
|
||||
|
||||
var strID string
|
||||
err = json.Unmarshal(data, &strID)
|
||||
if err == nil {
|
||||
*id = ID{strID, false}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("could not unmarshal %q as an int or string", data)
|
||||
}
|
||||
|
||||
func (id *ID) NewID() string {
|
||||
if id.WasInt {
|
||||
l := time.Now().UnixNano()
|
||||
r := rand.Uint64()
|
||||
return fmt.Sprintf("_%d_%d", l, r)
|
||||
} else {
|
||||
return id.StrID
|
||||
}
|
||||
}
|
||||
|
||||
// ListItemV2 is way to convert from old structure to new structure
|
||||
type ListItemV2 struct {
|
||||
ID ID
|
||||
Indented int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (v2 ListItemV2) ListItem() ListItem {
|
||||
return ListItem{
|
||||
v2.ID.StrID,
|
||||
v2.Indented,
|
||||
v2.Text,
|
||||
}
|
||||
}
|
||||
|
||||
// ListItem is a simplification of the information that was saved by the editor
|
||||
type ListItem struct {
|
||||
ID string
|
||||
Indented int
|
||||
Text string
|
||||
}
|
||||
|
||||
type ActualListItem struct {
|
||||
ID string `json:"id"`
|
||||
Indented int `json:"indented"`
|
||||
Text string `json:"text"`
|
||||
Fold string `json:"fold"`
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
|
||||
type FilePages struct {
|
||||
dirname string
|
||||
saveC chan saveMessage
|
||||
|
@ -38,7 +110,13 @@ type FilePages struct {
|
|||
}
|
||||
|
||||
func NewFilePages(dirname string, index bleve.Index) PagesRepository {
|
||||
err := os.MkdirAll(filepath.Join(dirname, "_blocks"), 0777)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
fp := &FilePages{dirname, make(chan saveMessage), index}
|
||||
|
||||
go func() {
|
||||
for msg := range fp.saveC {
|
||||
err := fp.save(msg)
|
||||
|
@ -50,38 +128,88 @@ func NewFilePages(dirname string, index bleve.Index) PagesRepository {
|
|||
return fp
|
||||
}
|
||||
|
||||
func convertBlocksToListItems(current string, blocks BlockResponse, indent int) []ActualListItem {
|
||||
var listItems []ActualListItem
|
||||
|
||||
for _, child := range blocks.Children[current] {
|
||||
l := convertBlocksToListItems(child, blocks, indent+1)
|
||||
listItems = append(listItems,
|
||||
ActualListItem{
|
||||
ID: child,
|
||||
Indented: indent,
|
||||
Text: blocks.Texts[child],
|
||||
Fold: "open",
|
||||
Hidden: false,
|
||||
})
|
||||
listItems = append(listItems, l...)
|
||||
}
|
||||
|
||||
return listItems
|
||||
}
|
||||
|
||||
func (fp *FilePages) Get(title string) Page {
|
||||
name := strings.Replace(title, " ", "_", -1)
|
||||
title = strings.Replace(title, "_", " ", -1)
|
||||
// TODO: cleanup loading of pages
|
||||
// TODO: convert all pages to blocks
|
||||
name := title
|
||||
|
||||
refs, err := getBackrefs(fp, name)
|
||||
if err != nil {
|
||||
refs = nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filepath.Join(fp.dirname, name))
|
||||
blocks, err := loadBlocks(fp.dirname, name)
|
||||
if err != nil {
|
||||
name = strings.Replace(title, " ", "_", -1)
|
||||
title = strings.Replace(title, "_", " ", -1)
|
||||
|
||||
f, err := os.Open(filepath.Join(fp.dirname, name))
|
||||
if err != nil {
|
||||
return Page{
|
||||
Title: title,
|
||||
Name: name,
|
||||
Content: "",
|
||||
Refs: refs,
|
||||
Blocks: blocks,
|
||||
}
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
body, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return Page{
|
||||
Title: title,
|
||||
Name: name,
|
||||
Content: "",
|
||||
Refs: refs,
|
||||
Blocks: blocks,
|
||||
}
|
||||
}
|
||||
|
||||
return Page{
|
||||
Title: title,
|
||||
Name: name,
|
||||
Content: "",
|
||||
Title: title,
|
||||
Content: string(body),
|
||||
Refs: refs,
|
||||
Blocks: blocks,
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
body, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return Page{
|
||||
Title: title,
|
||||
Name: name,
|
||||
Content: "",
|
||||
Refs: refs,
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
current := blocks.PageID
|
||||
listItems := convertBlocksToListItems(current, blocks, 0)
|
||||
if listItems == nil {
|
||||
listItems = []ActualListItem{}
|
||||
}
|
||||
|
||||
err = json.NewEncoder(&buf).Encode(&listItems)
|
||||
|
||||
return Page{
|
||||
Name: name,
|
||||
Title: title,
|
||||
Content: string(body),
|
||||
Title: blocks.Texts[name],
|
||||
Content: buf.String(),
|
||||
Refs: refs,
|
||||
Blocks: blocks,
|
||||
Parent: blocks.ParentID,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,7 +220,6 @@ func (fp *FilePages) Save(p string, page Page, summary, author string) error {
|
|||
|
||||
func (fp *FilePages) save(msg saveMessage) error {
|
||||
var sw stopwatch
|
||||
sw.Start("prepare")
|
||||
p := msg.p
|
||||
page := msg.page
|
||||
summary := msg.summary
|
||||
|
@ -101,56 +228,275 @@ func (fp *FilePages) save(msg saveMessage) error {
|
|||
page.Name = strings.Replace(p, " ", "_", -1)
|
||||
page.Title = strings.Replace(p, "_", " ", -1)
|
||||
|
||||
f, err := os.Create(filepath.Join(fp.dirname, strings.Replace(p, " ", "_", -1)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if page.Content[0] == '{' || page.Content[0] == '[' {
|
||||
var buf bytes.Buffer
|
||||
err = json.Indent(&buf, []byte(page.Content), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = buf.WriteTo(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
f.WriteString(strings.Replace(page.Content, "\r\n", "\n", -1))
|
||||
}
|
||||
sw.Stop()
|
||||
sw.Start("backrefs")
|
||||
err = processBackrefs(fp.dirname, page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while processing backrefs: %s", err)
|
||||
}
|
||||
sw.Stop()
|
||||
if p[0] != '_' {
|
||||
sw.Start("prepare")
|
||||
|
||||
sw.Start("git")
|
||||
err = saveWithGit(fp, p, summary, author)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while saving to git: %w", err)
|
||||
}
|
||||
sw.Stop()
|
||||
sw.Start("index")
|
||||
so, err := createSearchObject(page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while creating search object %s: %w", page.Name, err)
|
||||
}
|
||||
if fp.index != nil {
|
||||
err = fp.index.Index(page.Name, so)
|
||||
f, err := os.Create(filepath.Join(fp.dirname, strings.Replace(p, " ", "_", -1)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("while indexing %s: %w", page.Name, err)
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if page.Content[0] == '{' || page.Content[0] == '[' {
|
||||
var buf bytes.Buffer
|
||||
err = json.Indent(&buf, []byte(page.Content), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = buf.WriteTo(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
f.WriteString(strings.Replace(page.Content, "\r\n", "\n", -1))
|
||||
}
|
||||
sw.Stop()
|
||||
sw.Start("backrefs")
|
||||
err = processBackrefs(fp.dirname, page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while processing backrefs: %s", err)
|
||||
}
|
||||
sw.Stop()
|
||||
|
||||
sw.Start("git")
|
||||
err = saveWithGit(fp, p, summary, author)
|
||||
if err != nil {
|
||||
log.Printf("Error while saving to git: %w", err)
|
||||
// return fmt.Errorf("while saving to git: %w", err)
|
||||
}
|
||||
sw.Stop()
|
||||
|
||||
sw.Start("index")
|
||||
searchObjects, err := createSearchObjects(page.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while creating search object %s: %w", page.Name, err)
|
||||
}
|
||||
for _, so := range searchObjects {
|
||||
if fp.index != nil {
|
||||
err = fp.index.Index(so.ID, so)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while indexing %s: %w", page.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
sw.Stop()
|
||||
sw.Start("links")
|
||||
err = saveLinksIncremental(fp.dirname, page.Title)
|
||||
sw.Stop()
|
||||
}
|
||||
|
||||
sw.Start("create blocks")
|
||||
err := saveBlocksFromPage(fp.dirname, page)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
sw.Stop()
|
||||
sw.Start("links")
|
||||
err = saveLinksIncremental(fp.dirname, page.Title)
|
||||
sw.Stop()
|
||||
return err
|
||||
}
|
||||
|
||||
func saveWithNewIDs(dirname string, listItems []ListItemV2, pageName string) ([]ListItem, error) {
|
||||
var newListItems []ListItem
|
||||
for _, item := range listItems {
|
||||
newItem := ListItem{
|
||||
ID: item.ID.NewID(),
|
||||
Indented: item.Indented,
|
||||
Text: item.Text,
|
||||
}
|
||||
newListItems = append(newListItems, newItem)
|
||||
}
|
||||
|
||||
return newListItems, nil
|
||||
}
|
||||
|
||||
func saveBlocksFromPage(dirname string, page Page) error {
|
||||
log.Println("Processing: ", page.Name)
|
||||
var listItems []ListItem
|
||||
err := json.NewDecoder(strings.NewReader(page.Content)).Decode(&listItems)
|
||||
if err != nil {
|
||||
var listItemsV2 []ListItemV2
|
||||
err = json.NewDecoder(strings.NewReader(page.Content)).Decode(&listItemsV2)
|
||||
listItems, err = saveWithNewIDs(dirname, listItemsV2, page.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while rewriting %s to use new ids: %w", page.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
blocks := make(map[string]Block)
|
||||
prevList := make(map[string]ListItem)
|
||||
|
||||
root := "root"
|
||||
parentBlock, err := loadBlock(dirname, page.Name)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
} else {
|
||||
root = parentBlock.Parent
|
||||
}
|
||||
|
||||
title := page.Title
|
||||
if page.Name[0] == '_' {
|
||||
title = parentBlock.Text
|
||||
}
|
||||
|
||||
var parent = ListItem{
|
||||
Text: title,
|
||||
Indented: -1,
|
||||
ID: page.Name,
|
||||
}
|
||||
|
||||
prevList[parent.ID] = parent
|
||||
blocks[parent.ID] = Block{
|
||||
Text: title,
|
||||
Children: nil,
|
||||
Parent: root,
|
||||
}
|
||||
|
||||
var prev = &parent
|
||||
|
||||
for i, item := range listItems {
|
||||
prevList[item.ID] = item
|
||||
if item.Indented > prev.Indented {
|
||||
parent = *prev
|
||||
} else if item.Indented == prev.Indented {
|
||||
// nothing
|
||||
} else if item.Indented <= parent.Indented {
|
||||
for item.Indented <= parent.Indented {
|
||||
if block, e := blocks[parent.ID]; e {
|
||||
parent = prevList[block.Parent]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocks[item.ID] = Block{item.Text, []string{}, parent.ID}
|
||||
if block, e := blocks[parent.ID]; e {
|
||||
block.Children = append(block.Children, item.ID)
|
||||
blocks[parent.ID] = block
|
||||
} else {
|
||||
log.Println("Block missing")
|
||||
}
|
||||
prev = &listItems[i]
|
||||
}
|
||||
|
||||
log.Printf("Loading parent block: %s", parent.ID)
|
||||
f, err := os.Open(filepath.Join(dirname, BlocksDirectory, parent.ID))
|
||||
if err == nil {
|
||||
var parentBlock Block
|
||||
err = json.NewDecoder(f).Decode(&parentBlock)
|
||||
if err == nil {
|
||||
if pb, e := blocks[parent.ID]; e {
|
||||
pb.Text = parentBlock.Text
|
||||
pb.Parent = parentBlock.Parent
|
||||
blocks[parent.ID] = pb
|
||||
log.Printf("Text=%s, Parent=%s", parentBlock.Text, parentBlock.Parent)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
} else {
|
||||
log.Println(err)
|
||||
err = nil
|
||||
}
|
||||
|
||||
for id, block := range blocks {
|
||||
log.Println("Writing to ", id)
|
||||
f, err := os.OpenFile(filepath.Join(dirname, BlocksDirectory, id), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
err = enc.Encode(&block)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type BlockResponse struct {
|
||||
PageID string
|
||||
ParentID string
|
||||
Texts map[string]string
|
||||
Children map[string][]string
|
||||
Parents []string
|
||||
}
|
||||
|
||||
func loadBlocks(dirname, rootBlockID string) (BlockResponse, error) {
|
||||
resp := BlockResponse{
|
||||
rootBlockID,
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
}
|
||||
|
||||
resp.Texts = make(map[string]string)
|
||||
resp.Children = make(map[string][]string)
|
||||
|
||||
queue := []string{rootBlockID}
|
||||
|
||||
block, err := loadBlock(dirname, rootBlockID)
|
||||
if err != nil {
|
||||
return BlockResponse{}, fmt.Errorf("while loading current block (%s): %w", rootBlockID, err)
|
||||
}
|
||||
if rootBlockID[0] != '_' && block.Children == nil {
|
||||
return BlockResponse{}, fmt.Errorf("while loading current block (%s): not a block and no children", rootBlockID)
|
||||
}
|
||||
|
||||
prevID := rootBlockID
|
||||
parentID := block.Parent
|
||||
|
||||
for parentID != "root" {
|
||||
parent, err := loadBlock(dirname, parentID)
|
||||
if err != nil {
|
||||
return BlockResponse{}, fmt.Errorf("while loading current parent block (%s->%s): %w", prevID, parentID, err)
|
||||
}
|
||||
|
||||
resp.Texts[parentID] = parent.Text
|
||||
resp.Children[parentID] = parent.Children
|
||||
resp.ParentID = parentID
|
||||
|
||||
resp.Parents = append(resp.Parents, parentID)
|
||||
|
||||
prevID = parentID
|
||||
parentID = parent.Parent
|
||||
}
|
||||
if parentID == "root" {
|
||||
resp.ParentID = "root"
|
||||
}
|
||||
|
||||
for {
|
||||
if len(queue) == 0 {
|
||||
break
|
||||
}
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
block, err := loadBlock(dirname, current)
|
||||
if err != nil {
|
||||
return BlockResponse{}, fmt.Errorf("while loading block (%s): %w", current, err)
|
||||
}
|
||||
resp.Texts[current] = block.Text
|
||||
resp.Children[current] = block.Children
|
||||
queue = append(queue, block.Children...)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func loadBlock(dirname, blockID string) (Block, error) {
|
||||
f, err := os.Open(filepath.Join(dirname, BlocksDirectory, blockID))
|
||||
if err != nil {
|
||||
return Block{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
var block Block
|
||||
err = json.NewDecoder(f).Decode(&block)
|
||||
if err != nil {
|
||||
return Block{}, err
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func saveLinksIncremental(dirname, title string) error {
|
||||
type Document struct {
|
||||
Title string `json:"title"`
|
||||
|
@ -189,19 +535,19 @@ func saveLinksIncremental(dirname, title string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func saveLinks(fp *FilePages) error {
|
||||
func saveLinks(mp PagesRepository) error {
|
||||
type Document struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
var results []Document
|
||||
pages, err := mp.(*FilePages).AllPages()
|
||||
pages, err := mp.AllPages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, page := range pages {
|
||||
results = append(results, Document{page.Title})
|
||||
}
|
||||
f, err := os.Create(filepath.Join(fp.dirname, LinksFile))
|
||||
f, err := os.Create(filepath.Join(mp.(*FilePages).dirname, LinksFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -213,65 +559,6 @@ func saveLinks(fp *FilePages) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func saveDocuments(fp *FilePages) error {
|
||||
type Document struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
var results []Document
|
||||
pages, err := mp.(*FilePages).AllPages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, page := range pages {
|
||||
content := strings.Builder{}
|
||||
|
||||
var listItems []struct {
|
||||
Indented int
|
||||
Text string
|
||||
}
|
||||
|
||||
err = json.NewDecoder(strings.NewReader(page.Content)).Decode(&listItems)
|
||||
if err == nil {
|
||||
for _, item := range listItems {
|
||||
content.WriteString(item.Text)
|
||||
content.WriteByte(' ')
|
||||
}
|
||||
} else {
|
||||
content.WriteString(page.Content)
|
||||
content.WriteByte(' ')
|
||||
}
|
||||
|
||||
for page, refs := range page.Refs {
|
||||
content.WriteString(page)
|
||||
content.WriteByte(' ')
|
||||
for _, ref := range refs {
|
||||
content.WriteString(ref.Line)
|
||||
content.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, Document{
|
||||
Title: page.Title,
|
||||
Body: content.String(),
|
||||
URL: page.Name,
|
||||
})
|
||||
}
|
||||
|
||||
f, err := os.Create(filepath.Join(fp.dirname, DocumentsFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
err = json.NewEncoder(f).Encode(&results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveWithGit(fp *FilePages, p string, summary, author string) error {
|
||||
cmd := exec.Command("git", "add", ".")
|
||||
cmd.Dir = fp.dirname
|
||||
|
@ -282,6 +569,8 @@ func saveWithGit(fp *FilePages, p string, summary, author string) error {
|
|||
|
||||
cmd = exec.Command("git", "commit", "-m", "Changes to "+p+" by "+author+"\n\n"+summary)
|
||||
cmd.Dir = fp.dirname
|
||||
// cmd.Stderr = os.Stderr
|
||||
// cmd.Stdout = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("while commiting page %s: %s", p, err)
|
||||
|
@ -455,6 +744,8 @@ func (fp *FilePages) RecentChanges() ([]Change, error) {
|
|||
}
|
||||
|
||||
func (fp *FilePages) AllPages() ([]Page, error) {
|
||||
log.Println("AllPages", fp.dirname)
|
||||
|
||||
files, err := ioutil.ReadDir(fp.dirname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
1
go.mod
1
go.mod
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/blevesearch/bleve v1.0.9
|
||||
github.com/blevesearch/cld2 v0.0.0-20200327141045-8b5f551d37f5 // indirect
|
||||
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d // indirect
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a // indirect
|
||||
github.com/golang/protobuf v1.4.2 // indirect
|
||||
github.com/ikawaha/kagome.ipadic v1.1.2 // indirect
|
||||
|
|
|
@ -275,8 +275,6 @@ function editor(root, inputData, options) {
|
|||
if (store.hasChanged()) {
|
||||
resolve(store.tree(from))
|
||||
store.clearChanged()
|
||||
} else {
|
||||
reject()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -294,6 +292,15 @@ function editor(root, inputData, options) {
|
|||
});
|
||||
}
|
||||
|
||||
function zoomin(element, opt) {
|
||||
let item = $(element).parents('.list-item')
|
||||
let id = item.data('id')
|
||||
return new Promise(function (resolve, reject) {
|
||||
resolve(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function on(evt, handler) {
|
||||
events[evt].push(handler)
|
||||
}
|
||||
|
@ -452,8 +459,8 @@ function editor(root, inputData, options) {
|
|||
return true
|
||||
})
|
||||
|
||||
$(root).on('click', '.list-item', function () {
|
||||
let currentIndex = $(root).children('div.list-item').index(this)
|
||||
$(root).on('click', '.content', function (event) {
|
||||
let currentIndex = $(root).children('div.list-item').index($(this).parents('.list-item')[0])
|
||||
if (cursor.atPosition(currentIndex) && currentEditor !== null && currentEditor.closest('.list-item')[0] === this) {
|
||||
return true
|
||||
}
|
||||
|
@ -509,7 +516,8 @@ function editor(root, inputData, options) {
|
|||
saveTree,
|
||||
copy,
|
||||
update,
|
||||
start
|
||||
start,
|
||||
zoomin
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -195,7 +195,7 @@ function Store(inputData) {
|
|||
let index = _.findIndex(idList, (id) => id === currentId)
|
||||
let oldValue = _.clone(values[currentId])
|
||||
let newValue = callback(values[currentId], values[idList[index - 1]], values[idList[index + 1]]);
|
||||
if (oldValue.text !== newValue.text) {
|
||||
if (oldValue && oldValue.text !== newValue.text) {
|
||||
values[currentId] = newValue
|
||||
changed = true
|
||||
}
|
||||
|
|
68
main.go
68
main.go
|
@ -52,7 +52,9 @@ type Page struct {
|
|||
|
||||
Content string
|
||||
|
||||
Refs map[string][]Backref
|
||||
Refs map[string][]Backref
|
||||
Blocks BlockResponse
|
||||
Parent string
|
||||
}
|
||||
|
||||
type DiffPage struct {
|
||||
|
@ -123,6 +125,11 @@ type graphPage struct {
|
|||
Edges template.JS
|
||||
}
|
||||
|
||||
type Parent struct {
|
||||
Text string
|
||||
ID string
|
||||
}
|
||||
|
||||
type editPage struct {
|
||||
pageBaseInfo
|
||||
Session *Session
|
||||
|
@ -133,6 +140,8 @@ type editPage struct {
|
|||
Backrefs map[string][]Backref
|
||||
ShowGraph bool
|
||||
TodayPage string
|
||||
Parent Parent
|
||||
Parents []Parent
|
||||
}
|
||||
|
||||
type historyPage struct {
|
||||
|
@ -445,11 +454,27 @@ func (h *editHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
pageBase := getPageBase()
|
||||
title := cleanTitle(page)
|
||||
title := cleanTitle(mpPage.Title)
|
||||
if newTitle, err := PageTitle(pageText); err == nil {
|
||||
title = newTitle
|
||||
}
|
||||
|
||||
var parent Parent
|
||||
parent.ID = mpPage.Parent
|
||||
parent.Text = mpPage.Blocks.Texts[parent.ID]
|
||||
|
||||
var parents []Parent
|
||||
for _, p := range mpPage.Blocks.Parents {
|
||||
var parent Parent
|
||||
parent.ID = p
|
||||
parent.Text = mpPage.Blocks.Texts[p]
|
||||
parents = append(parents, parent)
|
||||
}
|
||||
|
||||
for i, j := 0, len(parents)-1; i < j; i, j = i+1, j-1 {
|
||||
parents[i], parents[j] = parents[j], parents[i]
|
||||
}
|
||||
|
||||
data := editPage{
|
||||
pageBaseInfo: pageBase,
|
||||
Session: sess,
|
||||
|
@ -460,6 +485,8 @@ func (h *editHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
Backrefs: mpPage.Refs,
|
||||
TodayPage: "Today",
|
||||
ShowGraph: page != "Daily_Notes",
|
||||
Parent: parent,
|
||||
Parents: parents,
|
||||
}
|
||||
templates := baseTemplate
|
||||
templates = append(templates, "templates/edit.html")
|
||||
|
@ -468,6 +495,8 @@ func (h *editHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
@ -595,7 +624,7 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
mpPage := mp.Get(page)
|
||||
pageText := mpPage.Content
|
||||
if pageText == "" {
|
||||
if (format == "" || format == "html") && pageText == "" {
|
||||
http.Redirect(w, r, "/edit/"+page, 302)
|
||||
return
|
||||
}
|
||||
|
@ -612,6 +641,8 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
jsonPage := pageText != "" && err == nil
|
||||
if jsonPage {
|
||||
if format == "json" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// Shortcut for json output
|
||||
_, err := io.WriteString(w, pageText)
|
||||
if err != nil {
|
||||
|
@ -619,11 +650,13 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
return
|
||||
} else if format == "metakv" {
|
||||
so, err := createSearchObject(mpPage)
|
||||
so, err := createStructuredFormat(mpPage)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
err = enc.Encode(so)
|
||||
|
@ -701,6 +734,8 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
@ -708,6 +743,8 @@ func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
} else if format == "markdown" {
|
||||
w.Header().Set("Content-Type", "text/markdown")
|
||||
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||
_, err = io.WriteString(w, "# ")
|
||||
_, err = io.WriteString(w, title)
|
||||
_, err = io.WriteString(w, "\n\n")
|
||||
|
@ -907,6 +944,18 @@ func createSearchIndex(dataDir, indexName string) (bleve.Index, error) {
|
|||
indexDir := filepath.Join(dataDir, indexName)
|
||||
if _, err := os.Stat(indexDir); os.IsNotExist(err) {
|
||||
indexMapping := bleve.NewIndexMapping()
|
||||
documentMapping := bleve.NewDocumentMapping()
|
||||
|
||||
nameFieldMapping := bleve.NewTextFieldMapping()
|
||||
nameFieldMapping.Store = true
|
||||
documentMapping.AddFieldMappingsAt("name", nameFieldMapping)
|
||||
|
||||
titleFieldMapping := bleve.NewTextFieldMapping()
|
||||
titleFieldMapping.Store = true
|
||||
documentMapping.AddFieldMappingsAt("title", titleFieldMapping)
|
||||
|
||||
indexMapping.AddDocumentMapping("block", documentMapping)
|
||||
|
||||
searchIndex, err := bleve.New(indexDir, indexMapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -919,16 +968,17 @@ func createSearchIndex(dataDir, indexName string) (bleve.Index, error) {
|
|||
}
|
||||
|
||||
for _, page := range pages {
|
||||
so, err := createSearchObject(page)
|
||||
searchObjects, err := createSearchObjects(page.Name)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = searchIndex.Index(page.Name, so)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for _, so := range searchObjects {
|
||||
err = searchIndex.Index(so.ID, so)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return searchIndex, nil
|
||||
|
|
227
render.go
227
render.go
|
@ -1,227 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Renderer interface {
|
||||
Render(w io.Writer) error
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Type string
|
||||
Data json.RawMessage
|
||||
|
||||
renderer Renderer
|
||||
}
|
||||
|
||||
type Paragraph struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
type Code struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
type List struct {
|
||||
Style string
|
||||
Items []string
|
||||
}
|
||||
|
||||
type Header struct {
|
||||
Level int
|
||||
Text string
|
||||
}
|
||||
|
||||
type ChecklistItem struct {
|
||||
Text string
|
||||
Checked bool
|
||||
}
|
||||
|
||||
type Checklist struct {
|
||||
Style string
|
||||
Items []ChecklistItem
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Link string
|
||||
Meta LinkResponseMeta
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
Content [][]string
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
Time int64
|
||||
Version string
|
||||
Blocks []Block
|
||||
}
|
||||
|
||||
func (block Block) getType() (Renderer, error) {
|
||||
switch block.Type {
|
||||
case "table":
|
||||
return &Table{}, nil
|
||||
case "link":
|
||||
return &Link{}, nil
|
||||
case "list":
|
||||
return &List{}, nil
|
||||
case "header":
|
||||
return &Header{}, nil
|
||||
case "paragraph":
|
||||
return &Paragraph{}, nil
|
||||
case "code":
|
||||
return &Code{}, nil
|
||||
case "checklist":
|
||||
return &Checklist{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type: %s", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (block Block) load() (Renderer, error) {
|
||||
if block.renderer != nil {
|
||||
return block.renderer, nil
|
||||
}
|
||||
renderer, err := block.getType()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(block.Data, renderer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block.renderer = renderer
|
||||
|
||||
return renderer, nil
|
||||
}
|
||||
|
||||
func (document *Document) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
for _, block := range document.Blocks {
|
||||
renderer, err := block.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = renderer.Render(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func renderJSON(text string) (string, error) {
|
||||
var document Document
|
||||
err := json.Unmarshal([]byte(text), &document)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = document.Render(&buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (checklist *Checklist) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
buf.WriteString(`<div class="checklist">`)
|
||||
for _, item := range checklist.Items {
|
||||
buf.WriteString(`<div class="checklist--item">`)
|
||||
|
||||
buf.WriteString(`<span class="icon is-medium">`)
|
||||
if item.Checked {
|
||||
buf.WriteString(`<i class="fa fa-check-circle has-text-success"></i>`)
|
||||
} else {
|
||||
buf.WriteString(`<i class="fa fa-circle-thin"></i>`)
|
||||
}
|
||||
buf.WriteString(`</span>`)
|
||||
buf.WriteString(`<div class="checklist--item-text">`)
|
||||
buf.WriteString(item.Text)
|
||||
buf.WriteString("</div>")
|
||||
buf.WriteString("</div>")
|
||||
}
|
||||
buf.WriteString("</div>")
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (code *Code) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("<pre>")
|
||||
buf.WriteString(code.Code)
|
||||
buf.WriteString("</pre>")
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (link *Link) Render(w io.Writer) error {
|
||||
// TODO(peter): improve link rendering
|
||||
_, err := fmt.Fprintf(w, `<a href=%q>%s</a>`, link.Link, link.Meta.Title)
|
||||
return err
|
||||
}
|
||||
|
||||
func (table *Table) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("<table class='table'>")
|
||||
for _, row := range table.Content {
|
||||
buf.WriteString("<tr>")
|
||||
for _, col := range row {
|
||||
buf.WriteString("<td>")
|
||||
buf.WriteString(col)
|
||||
buf.WriteString("</td>")
|
||||
}
|
||||
buf.WriteString("</tr>")
|
||||
}
|
||||
buf.WriteString("</table>")
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
func (list *List) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
var tag string
|
||||
if list.Style == "ordered" {
|
||||
tag = "ol"
|
||||
} else {
|
||||
tag = "ul"
|
||||
}
|
||||
buf.WriteString("<")
|
||||
buf.WriteString(tag)
|
||||
buf.WriteString(">")
|
||||
for _, item := range list.Items {
|
||||
buf.WriteString("<li>")
|
||||
buf.WriteString(item)
|
||||
buf.WriteString("</li>")
|
||||
}
|
||||
buf.WriteString("</")
|
||||
buf.WriteString(tag)
|
||||
buf.WriteString(">")
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (header *Header) Render(w io.Writer) error {
|
||||
_, err := fmt.Fprintf(w, "<h%d>%s</h%d>", header.Level, header.Text, header.Level)
|
||||
return err
|
||||
}
|
||||
|
||||
func (para Paragraph) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("<p>")
|
||||
buf.WriteString(para.Text)
|
||||
buf.WriteString("</p>")
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
87
search.go
87
search.go
|
@ -94,13 +94,40 @@ func (s *searchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
err = saveBlocksFromPage("data", page)
|
||||
if err != nil {
|
||||
log.Printf("error while processing blocks from page %s: %w", page.Name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Reload all pages
|
||||
pages, err = mp.AllPages()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
log.Println("processing ", page.Title)
|
||||
err = processBackrefsForPage(page, refs)
|
||||
if err != nil {
|
||||
log.Println("error while processing backrefs: ", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("saveLinks")
|
||||
err = saveLinks(mp)
|
||||
if err != nil {
|
||||
log.Printf("error while saving links %w", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("saveBackrefs")
|
||||
err = saveBackrefs("data/backrefs.json", refs)
|
||||
if err != nil {
|
||||
log.Printf("error while saving backrefs %w", err)
|
||||
|
@ -115,24 +142,25 @@ func (s *searchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
indexMapping := bleve.NewIndexMapping()
|
||||
index, err := bleve.New("data/_tmp_index", indexMapping)
|
||||
index, err := createSearchIndex("data", "_tmp_index")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
so, err := createSearchObject(page)
|
||||
searchObjects, err := createSearchObjects(page.Name)
|
||||
if err != nil {
|
||||
log.Printf("error while createing search object %s: %w", page.Title, err)
|
||||
log.Printf("error while creating search object %s: %w", page.Title, err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = index.Index(page.Name, so)
|
||||
if err != nil {
|
||||
log.Printf("error while indexing %s: %w", page.Title, err)
|
||||
continue
|
||||
for _, so := range searchObjects {
|
||||
err = index.Index(so.ID, so)
|
||||
if err != nil {
|
||||
log.Printf("error while indexing %s: %w", page.Title, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,6 +198,9 @@ func (s *searchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
q := bleve.NewQueryStringQuery(r.URL.Query().Get("q"))
|
||||
sr := bleve.NewSearchRequest(q)
|
||||
sr.Fields = []string{"page", "title", "text"}
|
||||
sr.Highlight = bleve.NewHighlightWithStyle("html")
|
||||
sr.Highlight.AddField("text")
|
||||
results, err := s.searchIndex.Search(sr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
|
@ -182,7 +213,45 @@ func (s *searchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func createSearchObject(page Page) (searchObject, error) {
|
||||
type pageBlock struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Page string `json:"page"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (p pageBlock) Type() string {
|
||||
return "block"
|
||||
}
|
||||
|
||||
func createSearchObjects(rootBlockID string) ([]pageBlock, error) {
|
||||
blocks, err := loadBlocks("data", rootBlockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pageBlocks []pageBlock
|
||||
|
||||
queue := []string{blocks.PageID}
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
pageBlocks = append(pageBlocks, pageBlock{
|
||||
ID: current,
|
||||
Title: blocks.Texts[blocks.PageID],
|
||||
Page: blocks.PageID,
|
||||
Text: blocks.Texts[current],
|
||||
})
|
||||
|
||||
queue = append(queue, blocks.Children[current]...)
|
||||
}
|
||||
|
||||
return pageBlocks, nil
|
||||
}
|
||||
|
||||
func createStructuredFormat(page Page) (searchObject, error) {
|
||||
so := searchObject{}
|
||||
so.Title = page.Title
|
||||
so.Meta = make(map[string]string)
|
||||
|
|
|
@ -6,6 +6,13 @@
|
|||
{{define "content"}}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<nav class="breadcrumb">
|
||||
<ol>
|
||||
{{ range $p := .Parents }}
|
||||
<li><a href="/edit/{{ $p.ID }}">{{ $p.Text }}</a></li>
|
||||
{{ end }}
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="title">{{ .Title }}</h1>
|
||||
<form action="/save/" method="post">
|
||||
<input type="hidden" name="p" value="{{ .Name }}"/>
|
||||
|
|
|
@ -1 +1,9 @@
|
|||
<div class="wiki-list-editor" data-url="{{ .BaseURL }}{{ .Page }}?format=json" data-input="{{ .Data }}" data-saveurl="/save/" data-base-url="{{ .BaseURL }}" data-page="{{ .Page }}" save-type="{{ .ContentType }}"></div>
|
||||
<div class="wiki-list-editor page-loader"
|
||||
data-base-url="{{ .BaseURL }}"
|
||||
data-edit="true"
|
||||
data-format="json"
|
||||
data-page="{{ .Page }}"
|
||||
data-saveurl="/save/"
|
||||
data-url="{{ .BaseURL }}{{ .Page }}?format=json"
|
||||
save-type="{{ .ContentType }}"
|
||||
></div>
|
||||
|
|
|
@ -12,201 +12,8 @@
|
|||
<title>{{ .Title }} - Wiki</title>
|
||||
{{ block "content_head" . }} {{ end }}
|
||||
<style>
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
#autocomplete {
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#autocomplete li > a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#autocomplete li {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
#autocomplete li.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
#link-complete {
|
||||
z-index: 1;
|
||||
width: 217px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
height: 300px;
|
||||
position: absolute;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#link-complete li {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
#link-complete li.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: "Fira Code Retina", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.content input[type="checkbox"] {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.lighter {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ins {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.checklist--item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.checklist--item-text {
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
@import url('https://rsms.me/inter/inter.css');
|
||||
|
||||
html {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
input.input-line {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
@supports (font-variation-settings: normal) {
|
||||
html {
|
||||
font-family: 'Inter var', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter var', sans-serif;
|
||||
}
|
||||
|
||||
input.input-line {
|
||||
font-family: 'Inter var', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.root .list-item {
|
||||
padding: 3px;
|
||||
padding-left: 12px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
margin-left: 32px;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.list-item .content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: lightblue;
|
||||
}
|
||||
.selected .marker {
|
||||
border-color: lightblue;
|
||||
}
|
||||
#editor {
|
||||
width: 750px;
|
||||
}
|
||||
.editor.selected .marker {
|
||||
/*border-color: white;*/
|
||||
}
|
||||
|
||||
.editor.selected {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
input.input-line, input.input-line:active {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.gu-mirror {
|
||||
position: fixed !important;
|
||||
margin: 0 !important;
|
||||
z-index: 9999 !important;
|
||||
opacity: 0.8;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)";
|
||||
filter: alpha(opacity=80);
|
||||
}
|
||||
|
||||
.gu-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gu-unselectable {
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.gu-transit {
|
||||
opacity: 0.2;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)";
|
||||
filter: alpha(opacity=20);
|
||||
}
|
||||
|
||||
.backrefs {
|
||||
padding: 24px;
|
||||
background: #deeeee;
|
||||
border-top: 3px solid #acc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-base-url="{{ .BaseURL }}">
|
||||
|
@ -272,7 +79,7 @@
|
|||
<div id="result-template" class="hide">
|
||||
<ul>
|
||||
[[#results]]
|
||||
<li><a href="/edit/[[ref]]">[[title]]</a></li>
|
||||
<li><a href="/edit/[[ref]]">[[title]] <div>[[& text]]</div></a></li>
|
||||
[[/results]]
|
||||
[[^results]]
|
||||
<li>No results</li>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{{ define "sidebar" }}
|
||||
<div class="sidebar page-loader content" data-page="Sidebar"></div>
|
||||
<div class="sidebar page-loader content" data-page="Sidebar" data-format="markdown"></div>
|
||||
{{ end }}
|
||||
|
|
Loading…
Reference in New Issue
Block a user