initial commit
This commit is contained in:
commit
e1dc83d5df
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -0,0 +1,10 @@
|
|||
# Changelog
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 0.1.0 - 2022-02-21
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2022 John Bintz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Joplin Multi-Note Viewer
|
||||
|
||||
Use [Joplin's Data API](https://joplinapp.org/api/references/rest_api/) to
|
||||
view and browse multiple notes at once. Supports title and body search and internal
|
||||
links, as well as dragging and dropping notes to rearrange them in your view.
|
||||
|
||||
Visible note IDs are put into the URL as you go, so if you want to return to a
|
||||
particular note set, bookmark the generated URL.
|
||||
|
||||
## Installing
|
||||
|
||||
* Enable the Web Clipper service in Joplin (Tools > Options > Web Clipper > Enable the clipper service).
|
||||
* Download the latest release binary for your platform.
|
||||
* Download the sample `config.yml` file from Releases. Then edit it and
|
||||
fill in the following information:
|
||||
|
||||
```yaml
|
||||
joplin:
|
||||
api_key: <the authorization token from Tools > Options > Web Clipper > Advanced Options>
|
||||
url: http://localhost:41184
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
* Start Joplin.
|
||||
* Start the viewer from the command line.
|
||||
* Visit [http://localhost:8181/](http://localhost:8181).
|
||||
|
||||
## Using
|
||||
|
||||
* The search box can be refocused and brought into view by hitting `s`.
|
||||
* You can use the up and down arrow keys to navigate autocomplete results,
|
||||
and `[Enter]` to open a result.
|
||||
* Open notes can be rearranged by dragging and dropping.
|
||||
* Close a note with the Close button.
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a hobby project for me to learn Vue 3 and Go and to be able to use my
|
||||
interlinked notes better, so if I fix a bug, it'll either be because it's
|
||||
directly affecting me, it's easy to fix, or it's interesting. Pull
|
||||
requests are preferred over issues if you have the skill, but even if you
|
||||
don't, someone else might!
|
||||
|
||||
## Developing
|
||||
|
||||
I built this using Go 1.18 and Node 16.
|
||||
|
||||
* Run `dev-setup.sh`. This will make a build of the Vue app and put its
|
||||
`dist` folder into `server`. This is so `go:embed` works.
|
||||
* Start the server with `go run .`.
|
||||
* Start the client with `npm run dev`. It will proxy API requests to a locally
|
||||
running server so the embedded client files will be overridden by the files
|
||||
Vite produces.
|
||||
|
||||
Add tests if you want. I may get around to it eventually.
|
||||
|
||||
## Building & Reelasing
|
||||
|
||||
* Bump the version. This project uses semantic versioning as best as possible.
|
||||
* Update the changelog with details on the changes. It uses the
|
||||
[Keep a Changelog format](https://raw.githubusercontent.com/olivierlacan/keep-a-changelog/main/CHANGELOG.md).
|
||||
* Run `build.sh`. It will create the different platform binaries in `build` as
|
||||
well as a `config.yml` sample.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
VERSION=$(cat VERSION)
|
||||
|
||||
rm -Rf build
|
||||
mkdir build
|
||||
cd client
|
||||
nvm use
|
||||
npm run build
|
||||
cd ../server
|
||||
rm -Rf dist
|
||||
mv ../client/dist .
|
||||
|
||||
|
||||
GOOS=linux GOARCH=amd64 go build -o ../build/viewer-${VERSION}-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 go build -o ../build/viewer-${VERSION}-linux-arm64 .
|
||||
GOOS=windows GOARCH=amd64 go build -o ../build/viewer-${VERSION}-windows-amd64 .
|
||||
GOOS=windows GOARCH=arm64 go build -o ../build/viewer-${VERSION}-windows-arm64 .
|
||||
GOOS=darwin GOARCH=amd64 go build -o ../build/viewer-${VERSION}-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 go build -o ../build/viewer-${VERSION}-darwin-arm4 .
|
||||
|
||||
cd ../build
|
||||
cp ../config.yml.example ./config.yml
|
||||
|
||||
echo "Done. App is in build/"
|
||||
echo "Have fun."
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
|
@ -0,0 +1 @@
|
|||
16
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Joplin multi-note viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "joplin-multi-note-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oruga-ui/oruga-next": "^0.5.3",
|
||||
"marked": "^4.0.12",
|
||||
"sass": "^1.49.8",
|
||||
"vue": "^3.2.25",
|
||||
"vue-router": "^4.0.12",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^2.2.0",
|
||||
"vite": "^2.8.0"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,194 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
import Note from './components/Note.vue'
|
||||
import NoteSearch from './components/NoteSearch.vue'
|
||||
|
||||
const results = ref([])
|
||||
const notes = ref([])
|
||||
const searchFocus = ref(+new Date)
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
if (route.query.ids) {
|
||||
const ids = route.query.ids.split(',')
|
||||
router.push({query: {ids: ''}})
|
||||
|
||||
const loadNotes = async () => {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await loadNote(ids[i])
|
||||
}
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
}
|
||||
|
||||
watch(notes, (newNotes) => {
|
||||
router.push({
|
||||
query: { ids: newNotes.map(n => n.id).join(',') }
|
||||
})
|
||||
|
||||
window.document.title = newNotes.map(n => n.title).join(', ')
|
||||
})
|
||||
|
||||
async function performSearch(query) {
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/search?q=${encodeURIComponent(query)}`)
|
||||
const responseJSON = await response.json()
|
||||
results.value = responseJSON
|
||||
}
|
||||
|
||||
async function loadNoteData(id) {
|
||||
const response = await fetch(`/api/v1/note/${id}`)
|
||||
const responseJSON = await response.json()
|
||||
return responseJSON
|
||||
}
|
||||
|
||||
async function loadNote(id) {
|
||||
try {
|
||||
const noteData = await loadNoteData(id)
|
||||
|
||||
if (noteData.id === "") {
|
||||
console.error(`no note data for note ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
notes.value = notes.value.concat([noteData])
|
||||
|
||||
results.value = []
|
||||
focusSearch()
|
||||
} catch (e) {
|
||||
console.error(`unable to load note ${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function moveLeft(index) {
|
||||
const newNotes = []
|
||||
for (let i = 0; i < notes.value.length; i++) {
|
||||
if (i === index) {
|
||||
newNotes[i] = notes.value[i - 1]
|
||||
newNotes[i - 1] = notes.value[i]
|
||||
} else {
|
||||
newNotes[i] = notes.value[i]
|
||||
}
|
||||
}
|
||||
|
||||
notes.value = newNotes
|
||||
}
|
||||
|
||||
function moveRight(index) {
|
||||
const newNotes = []
|
||||
for (let i = 0; i < notes.value.length; i++) {
|
||||
if (i === index + 1) {
|
||||
newNotes[i] = notes.value[i - 1]
|
||||
newNotes[i - 1] = notes.value[i]
|
||||
} else {
|
||||
newNotes[i] = notes.value[i]
|
||||
}
|
||||
}
|
||||
|
||||
notes.value = newNotes
|
||||
}
|
||||
|
||||
function closeNote(index) {
|
||||
notes.value = notes.value.filter((_, idx) => idx !== index)
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchFocus.value++
|
||||
}
|
||||
|
||||
async function reloadNote(index) {
|
||||
const id = notes.value[index].id
|
||||
const newNote = await loadNoteData(id)
|
||||
|
||||
notes.value = notes.value.map(n => {
|
||||
if (n.id === id) {
|
||||
return newNote
|
||||
} else {
|
||||
return n
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.matches('input')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.keyCode == 83) { // s
|
||||
e.preventDefault()
|
||||
focusSearch()
|
||||
}
|
||||
})
|
||||
|
||||
setInterval(async () => {
|
||||
for (let i = 0; i < notes.value.length; ++i) {
|
||||
await reloadNote(i)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<draggable
|
||||
v-model="notes"
|
||||
item-key="id"
|
||||
id="notes-container"
|
||||
>
|
||||
<template #item="{element, index}">
|
||||
<Note
|
||||
v-bind="element"
|
||||
:index="index"
|
||||
|
||||
@loadNote="loadNote"
|
||||
@close="closeNote"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NoteSearch
|
||||
@search="performSearch"
|
||||
@select="loadNote"
|
||||
:searchFocus="searchFocus"
|
||||
ref="noteSearch"
|
||||
:results="results"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
html, body, #app {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#notes-container {
|
||||
display: flex;
|
||||
|
||||
height: 100vh;
|
||||
overflow-x: auto;
|
||||
overflow-y: none;
|
||||
|
||||
> div {
|
||||
width: calc(35rem - 4rem);
|
||||
padding: 2rem;
|
||||
height: calc(100vh - 6rem);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
|
@ -0,0 +1,84 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
index: Number,
|
||||
})
|
||||
|
||||
const bodyRef = ref(null)
|
||||
|
||||
const emit = defineEmits(['close', 'loadNote', 'moveLeft', 'moveRight', 'reload'])
|
||||
|
||||
const parsedBody = computed(() => {
|
||||
return marked.parse(props.body)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const handleJoplinInternalLinks = (e) => {
|
||||
// an internal joplin link
|
||||
if (e.target.matches('a[href^=":/"]')) {
|
||||
e.preventDefault()
|
||||
emit('loadNote', e.target.href.replace(/^.*:\//, ''))
|
||||
return
|
||||
}
|
||||
|
||||
if (e.target.matches('a[href]')) {
|
||||
e.preventDefault()
|
||||
window.open(e.target.href, '_blank')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bodyRef.value.addEventListener('click', handleJoplinInternalLinks)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note" draggable>
|
||||
<div class="buttons">
|
||||
<button @click="emit('close', index)">Close</button>
|
||||
</div>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div ref="bodyRef" class="body" v-html="parsedBody"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.note {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
|
||||
padding: 2rem;
|
||||
|
||||
overflow: auto;
|
||||
border: solid #333 1px;
|
||||
|
||||
background-color: #ddd;
|
||||
|
||||
&:hover {
|
||||
background-color: #ffe;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: absolute;
|
||||
right: 2.25rem;
|
||||
top: 3.25rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,102 @@
|
|||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const searchRef = ref(null)
|
||||
const noteSearchRef = ref(null)
|
||||
|
||||
const emit = defineEmits(['search', 'select'])
|
||||
const props = defineProps({
|
||||
results: { type: Array, required: true },
|
||||
searchFocus: { type: Number, required: true }
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const activeKBElement = ref(0)
|
||||
|
||||
watch(search, (nv) => {
|
||||
emit('search', nv)
|
||||
noteSearchRef.value.scrollIntoView({ block: 'end', inline: 'end' })
|
||||
})
|
||||
watch(() => props.searchFocus, () => {
|
||||
noteSearchRef.value.scrollIntoView({ block: 'end', inline: 'end' })
|
||||
searchRef.value.focus()
|
||||
})
|
||||
|
||||
function select(id) {
|
||||
emit('select', id)
|
||||
search.value = ''
|
||||
activeKBElement.value = 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchRef.value.focus()
|
||||
|
||||
searchRef.value.addEventListener('keydown', (e) => {
|
||||
if ([40,38,13].includes(e.keyCode) && props.results.length > 0) {
|
||||
e.preventDefault()
|
||||
switch (e.keyCode) {
|
||||
case 40: //down
|
||||
if (activeKBElement.value < props.results.length) {
|
||||
activeKBElement.value++
|
||||
}
|
||||
break;
|
||||
case 38: //up
|
||||
if (activeKBElement.value > 0) {
|
||||
activeKBElement.value--
|
||||
}
|
||||
break;
|
||||
case 13:
|
||||
select(props.results[activeKBElement.value].id)
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note-search" ref="noteSearchRef">
|
||||
<div class="form-element">
|
||||
<label>Search</label>
|
||||
<input placeholder="Enter 2 or more characters" ref="searchRef" v-model="search" />
|
||||
</div>
|
||||
<div
|
||||
class="result"
|
||||
@click="select(result.id)"
|
||||
:class="{'is-active': index === activeKBElement}"
|
||||
v-for="(result, index) in results"
|
||||
>
|
||||
{{ result.title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-element {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
flex: 0 1 auto;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1 0 auto;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.result {
|
||||
border: solid #222 1px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, &.is-active {
|
||||
color: #00f;
|
||||
background-color: #ddd;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,16 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import Wrapper from './Wrapper.vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// i just want query string parsing
|
||||
{ path: '/', component: App }
|
||||
]
|
||||
})
|
||||
|
||||
createApp(Wrapper)
|
||||
.use(router)
|
||||
.mount('#app')
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/v1': 'http://localhost:8181'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]',
|
||||
manualChunks: () => 'everything.js'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,4 @@
|
|||
joplin:
|
||||
api_key: <web clipper api key>
|
||||
url: http://localhost:41184
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
cd client
|
||||
nvm use
|
||||
npm install
|
||||
npm run build
|
||||
cd ../server
|
||||
cp -Rpv ../client/dist .
|
||||
|
||||
echo "All set up for local dev work!"
|
|
@ -0,0 +1,3 @@
|
|||
/dist
|
||||
/config.yml
|
||||
joplin-multi-note-viewer
|
|
@ -0,0 +1,10 @@
|
|||
module johnbintz.com/joplin-multi-note-viewer
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
|
@ -0,0 +1,11 @@
|
|||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ=
|
||||
github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 h1:X/2sJAybVknnUnV7AD2HdT6rm2p5BP6eH2j+igduWgk=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@ -0,0 +1,138 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type joplinConfig struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
JoplinConfig joplinConfig `yaml:"joplin"`
|
||||
}
|
||||
|
||||
func (c *config) BaseURL() string {
|
||||
return c.JoplinConfig.URL
|
||||
}
|
||||
|
||||
func (c *config) BaseParams() url.Values {
|
||||
values := url.Values{}
|
||||
values.Add("token", c.JoplinConfig.APIKey)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
var appConfig = &config{}
|
||||
|
||||
func readConfig() error {
|
||||
configFile, err := ioutil.ReadFile("config.yml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(configFile, &appConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type searchResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type searchResponse struct {
|
||||
Items []searchResponseItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
func (s *searchResponse) GetFrontendBody() ([]byte, error) {
|
||||
res, err := json.Marshal(s.Items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type noteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func (s *noteResponse) GetFrontendBody() ([]byte, error) {
|
||||
res, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func performSearch(query string) (searchResponse, error) {
|
||||
params := appConfig.BaseParams()
|
||||
params.Add("query", query+"*")
|
||||
params.Add("type", "note")
|
||||
|
||||
response := searchResponse{}
|
||||
|
||||
url := appConfig.BaseURL() + "/search?" + params.Encode()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &response)
|
||||
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func retrieveNote(ID string) (noteResponse, error) {
|
||||
params := appConfig.BaseParams()
|
||||
params.Add("fields", "title,body,id")
|
||||
|
||||
response := noteResponse{}
|
||||
|
||||
url := appConfig.BaseURL() + "/notes/" + ID + "?" + params.Encode()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &response)
|
||||
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var res embed.FS
|
||||
|
||||
func main() {
|
||||
mime.AddExtensionType("js", "application/javascript")
|
||||
mime.AddExtensionType("css", "text/css")
|
||||
|
||||
err := readConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
router.HandleFunc("/api/v1/search", searchHandler)
|
||||
router.HandleFunc("/api/v1/note/{id}", noteHandler)
|
||||
|
||||
fmt.Println("starting public file server")
|
||||
|
||||
strippedFs, err := fs.Sub(res, "dist")
|
||||
if err != nil {
|
||||
log.Fatal("can't load embedded js")
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.FS(strippedFs))
|
||||
|
||||
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".css") {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
} else if strings.HasSuffix(r.URL.Path, ".js") {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
}
|
||||
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
fmt.Println("listening on localhost:8181")
|
||||
http.ListenAndServe("localhost:8181", router)
|
||||
|
||||
err = browser.OpenURL("http://localhost:8181")
|
||||
if err != nil {
|
||||
fmt.Println("Unable to open browser, visit http://localhost:8181")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
response, err := performSearch(query)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Issue querying Joplin! Is it running?"))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := response.GetFrontendBody()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Error serializing JSON!"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func noteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
noteID := mux.Vars(r)["id"]
|
||||
|
||||
response, err := retrieveNote(noteID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Issue querying Joplin! Is it running?"))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := response.GetFrontendBody()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Error serializing JSON!"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Write(body)
|
||||
}
|
Loading…
Reference in New Issue