initial commit
This commit is contained in:
commit
e1dc83d5df
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
10
CHANGELOG
Normal file
10
CHANGELOG
Normal file
@ -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.
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||||
|
|
68
README.md
Normal file
68
README.md
Normal file
@ -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
|
26
build.sh
Executable file
26
build.sh
Executable file
@ -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."
|
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -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?
|
1
client/.nvmrc
Normal file
1
client/.nvmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
16
|
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -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>
|
1502
client/package-lock.json
generated
Normal file
1502
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
client/package.json
Normal file
22
client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
194
client/src/App.vue
Normal file
194
client/src/App.vue
Normal file
@ -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>
|
3
client/src/Wrapper.vue
Normal file
3
client/src/Wrapper.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view></router-view>
|
||||||
|
</template>
|
84
client/src/components/Note.vue
Normal file
84
client/src/components/Note.vue
Normal file
@ -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>
|
102
client/src/components/NoteSearch.vue
Normal file
102
client/src/components/NoteSearch.vue
Normal file
@ -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>
|
16
client/src/main.js
Normal file
16
client/src/main.js
Normal file
@ -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')
|
22
client/vite.config.js
Normal file
22
client/vite.config.js
Normal file
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
4
config.yml.example
Normal file
4
config.yml.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
joplin:
|
||||||
|
api_key: <web clipper api key>
|
||||||
|
url: http://localhost:41184
|
||||||
|
|
10
dev-setup.sh
Normal file
10
dev-setup.sh
Normal file
@ -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!"
|
3
server/.gitignore
vendored
Normal file
3
server/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/dist
|
||||||
|
/config.yml
|
||||||
|
joplin-multi-note-viewer
|
10
server/go.mod
Normal file
10
server/go.mod
Normal file
@ -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
|
||||||
|
)
|
11
server/go.sum
Normal file
11
server/go.sum
Normal file
@ -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=
|
138
server/joplin.go
Normal file
138
server/joplin.go
Normal file
@ -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
|
||||||
|
}
|
58
server/main.go
Normal file
58
server/main.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
49
server/server.go
Normal file
49
server/server.go
Normal file
@ -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
Block a user