initial commit

This commit is contained in:
John Bintz 2022-02-21 13:04:18 -05:00
commit e1dc83d5df
26 changed files with 2393 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

10
CHANGELOG Normal file
View 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
View 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
View 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

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

26
build.sh Executable file
View 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
View 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
View File

@ -0,0 +1 @@
16

13
client/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

22
client/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

194
client/src/App.vue Normal file
View 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
View File

@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View 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>

View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
joplin:
api_key: <web clipper api key>
url: http://localhost:41184

10
dev-setup.sh Normal file
View 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
View File

@ -0,0 +1,3 @@
/dist
/config.yml
joplin-multi-note-viewer

10
server/go.mod Normal file
View 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
View 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
View 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
View 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
View 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)
}