1
0
Fork 0
mirror of https://github.com/Luzifer/wiki.git synced 2024-11-08 14:50:11 +00:00

Initial implementation

This commit is contained in:
Knut Ahlers 2019-08-05 00:42:37 +02:00
parent 2d2ec2472b
commit 9ff77362aa
Signed by: luzifer
GPG key ID: DC2729FDD34BE99E
17 changed files with 25886 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
data
src/node_modules
wiki

16426
assets.go Normal file

File diff suppressed because it is too large Load diff

66
frontend/app.js Normal file

File diff suppressed because one or more lines are too long

21
frontend/index.html Normal file
View file

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Wiki</title>
<div id="app"></div>
<!-- Loading prism from external not to pack ALL languages -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-okaidia.min.css">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/line-numbers/prism-line-numbers.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.10.1/css/all.css">
<script src="/app.js"></script>
</html>

15
go.mod Normal file
View file

@ -0,0 +1,15 @@
module github.com/Luzifer/wiki
go 1.12
require (
github.com/Luzifer/go_helpers v2.8.1+incompatible // indirect
github.com/Luzifer/go_helpers/v2 v2.9.1
github.com/Luzifer/rconfig/v2 v2.2.1
github.com/gorilla/mux v1.7.3
github.com/gosimple/slug v1.6.0
github.com/pkg/errors v0.8.1
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/sirupsen/logrus v1.4.2
gopkg.in/yaml.v2 v2.2.2
)

36
go.sum Normal file
View file

@ -0,0 +1,36 @@
github.com/Luzifer/go_helpers v2.8.1+incompatible h1:9YvrAn7pU2viK5vRpAnI+0gyz+Tw8rxWHVIYHi642zk=
github.com/Luzifer/go_helpers v2.8.1+incompatible/go.mod h1:5yUSe0FS7lIx1Uzmt0R3tdPFrSSaPfiCqaIA6u0Zn4Y=
github.com/Luzifer/go_helpers/v2 v2.9.1 h1:MVUOlD6tJ2m/iTF0hllnI/QVZH5kI+TikUm1WRGg/c4=
github.com/Luzifer/go_helpers/v2 v2.9.1/go.mod h1:ZnWxPjyCdQ4rZP3kNiMSUW/7FigU1X9Rz8XopdJ5ZCU=
github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg=
github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gosimple/slug v1.6.0 h1:jB/X2muqD2+ABdGF0YLJukfS1ppeTFfLxU757UE6K7c=
github.com/gosimple/slug v1.6.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc=
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

149
main.go Normal file
View file

@ -0,0 +1,149 @@
package main
//go:generate go-bindata -pkg $GOPACKAGE -o assets.go -modtime 1 -md5checksum ./frontend/...
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"strings"
"github.com/gorilla/mux"
"github.com/gosimple/slug"
log "github.com/sirupsen/logrus"
httpHelper "github.com/Luzifer/go_helpers/v2/http"
"github.com/Luzifer/rconfig/v2"
)
var (
cfg = struct {
DataDir string `flag:"data-dir" default:"./data/" description:"Directory to store data to"`
EnableGit bool `flag:"enable-git" default:"false" description:"Enable git management of the data dir"`
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
version = "dev"
)
func init() {
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
log.Fatalf("Unable to parse commandline options: %s", err)
}
if cfg.VersionAndExit {
fmt.Printf("wiki %s\n", version)
os.Exit(0)
}
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
log.WithError(err).Fatal("Unable to parse log level")
} else {
log.SetLevel(l)
}
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/_content/{page}", handlePageRead).Methods(http.MethodGet)
r.HandleFunc("/_content/{page}", handlePageWrite).Methods(http.MethodPost)
r.NotFoundHandler = http.HandlerFunc(handleIndexPage)
var handler http.Handler = r
handler = httpHelper.GzipHandler(handler)
handler = httpHelper.NewHTTPLogHandler(handler)
http.ListenAndServe(cfg.Listen, handler)
}
func handleIndexPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/app.js" {
r.URL.Path = "/index.html"
}
var (
filename = path.Join("frontend", r.URL.Path)
src io.Reader
)
if _, err := os.Stat(filename); err == nil {
f, err := os.Open(filename)
if err != nil {
log.WithError(err).Error("Unable to open base asset")
}
defer f.Close()
src = f
} else if asset, err := Asset(filename); err == nil {
src = bytes.NewReader(asset)
} else {
log.WithField("asset", filename).Error("Asset not found in frontend dir or bundled assets")
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(filename)))
io.Copy(w, src)
}
func handlePageRead(w http.ResponseWriter, r *http.Request) {
var vars = mux.Vars(r)
file, err := loadStoredFile(sanitizeFilename(vars["page"]))
switch err {
case nil:
// All okay, render follows
case errFileNotFound:
http.Error(w, "Page not yet exists", http.StatusNotFound)
return
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(file); err != nil {
log.WithError(err).Error("Unable to marshal file for JSON")
}
}
func handlePageWrite(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
file = &storedFile{}
)
if err := json.NewDecoder(r.Body).Decode(file); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := file.Save(sanitizeFilename(vars["page"])); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func sanitizeFilename(page string) string {
return path.Join(
cfg.DataDir,
strings.Join([]string{slug.Make(page), "md"}, "."),
)
}

88
src/.eslintrc.js Normal file
View file

@ -0,0 +1,88 @@
// https://eslint.org/docs/user-guide/configuring
module.exports = {
'root': true,
'parserOptions': {
parser: 'babel-eslint',
sourceType: 'module',
},
'env': {
node: true,
},
'extends': [
/*
* https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
* consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
*/
'plugin:vue/recommended',
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
'eslint:recommended',
],
// required to lint *.vue files
'plugins': ['vue'],
'globals': {
process: true,
Prism: true,
},
// add your custom rules here
'rules': {
'array-bracket-newline': ['error', { multiline: true }],
'array-bracket-spacing': ['error'],
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': ['error', { before: true, after: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'comma-dangle': ['error', 'always-multiline'], // Apply Contentflow rules
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
'curly': ['error'],
'dot-location': ['error', 'property'],
'dot-notation': ['error'],
'eol-last': ['error', 'always'],
'eqeqeq': ['error', 'always', { 'null': 'ignore' }],
'func-call-spacing': ['error', 'never'],
'function-paren-newline': ['error', 'multiline'],
'generator-star-spacing': ['off'], // allow async-await
'implicit-arrow-linebreak': ['error'],
'indent': ['error', 2],
'key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['warn'],
'newline-per-chained-call': ['error'],
'no-console': ['off'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
'no-else-return': ['error'],
'no-extra-parens': ['error'],
'no-implicit-coercion': ['error'],
'no-lonely-if': ['error'],
'no-multiple-empty-lines': ['warn', { max: 2, maxEOF: 0, maxBOF: 0 }],
'no-multi-spaces': ['error'],
'no-trailing-spaces': ['error'],
'no-unneeded-ternary': ['error'],
'no-useless-return': ['error'],
'no-whitespace-before-property': ['error'],
'object-curly-newline': ['error', { consistent: true }],
'object-curly-spacing': ['error', 'always'],
'object-shorthand': ['error'],
'padded-blocks': ['error', 'never'],
'prefer-arrow-callback': ['error'],
'prefer-const': ['error'],
'prefer-object-spread': ['error'],
'prefer-template': ['error'],
'quote-props': ['error', 'consistent-as-needed', { keywords: true }],
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
'semi': ['error', 'never'],
'space-before-blocks': ['error', 'always'],
'spaced-comment': ['warn', 'always'],
'space-infix-ops': ['error'],
'space-in-parens': ['error', 'never'],
'space-unary-ops': ['error', { words: true, nonwords: false }],
'switch-colon-spacing': ['error'],
'unicode-bom': ['error', 'never'],
'wrap-iife': ['error'],
'yoda': ['error'],
},
}

24
src/app.vue Normal file
View file

@ -0,0 +1,24 @@
<template>
<div>
<b-navbar
type="dark"
variant="primary"
class="mb-4"
>
<b-navbar-brand
href="/"
@click.prevent="$router.push({ name: 'home' })"
>
Wiki
</b-navbar-brand>
</b-navbar>
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
}
</script>

89
src/edit.vue Normal file
View file

@ -0,0 +1,89 @@
<template>
<b-container>
<b-row>
<b-col>
<textarea ref="editor" />
<b-btn
variant="primary"
@click="save"
>
Save
</b-btn>
</b-col>
</b-row>
</b-container>
</template>
<script>
import axios from 'axios'
import EasyMDE from 'easymde'
export default {
name: 'Edit',
data() {
return {
editor: null,
}
},
watch: {
'$route'(to, from) {
if (to.params.page === from.params.page) {
return
}
this.loadPage(to.params.page)
},
},
mounted() {
if (!this.editor) {
this.editor = new EasyMDE({
element: this.$refs.editor,
forceSync: true,
indentWithTabs: false,
})
window.editor = this.editor
}
this.loadPage(this.$route.params.page)
},
methods: {
loadPage(pageName) {
console.debug(`Loading ${pageName}...`)
axios.get(`/_content/${pageName}`)
.then(resp => {
this.editor.codemirror.setValue(resp.data.content)
})
.catch(err => {
if (err.response && err.response.status === 404) {
return
}
console.error(err)
// FIXME: Show error
})
},
save() {
axios.post(`/_content/${this.$route.params.page}`, {
content: this.$refs.editor.value,
})
.then(() => {
this.$router.push({ name: 'view', params: { page: this.$route.params.page } })
})
.catch(err => {
console.error(err)
})
},
},
}
</script>
<style>
.editor-toolbar {
background-color: #ccc;
}
</style>

47
src/main.js Normal file
View file

@ -0,0 +1,47 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import 'bootswatch/dist/darkly/bootstrap.css'
import 'easymde/dist/easymde.min.css'
import App from './app.vue'
import View from './view.vue'
import Edit from './edit.vue'
Vue.use(BootstrapVue)
Vue.use(VueRouter)
const routes = [
{
component: View,
name: 'view',
path: '/:page',
},
{
component: Edit,
name: 'edit',
path: '/:page/edit',
},
{
name: 'home',
path: '/',
redirect: '/Home',
},
]
const router = new VueRouter({
mode: 'history',
routes,
})
new Vue({
el: '#app',
components: { App },
data: { },
render: createElement => createElement('app'),
router,
})

8560
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
src/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"devDependencies": {
"babel-core": "^6.26.3",
"babel-eslint": "^10.0.1",
"babel-loader": "^7.1.5",
"babel-preset-env": "^1.7.0",
"css-loader": "^1.0.0",
"easymde": "^2.7.0",
"eslint": "^5.13.0",
"eslint-config-standard": "^12.0.0",
"eslint-friendly-formatter": "^4.0.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^8.0.1",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.3",
"node-sass": "^4.9.2",
"sass-loader": "^7.0.3",
"style-loader": "^0.21.0",
"vue-loader": "^15.7.0",
"vue-markdown": "^2.2.4",
"vue-router": "^3.0.7",
"vue-template-compiler": "^2.6.10",
"webpack": "^4.16.2",
"webpack-cli": "^3.1.0",
"webpack-dev-middleware": "^1.4.0",
"webpack-hot-middleware": "^2.9.1"
},
"name": "ots",
"private": true,
"scripts": {
"build": "webpack -p"
},
"dependencies": {
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"bootstrap-vue": "^2.0.0-rc.19",
"bootswatch": "^4.3.1",
"popper.js": "^1.15.0",
"vue": "^2.6.10"
}
}

113
src/view.vue Normal file
View file

@ -0,0 +1,113 @@
<template>
<b-container>
<b-row>
<b-col
ref="content"
class="relAnchor"
>
<b-btn
class="editBtn"
variant="secondary"
size="sm"
:to="{ name: 'edit', params: { page: $route.params.page } }"
>
<i class="fas fa-edit" />
</b-btn>
<vue-markdown
:source="content"
:prerender="prerender"
@rendered="rendered"
/>
</b-col>
</b-row>
</b-container>
</template>
<script>
import axios from 'axios'
import VueMarkdown from 'vue-markdown'
export default {
name: 'View',
components: {
VueMarkdown,
},
data() {
return {
content: '',
}
},
watch: {
'$route'(to, from) {
if (to.params.page === from.params.page) {
return
}
this.loadPage(to.params.page)
},
},
mounted() {
this.loadPage(this.$route.params.page)
},
methods: {
intLinkClick(evt) {
const link = evt.target
this.$router.push({ name: 'view', params: { page: link.dataset.page } })
return false
},
loadPage(pageName) {
console.debug(`Loading ${pageName}...`)
axios.get(`/_content/${pageName}`)
.then(resp => {
this.content = resp.data.content
})
.catch(err => {
if (err.response && err.response.status === 404) {
this.$router.push({ name: 'edit', params: { page: pageName } })
return
}
console.error(err)
// FIXME: Show error
})
},
prerender(mdtext) {
// replace [[Internal]] links
mdtext = mdtext.replace(/\[\[([^\]]+)\]\]/, '<a class="intLink" data-page="$1" href="$1">$1</a>')
return mdtext
},
rendered() {
// Give the DOM a moment to update before manipulating further
window.setTimeout(() => {
// Add listeners to internal links
const links = this.$refs.content.getElementsByClassName('intLink')
for (const link of links) {
link.onclick = this.intLinkClick
}
// Highlight code blocks
Prism.highlightAll()
}, 100)
},
},
}
</script>
<style>
.editBtn {
position: absolute;
right: 5px;
top: 5px;
}
.relAnchor {
position: relative;
}
</style>

54
src/webpack.config.js Normal file
View file

@ -0,0 +1,54 @@
const path = require('path')
const webpack = require('webpack')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
entry: './main.js',
output: {
filename: 'app.js',
path: path.resolve(__dirname, '..', 'frontend'),
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
},
}),
new VueLoaderPlugin(),
],
optimization: {
minimize: true,
},
module: {
rules: [
{
test: /\.(s?)css$/,
use: [
'style-loader',
'css-loader',
'sass-loader',
],
},
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: [['env', { targets: { browsers: ['>0.25%', 'not ie 11', 'not op_mini all'] } }]],
},
},
},
{
test: /\.vue$/,
loader: 'vue-loader',
},
],
},
}

99
storage.go Normal file
View file

@ -0,0 +1,99 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
const yamlDelimiter = `---`
var errFileNotFound = errors.New("Specified file was not found")
type storedFile struct {
Meta map[string]interface{} `json:"meta"`
Content string `json:"content"`
}
func loadStoredFile(filename string) (*storedFile, error) {
if _, err := os.Stat(filename); err != nil {
return nil, errFileNotFound
}
content, err := ioutil.ReadFile(filename)
if err != nil {
return nil, errors.Wrap(err, "Unable to read file")
}
return storedFileFromString(string(content))
}
func storedFileFromString(content string) (*storedFile, error) {
// Look at first line and see whether this file has a metadata part
lines := strings.Split(strings.TrimSpace(content), "\n")
if len(lines) == 0 {
// Empty file
return &storedFile{}, nil
}
var (
metadata []string
contentStart int
)
if lines[0] == yamlDelimiter {
// This file has a metadata part
for i := 1; i < len(lines); i++ {
if lines[i] == yamlDelimiter {
contentStart = i + 1
break
}
metadata = append(metadata, lines[i])
}
}
file := &storedFile{
Content: strings.TrimSpace(strings.Join(lines[contentStart:], "\n")),
Meta: map[string]interface{}{},
}
if len(metadata) > 0 {
if err := yaml.NewDecoder(strings.NewReader(strings.Join(metadata, "\n"))).Decode(&file.Meta); err != nil {
return nil, errors.Wrap(err, "Unable to parse metadata part")
}
}
return file, nil
}
func (s storedFile) GetMetaString(key string) string {
if v, ok := s.Meta[key].(string); ok {
return v
}
return ""
}
func (s storedFile) Save(filename string) error {
f, err := os.Create(filename)
if err != nil {
return errors.Wrap(err, "Unable to create file")
}
defer f.Close()
if len(s.Meta) > 0 {
fmt.Fprintln(f, yamlDelimiter)
if err := yaml.NewEncoder(f).Encode(s.Meta); err != nil {
return errors.Wrap(err, "Unable to write metadata")
}
fmt.Fprintln(f, yamlDelimiter)
}
fmt.Fprintln(f, s.Content)
return nil
}

53
storage_test.go Normal file
View file

@ -0,0 +1,53 @@
package main
import "testing"
func TestStoredFileParse(t *testing.T) {
var (
err error
file string
sFile *storedFile
)
// Case: Proper file with header
file = `
---
key: value
---
# Header
content
`
sFile, err = storedFileFromString(file)
if err != nil {
t.Fatalf("Parsing of proper file errored: %s", err)
}
if sFile.Content != "# Header\n\ncontent" {
t.Errorf("Content did not match expectation: %q", sFile.Content)
}
if len(sFile.Meta) != 1 || sFile.GetMetaString("key") != "value" {
t.Errorf("Metadata did not match expectation: %#v", sFile.Meta)
}
// Case: No header
file = "# Header\n\ncontent"
sFile, err = storedFileFromString(file)
if err != nil {
t.Fatalf("Parsing of proper file errored: %s", err)
}
if sFile.Content != "# Header\n\ncontent" {
t.Errorf("Content did not match expectation: %q", sFile.Content)
}
if len(sFile.Meta) != 0 {
t.Errorf("Metadata did not match expectation: %#v", sFile.Meta)
}
}