Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f38444707 | ||
|
|
2ce1fc073d | ||
|
|
42287395fe | ||
|
|
98261207d6 | ||
|
|
590ae01055 | ||
|
|
2d5d23b104 | ||
|
|
5bebe072b2 | ||
|
|
9f37d3faf1 | ||
|
|
68f71c9d6c | ||
|
|
0b77b0516d |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
echo '-------'
|
||||
echo 'Starting Database Restore'
|
||||
echo '-------'
|
||||
|
||||
#get Latest database backup
|
||||
|
||||
# Unzip File
|
||||
# gzip -dk file.gz
|
||||
|
||||
source ~/.env
|
||||
|
||||
echo "$PROD_DB_PASS"
|
||||
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
|
||||
mkdir -p $BACKUPDIR
|
||||
cd $BACKUPDIR
|
||||
|
||||
# -t sort by modification time, newest first
|
||||
# -A --almost-all, do not list implied . and ..
|
||||
LASTZIPPEDFILE=$(ls -At *.gz | head -n1)
|
||||
|
||||
# -k keep file after unzip
|
||||
# -d Decompress
|
||||
# -v verbose
|
||||
echo "Unzipping $LASTZIPPEDFILE"
|
||||
gunzip -dkv $LASTZIPPEDFILE
|
||||
|
||||
BACKUPFILE=$(ls -At *.sql | head -n1)
|
||||
|
||||
#Fix to replace incompatible DB type
|
||||
echo "Updating table name in -> $BACKUPFILE"
|
||||
#sed -i $BACKUPFILE -e 's/utf8mb4_0900_ai_ci/utf8mb4_unicode_ci/g'
|
||||
|
||||
#Fix encoding for dev DB and exclude system tables
|
||||
# sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_general_ci/g' $BACKUPFILE
|
||||
# sed -r '/INSERT INTO `(sys|mysql)`/d' $BACKUPFILE > $BACKUPFILE
|
||||
|
||||
#echo "Removing and syncing static files"
|
||||
#rm -r /home/mab/ss/staticFiles/*
|
||||
#rsync -e 'ssh -p 13328' -hazC --update [email protected]:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
|
||||
echo "Updating Database"
|
||||
mysql -u root --password="$PROD_DB_PASS" < $BACKUPFILE
|
||||
|
||||
## Optimize Database Tables
|
||||
# mysqlcheck --all-databases
|
||||
mysqlcheck --all-databases -o -u root --password="$PROD_DB_PASS" #--silent
|
||||
# mysqlcheck --all-databases --auto-repair
|
||||
# mysqlcheck --all-databases --analyze
|
||||
|
||||
# Fix an issues with DB after messing around with it
|
||||
mysql_upgrade -u root --password="$PROD_DB_PASS"
|
||||
|
||||
#clean up extracted and modified SQL dumps
|
||||
rm *.sql
|
||||
|
||||
|
||||
|
||||
echo '-------'
|
||||
echo "Applied Prod database to Dev. LastFile: $BACKUPFILE"
|
||||
echo '-------'
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo '-------'
|
||||
echo 'Starting Database Restore'
|
||||
echo '-------'
|
||||
@@ -12,7 +10,7 @@ echo '-------'
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
#DEVDBPASS="Crama!Lama*Jamma###88383!!!!!345345956245i"
|
||||
#DEVDBPASS="RootPass1234!"
|
||||
DEVDBPASS="ReallySecureRootPass123!"
|
||||
DEVDBPASS="LetsKeepOut69FatHorses!"
|
||||
# LazaLinga&33Can't!Do!That34
|
||||
|
||||
cd $BACKUPDIR
|
||||
@@ -39,14 +37,14 @@ sed -r '/INSERT INTO `(sys|mysql)`/d' $BACKUPFILE > $BACKUPFILE
|
||||
|
||||
echo "Removing and syncing static files"
|
||||
rm -r /home/mab/ss/staticFiles/*
|
||||
rsync -e 'ssh -p 13328' -hazC --update mab@solidscribe.com:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
rsync -e 'ssh -p 13328' -hazC --update mab@45.79.41.75:/home/mab/pi/staticFiles /home/mab/ss/
|
||||
|
||||
echo "Updating Database"
|
||||
mysql -u root --password="$DEVDBPASS" < $BACKUPFILE
|
||||
|
||||
## Optimize Database Tables
|
||||
# mysqlcheck --all-databases
|
||||
mysqlcheck --all-databases -o -u root --password="$DEVDBPASS" --silent
|
||||
mysqlcheck --all-databases -o -u root --password="$DEVDBPASS" #--silent
|
||||
# mysqlcheck --all-databases --auto-repair
|
||||
# mysqlcheck --all-databases --analyze
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Take all variables in .env and turn them into local variables for this script
|
||||
source ~/.env
|
||||
source /home/mab/.env
|
||||
|
||||
BACKUPDIR="/home/mab/databaseBackupSolidScribe"
|
||||
# BACKUPDIR defined in .env
|
||||
|
||||
mkdir -p $BACKUPDIR
|
||||
cd $BACKUPDIR
|
||||
|
||||
NOW=$(date +"%Y-%m-%d_%H-%M")
|
||||
ssh [email protected] -p 13328 "mysqldump --all-databases --single-transaction --user root -p$PROD_DB_PASS" > "backup-$NOW.sql"
|
||||
mysqldump --all-databases --single-transaction --user root --password="$PROD_DB_PASS" > "backup-$NOW.sql"
|
||||
gzip "backup-$NOW.sql"
|
||||
|
||||
# cp "backup-$NOW.sql" "/mnt/Windows Data/DatabaseBackups/backup-$NOW.sql"
|
||||
@@ -17,7 +17,7 @@ gzip "backup-$NOW.sql"
|
||||
echo "Database Backup Complete on $NOW"
|
||||
|
||||
# Delete all but last 8 files
|
||||
ls -tp | grep -v '/$' | tail -n +9 | tr '\n' '\0' | xargs -0 rm --
|
||||
ls -tp | grep -v '/$' | tail -n +9 | tr '\n' '\0' | xargs -0 rm -f --
|
||||
|
||||
##
|
||||
# Restore DB
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
#
|
||||
# Push built release files to production server
|
||||
#
|
||||
|
||||
echo -e "\e[32m\nStarting Build. \n\e[0m"
|
||||
|
||||
# Build out new release
|
||||
cd client
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# only compress client/dist and server with node_modules
|
||||
echo -e "\e[32m\nCompressing client and server code... \n\e[0m"
|
||||
tar -czf release.tar.gz server node_modules client/dist staticFiles/assets
|
||||
|
||||
#send compressed release to remote machine
|
||||
echo -e "\e[32m\nMoving compressed release to production... \n\e[0m"
|
||||
rsync -e 'ssh -p 13328' -havzC --update release.tar.gz [email protected]:/home/mab/pi/
|
||||
|
||||
# Remove Release from local after its been uploaded
|
||||
rm release.tar.gz
|
||||
|
||||
#uncompress release on server
|
||||
echo -e "\e[32m\nExtracting release on production... \n\e[0m"
|
||||
ssh [email protected] -p 13328 "cd /home/mab/pi/; rm -r server node_modules client; tar -xzf *.tar.gz --overwrite; rm *.tar.gz; pm2 reload all"
|
||||
|
||||
#Congratulate how awesome you are
|
||||
echo -e "\e[32m\nRelease Complete! Nice Work! \n\e[0m"
|
||||
@@ -1,26 +1,28 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# Log files
|
||||
# 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
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local dev TLS certs (mkcert)
|
||||
*.pem
|
||||
certs/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
# client
|
||||
# Vue 3 + Vite
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Encrypted notes and scratch pad" />
|
||||
|
||||
<!-- PWA. The manifest is a real static file: it used to be injected at
|
||||
runtime as a base64 data: URI from App.vue, with the production origin
|
||||
hardcoded, which broke icons and scope outside production. -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/icons/logo.svg" />
|
||||
|
||||
<!-- iOS has no share-target support, but still installs to the home screen.
|
||||
The apple-touch icon must be the opaque variant -- iOS composites
|
||||
transparent PNGs onto black and the dark half of the mark vanishes. -->
|
||||
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-180.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Solid Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "solidscribe",
|
||||
"version": "0.1.0",
|
||||
"name": "solidscribe-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"axios": "^1.1.3",
|
||||
"core-js": "^3.6.5",
|
||||
"es6-promise": "^4.2.8",
|
||||
"chart.js": "^4.6.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fomantic-ui-css": "^2.9.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-chartjs": "^5.0.1",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuedraggable": "^2.24.3",
|
||||
"vuex": "^3.4.0"
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-router": "^4.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-plugin-vuex": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 132.29166 132.29167" width="512" height="512">
|
||||
<rect x="0" y="0" width="132.29166" height="132.29167" fill="#ffffff"/>
|
||||
<g transform="translate(13.229166,13.229167) scale(0.8)">
|
||||
<g transform="translate(0,-164.70832)">
|
||||
<path id="path3813-4" d="m 56.22733,165.36641 -55.56249926,15.875 8e-7,63.5 47.62499846,11.90625 v 27.78125 l -47.76066333,-13.9757 0.13566407,10.00695 55.56249926,15.875 v -47.625 l -47.6249985,-11.90625 -8e-7,-47.625 47.7606633,-13.94121 c 0.135664,-2.30629 -0.135664,-9.87129 -0.135664,-9.87129 z" style="fill:#0f7425;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
<path id="path4563" d="m 20.508581,220.92891 c 15.265814,-14.23899 27.809717,-7.68002 39.687499,3.96875 v -7.9375 C 51.75093,200.8366 37.512584,206.01499 20.508581,205.05391 Z" style="fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path4563-6" d="m 111.78985,220.92891 c -15.265834,-14.23899 -27.809737,-7.68002 -39.68752,3.96875 v -7.9375 c 8.445151,-16.12356 22.683497,-10.94517 39.68752,-11.90625 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path3813-4-2" d="m 76.07108,165.36641 55.5625,15.875 v 63.5 l -47.625,11.90625 v 27.78125 l 47.76067,-13.9757 -0.13567,10.00695 -55.5625,15.875 v -47.625 l 47.625,-11.90626 V 189.17891 L 75.93542,175.2377 c -0.13567,-2.30629 0.13566,-9.87129 0.13566,-9.87129 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.52916676;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 132.29166 132.29167" width="512" height="512">
|
||||
<g transform="translate(0,-164.70832)">
|
||||
<path id="path3813-4" d="m 56.22733,165.36641 -55.56249926,15.875 8e-7,63.5 47.62499846,11.90625 v 27.78125 l -47.76066333,-13.9757 0.13566407,10.00695 55.56249926,15.875 v -47.625 l -47.6249985,-11.90625 -8e-7,-47.625 47.7606633,-13.94121 c 0.135664,-2.30629 -0.135664,-9.87129 -0.135664,-9.87129 z" style="fill:#0f7425;fill-opacity:1;stroke:none;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
<path id="path4563" d="m 20.508581,220.92891 c 15.265814,-14.23899 27.809717,-7.68002 39.687499,3.96875 v -7.9375 C 51.75093,200.8366 37.512584,206.01499 20.508581,205.05391 Z" style="fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path4563-6" d="m 111.78985,220.92891 c -15.265834,-14.23899 -27.809737,-7.68002 -39.68752,3.96875 v -7.9375 c 8.445151,-16.12356 22.683497,-10.94517 39.68752,-11.90625 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/>
|
||||
<path id="path3813-4-2" d="m 76.07108,165.36641 55.5625,15.875 v 63.5 l -47.625,11.90625 v 27.78125 l 47.76067,-13.9757 -0.13567,10.00695 -55.5625,15.875 v -47.625 l 47.625,-11.90626 V 189.17891 L 75.93542,175.2377 c -0.13567,-2.30629 0.13566,-9.87129 0.13566,-9.87129 z" style="display:inline;fill:#04cb03;fill-opacity:1;stroke:none;stroke-width:0.52916676;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -1,69 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<link rel="icon" href="/api/static/assets/favicon.ico" type="image/ico"/>
|
||||
<link rel="shortcut icon" href="/api/static/assets/favicon.ico" type="image/x-icon"/>
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
<link rel="manifest" href="/api/static/assets/manifest.json">
|
||||
|
||||
<title>Solid Scribe - An easy, encrypted Note App</title>
|
||||
<!-- <title><%= htmlWebpackPlugin.options.title %></title> -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but Solid Scribe doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app">
|
||||
<!-- placeholder data for scrapers with no JS -->
|
||||
<style>
|
||||
body {
|
||||
background-color: #212221;
|
||||
color: #aeaeae;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
.centered {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.logo {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
}
|
||||
.scrape-info {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="centered">
|
||||
<img class="logo" src="/api/static/assets/logo.svg" alt="Solid Scribe Logo - if you can read this your connection is really slow">
|
||||
<h1>Solid Scribe</h1>
|
||||
<h3>An easy, encrypted Note App</h3>
|
||||
<h4>Loading...</h4>
|
||||
</div>
|
||||
|
||||
<div class="scrape-info">
|
||||
<h1>Solid Scribe</h1>
|
||||
<h2>A note application that respects your privacy.</h2>
|
||||
<p>Take notes with a clean editor that works on desktop or mobile.</p>
|
||||
<p>Search notes, links and files to find what you need.</p>
|
||||
<p>Accessable everywhere.</p>
|
||||
<p>Categorize notes with tags.</p>
|
||||
<p>Share data with fellow users.</p>
|
||||
<p>Encrypt notes for additional security.</p>
|
||||
<b>This site requires Javascipt to run.</b>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Solid Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Encrypted notes and scratch pad",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Scratch Pad",
|
||||
"short_name": "Scratch",
|
||||
"url": "/quick",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Files & Links",
|
||||
"short_name": "Files",
|
||||
"url": "/attachments",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="app" :class="{ 'night-mode':($store.getters.getIsNightMode == 2) }">
|
||||
|
||||
<div class="ui container" v-if="showFakeSite">
|
||||
<!-- <div class="ui container" v-if="showFakeSite">
|
||||
<div class="ui basic very padded segment">
|
||||
<div class="ui inverted red segment">
|
||||
<h1>WARNING - False site detected</h1>
|
||||
@@ -11,7 +11,7 @@
|
||||
<h2>You will be redirected to the correct domain in {{redirectSeconds}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="auth-block" v-if="requireAuth">
|
||||
<div class="ui raised inverted segment">
|
||||
@@ -51,19 +51,24 @@
|
||||
|
||||
<global-notification />
|
||||
|
||||
<update-prompt />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
// import io from 'socket.io-client'
|
||||
import axios from 'axios'
|
||||
import GlobalSiteMenu from '@/components/GlobalSiteMenu.vue'
|
||||
import GlobalNotification from '@/components/GlobalNotificationComponent.vue'
|
||||
import UpdatePrompt from '@/components/UpdatePrompt.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
'global-site-menu': require('@/components/GlobalSiteMenu.vue').default,
|
||||
'global-notification':require('@/components/GlobalNotificationComponent.vue').default,
|
||||
'global-site-menu': GlobalSiteMenu,
|
||||
'global-notification': GlobalNotification,
|
||||
'update-prompt': UpdatePrompt,
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -172,16 +177,18 @@ export default {
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
const isDev = process.env['NODE_ENV'] == 'development'
|
||||
if(window.location.hostname.toLowerCase().replace('www.','') != "solidscribe.com" && !isDev){
|
||||
this.showFakeSite = true
|
||||
setInterval(() => {
|
||||
this.redirectSeconds--
|
||||
if(this.redirectSeconds == 0){
|
||||
window.location.href = 'https://www.solidscribe.com'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
// Vite does not shim node's `process` into the browser bundle the way
|
||||
// webpack/vue-cli did; import.meta.env.DEV is the equivalent.
|
||||
const isDev = import.meta.env.DEV
|
||||
// if(window.location.hostname.toLowerCase().replace('www.','') != "solidscribe.com" && !isDev){
|
||||
// this.showFakeSite = true
|
||||
// setInterval(() => {
|
||||
// this.redirectSeconds--
|
||||
// if(this.redirectSeconds == 0){
|
||||
// window.location.href = 'https://www.solidscribe.com'
|
||||
// }
|
||||
// }, 1000)
|
||||
// }
|
||||
|
||||
//Update totals for entire app on event
|
||||
this.$io.on('update_counts', () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Vue from 'vue'
|
||||
const EventBus = new Vue()
|
||||
import mitt from 'mitt'
|
||||
|
||||
Object.defineProperties(Vue.prototype, {
|
||||
$bus: {
|
||||
get: () => EventBus
|
||||
const emitter = mitt()
|
||||
|
||||
// Preserves the Vue 2 $on/$emit/$off API used throughout the codebase
|
||||
const EventBus = {
|
||||
$on: (...args) => emitter.on(...args),
|
||||
$off: (...args) => emitter.off(...args),
|
||||
$emit: (...args) => emitter.emit(...args),
|
||||
}
|
||||
})
|
||||
|
||||
export default EventBus;
|
||||
export default EventBus
|
||||
@@ -1,5 +1,3 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
const helpers = {}
|
||||
|
||||
helpers.timeAgo = (time) => {
|
||||
@@ -62,10 +60,9 @@ helpers.timeAgo = (time) => {
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
install () {
|
||||
Vue.helpers = helpers
|
||||
Vue.prototype.$helpers = helpers
|
||||
install(app) {
|
||||
app.config.globalProperties.$helpers = helpers
|
||||
}
|
||||
}
|
||||
|
||||
Vue.use(plugin)
|
||||
export default plugin
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Launch page preference.
|
||||
//
|
||||
// The app used to inject its manifest at runtime with
|
||||
// `start_url: window.location.href`, so installing from /metrictrack made the
|
||||
// installed app always open there. That mattered: not everyone wants the notes
|
||||
// page as their home.
|
||||
//
|
||||
// A static manifest can't carry a per-install start_url, and we need a static
|
||||
// same-origin manifest for share_target to register at all (a data: URI
|
||||
// manifest has an opaque origin, so scope resolution fails). So the preference
|
||||
// moves in-app: the manifest's start_url is "/", and we redirect from there.
|
||||
//
|
||||
// This is also strictly better than the old behaviour -- the old one froze the
|
||||
// home page at install time and could only be changed by reinstalling. This one
|
||||
// is editable in Settings.
|
||||
//
|
||||
|
||||
const LAUNCH_KEY = 'launchPage'
|
||||
const DEFAULT_LAUNCH = '/notes'
|
||||
|
||||
// Pages that make sense as a home. Anything else (a specific note, the share
|
||||
// target, login) would be a bad place to land every time.
|
||||
export const LAUNCH_OPTIONS = [
|
||||
{ path: '/notes', label: 'Notes' },
|
||||
{ path: '/overview', label: 'Overview' },
|
||||
{ path: '/quick', label: 'Scratch Pad' },
|
||||
{ path: '/metrictrack', label: 'Metric Tracking' },
|
||||
{ path: '/attachments', label: 'Files & Links' },
|
||||
]
|
||||
|
||||
export function getLaunchPage(){
|
||||
const stored = localStorage.getItem(LAUNCH_KEY)
|
||||
const valid = LAUNCH_OPTIONS.some(option => option.path === stored)
|
||||
return valid ? stored : DEFAULT_LAUNCH
|
||||
}
|
||||
|
||||
export function setLaunchPage(path){
|
||||
if(!LAUNCH_OPTIONS.some(option => option.path === path)){ return }
|
||||
localStorage.setItem(LAUNCH_KEY, path)
|
||||
}
|
||||
|
||||
export function isStandalone(){
|
||||
return window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true
|
||||
}
|
||||
|
||||
//
|
||||
// Capture the page the user installed from, matching the old behaviour: if they
|
||||
// install while on /metrictrack, that becomes their launch page. Only set as a
|
||||
// default -- an explicit choice in Settings is never overwritten.
|
||||
//
|
||||
export function watchForInstall(){
|
||||
window.addEventListener('appinstalled', () => {
|
||||
if(localStorage.getItem(LAUNCH_KEY)){ return }
|
||||
|
||||
const path = window.location.pathname
|
||||
if(LAUNCH_OPTIONS.some(option => option.path === path)){
|
||||
localStorage.setItem(LAUNCH_KEY, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// "/" is a shim for installed launches. Redirect only when running standalone,
|
||||
// so the marketing home page still works normally in a browser tab.
|
||||
//
|
||||
export function installLaunchRedirect(router){
|
||||
|
||||
watchForInstall()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
||||
// Only the very first navigation of a standalone launch
|
||||
if(to.path === '/' && from.matched.length === 0 && isStandalone()){
|
||||
return next(getLaunchPage())
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -1001,3 +1001,8 @@ padding-right: 10px;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
||||
i.outline {
|
||||
outline-style: none;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -18,12 +18,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
name: 'FileUploadButton',
|
||||
props: [ 'noteId' ],
|
||||
components: {
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -369,14 +369,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'search-input': require('@/components/SearchInput.vue').default,
|
||||
'counter':require('@/components/AnimatedCounterComponent.vue').default,
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
||||
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -393,7 +394,7 @@
|
||||
beforeMount(){
|
||||
window.addEventListener('resize', this.resizeEventHandler)
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
window.removeEventListener('resize', this.resizeEventHandler)
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
@@ -153,8 +153,10 @@
|
||||
this.$io.emit('user_connect', token)
|
||||
localStorage.setItem('loginToken', token)
|
||||
|
||||
//Redirect user to notes section after login
|
||||
this.$router.push('/notes')
|
||||
//Redirect user to notes section after login, or back to
|
||||
//wherever they were headed (e.g. an Android share waiting to
|
||||
//be saved -- see ShareTargetPage.vue)
|
||||
this.$router.push(this.$route.query.redirect || '/notes')
|
||||
}
|
||||
},
|
||||
register(){
|
||||
|
||||
@@ -12,18 +12,6 @@
|
||||
width="500">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
style="display:inline"
|
||||
transform="translate(0,-164.70832)"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="line-graph-holder">
|
||||
<Line v-if="hasData" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="no-data">No data for the selected range.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||
|
||||
// Categorical slots, validated for both of this app's surfaces (#fff / #000)
|
||||
// with scripts/validate_palette.js. Assigned by a field's stable position,
|
||||
// never by its position in a particular graph, so toggling fields on and off
|
||||
// never repaints the ones that stay.
|
||||
const SERIES = {
|
||||
light: ['#2a78d6','#eb6834','#1baf7a','#eda100','#e87ba4','#008300','#4a3aa7','#e34948'],
|
||||
dark: ['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'],
|
||||
}
|
||||
|
||||
// A field can carry an explicit Fomantic colour name chosen by the user; that
|
||||
// is their intent and it matches the field's icon elsewhere on the page, so it
|
||||
// wins over the generated slot.
|
||||
const FOMANTIC = {
|
||||
red:'#DB2828', orange:'#F2711C', yellow:'#FBBD08', olive:'#B5CC18',
|
||||
green:'#21BA45', teal:'#00B5AD', blue:'#2185D0', violet:'#6435C9',
|
||||
purple:'#A333C8', pink:'#E03997', brown:'#A5673F', grey:'#767676',
|
||||
black:'#1B1C1D',
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MetricLineGraph',
|
||||
inject: ['getFieldColor'],
|
||||
components: { Line },
|
||||
props: {
|
||||
graph: { type: Object, required: true }, // { fieldIds: [...] }
|
||||
userFields: { type: Object, default: () => ({}) },
|
||||
cycleData: { type: Object, default: () => ({}) },
|
||||
tempChartDays: { type: [Number, String], default: 30 },
|
||||
},
|
||||
computed: {
|
||||
isDark(){
|
||||
return Number(this.$store.getters.getIsNightMode) === 1
|
||||
},
|
||||
surface(){
|
||||
return this.isDark ? '#000000' : '#ffffff'
|
||||
},
|
||||
textColor(){
|
||||
// Text wears text tokens, never the series colour.
|
||||
return this.isDark ? '#d9d9d9' : '#3d3d3d'
|
||||
},
|
||||
gridColor(){
|
||||
// One step off the surface, hairline and recessive.
|
||||
return this.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'
|
||||
},
|
||||
fieldIds(){
|
||||
return this.graph?.fieldIds || []
|
||||
},
|
||||
// Every date present in the data, oldest first, trimmed to the
|
||||
// requested window. Sorting by real date rather than object key
|
||||
// order means out-of-order writes cannot scramble the line.
|
||||
dateCodes(){
|
||||
const codes = Object.keys(this.cycleData)
|
||||
.map(code => ({ code, time: this.codeToDate(code) }))
|
||||
.filter(entry => !isNaN(entry.time))
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.map(entry => entry.code)
|
||||
|
||||
const span = parseInt(this.tempChartDays)
|
||||
if(!span || span < 1 || span >= codes.length){ return codes }
|
||||
return codes.slice(-span)
|
||||
},
|
||||
hasData(){
|
||||
return this.fieldIds.length > 0 && this.dateCodes.length > 0
|
||||
},
|
||||
chartData(){
|
||||
return {
|
||||
labels: this.dateCodes.map(code => this.formatLabel(code)),
|
||||
datasets: this.fieldIds.map(fieldId => {
|
||||
const colour = this.colourFor(fieldId)
|
||||
return {
|
||||
label: this.userFields[fieldId]?.label || fieldId,
|
||||
data: this.dateCodes.map(code => this.valueAt(code, fieldId)),
|
||||
borderColor: colour,
|
||||
backgroundColor: colour,
|
||||
borderWidth: 2,
|
||||
tension: 0.25,
|
||||
// A marker that is drawn is >= 8px with a 2px ring in
|
||||
// the surface colour; dense series only show them on
|
||||
// hover so the line stays readable.
|
||||
pointRadius: this.denseData ? 0 : 4,
|
||||
pointHoverRadius: 5,
|
||||
pointBorderColor: this.surface,
|
||||
pointBorderWidth: 2,
|
||||
pointHoverBorderColor: this.surface,
|
||||
pointHoverBorderWidth: 2,
|
||||
pointHitRadius: 12,
|
||||
spanGaps: false, // a missing day is a gap, not a zero
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
denseData(){
|
||||
return this.dateCodes.length > 40
|
||||
},
|
||||
chartOptions(){
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 200 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: this.textColor, maxRotation: 0, autoSkipPadding: 16 },
|
||||
border: { color: this.gridColor },
|
||||
},
|
||||
y: {
|
||||
grid: { color: this.gridColor, lineWidth: 1, drawTicks: false },
|
||||
ticks: { color: this.textColor, padding: 8 },
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
// Always present: identity is never carried by colour alone,
|
||||
// and it is the relief for the low-contrast light slots.
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: this.textColor,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 14,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: this.isDark ? '#1b1b1b' : '#ffffff',
|
||||
titleColor: this.textColor,
|
||||
bodyColor: this.textColor,
|
||||
borderColor: this.gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const v = ctx.parsed.y
|
||||
return `${ctx.dataset.label}: ${v === null ? '—' : v}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored as D.M.YYYY
|
||||
codeToDate(code){
|
||||
const [d, m, y] = String(code).split('.').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1).getTime()
|
||||
},
|
||||
formatLabel(code){
|
||||
const date = new Date(this.codeToDate(code))
|
||||
if(isNaN(date.getTime())){ return code }
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
},
|
||||
valueAt(code, fieldId){
|
||||
const raw = this.cycleData[code]?.[fieldId]
|
||||
if(raw === undefined || raw === null || raw === ''){ return null }
|
||||
const num = Number(raw)
|
||||
// Non-numeric field types cannot be plotted on a value axis; a gap
|
||||
// is honest, where the old code substituted -1 and bent the scale.
|
||||
return isNaN(num) ? null : num
|
||||
},
|
||||
colourFor(fieldId){
|
||||
const named = this.getFieldColor ? this.getFieldColor(fieldId) : null
|
||||
if(named && FOMANTIC[named]){ return FOMANTIC[named] }
|
||||
|
||||
// Stable slot: keyed off the field's position in userFields, so the
|
||||
// colour follows the field rather than its rank in this graph.
|
||||
const all = Object.keys(this.userFields)
|
||||
const index = all.indexOf(fieldId)
|
||||
const slots = this.isDark ? SERIES.dark : SERIES.light
|
||||
return slots[(index < 0 ? 0 : index) % slots.length]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-graph-holder {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.no-data {
|
||||
padding: 2em 0;
|
||||
text-align: center;
|
||||
color: var(--menu-text, #767676);
|
||||
}
|
||||
</style>
|
||||
@@ -43,12 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -64,7 +64,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment animate-height`">
|
||||
<div class="ui basic center aligned segment" v-if="!graphs || graphs.length == 0">
|
||||
<h4 class="ui header">No graphs yet</h4>
|
||||
<p>Choose <b>Add/Edit Graphs</b>, then <b>New Graph</b>, and pick which metrics to plot.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(graph, index) in graphs" :class="`ui not-padded ${editGraphs?'active ':'inactive '}segment`">
|
||||
|
||||
<!-- Edit options -->
|
||||
<div class="ui small header" v-if="editGraphs">
|
||||
@@ -126,15 +131,20 @@
|
||||
<span v-if="!editGraphs">Click "Edit Graphs" then,</span>
|
||||
Select Graph type and Metrics to display
|
||||
</div>
|
||||
<div v-if="graph?.type == undefined && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<div :id="`graphdiv${index}`" style="width: 100%; min-height: 320px;"></div>
|
||||
<div v-if="isLineGraph(graph) && graph.fieldIds && graph.fieldIds.length > 0">
|
||||
<LineGraph
|
||||
:graph="graph"
|
||||
:userFields="userFields"
|
||||
:cycleData="cycleData"
|
||||
:tempChartDays="tempChartDays"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ui segment" v-if="editGraphs">
|
||||
|
||||
<!-- change graph type -->
|
||||
<div v-for="(graphType, graphId) in graphTypesDef" class="ui buttons">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':(String(graphId) == String(graph?.type))}">
|
||||
<div class="ui tiny button" v-on:click="changeGraphType(index, graphId)" :class="{'green':isGraphTypeActive(graphId, graph)}">
|
||||
{{ graphType }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +156,7 @@
|
||||
<span v-else v-on:click="toggleGraphField(fieldId, index)">
|
||||
<i class="square outline icon"></i>
|
||||
</span>
|
||||
<i :class="`${$parent.getFieldColor(fieldId)} ${$parent.getFieldIcon(fieldId)} icon`"></i>
|
||||
<i :class="`${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i>
|
||||
<b>{{ userFields[fieldId]?.label }}</b>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,12 +170,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sixteen wide column">
|
||||
<dix class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui basic segment" v-if="!editGraphs">
|
||||
<div class="ui button" v-on:click="toggleEditGraphs">
|
||||
<i class="edit icon"></i>
|
||||
<span>Add/Edit Graphs</span>
|
||||
</div>
|
||||
</dix>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="editGraphs">
|
||||
@@ -189,11 +199,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const PILL_CALENDAR = 'pillCalendar'
|
||||
const LAST_DONE = 'lastDone'
|
||||
|
||||
export default {
|
||||
inject: ['getFieldColor', 'getFieldIcon'],
|
||||
name: 'MetricTrackingGraphs',
|
||||
props: [
|
||||
'tempChartDays', // Number of days to display
|
||||
@@ -205,7 +217,8 @@
|
||||
'editGraphs' // boolean for edit or not edit graphs
|
||||
],
|
||||
components: {
|
||||
'PillCalendarGraph':require('@/components/Metrictracking/PillCalendarGraph.vue').default,
|
||||
'PillCalendarGraph':defineAsyncComponent(() => import('@/components/Metrictracking/PillCalendarGraph.vue')),
|
||||
'LineGraph':defineAsyncComponent(() => import('@/components/Metrictracking/LineGraph.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -221,33 +234,9 @@
|
||||
// Constants
|
||||
this.PILL_CALENDAR = PILL_CALENDAR
|
||||
this.LAST_DONE = LAST_DONE
|
||||
|
||||
// Include JS libraries
|
||||
let graphsScript = document.createElement('script')
|
||||
graphsScript.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.js')
|
||||
document.head.appendChild(graphsScript)
|
||||
},
|
||||
mounted(){
|
||||
this.localGraphData = this.graphs
|
||||
|
||||
this.graphCurrentData()
|
||||
},
|
||||
updated(){
|
||||
// update graphs here? Or watch graphs prop
|
||||
},
|
||||
watch: {
|
||||
// whenever question changes, this function will run
|
||||
userFields(newFields, oldFields) {
|
||||
// console.log([newFields, oldFields])
|
||||
if( JSON.stringify(oldFields) == "{}" ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
tempChartDays(newDays, oldDays){
|
||||
if( newDays != oldDays ){
|
||||
this.graphCurrentData()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveGraphs(){
|
||||
@@ -267,10 +256,35 @@
|
||||
this.$emit('toggleEditGraphs')
|
||||
},
|
||||
changeGraphType(index, newType){
|
||||
console.log(index + ' change to ' + newType)
|
||||
this.localGraphData[index]['type'] = newType
|
||||
|
||||
// graphTypesDef is an object, so its keys are strings and the
|
||||
// default line graph arrives here as the STRING 'undefined'.
|
||||
// Saved graphs represent the line graph as an absent type, so
|
||||
// remove the key rather than storing a string that no render
|
||||
// test can match.
|
||||
if(this.isLineGraphType(newType)){
|
||||
delete this.localGraphData[index].type
|
||||
}
|
||||
else {
|
||||
this.localGraphData[index].type = newType
|
||||
}
|
||||
|
||||
this.saveGraphs()
|
||||
},
|
||||
// A line graph is the default: an absent type. Accept the string
|
||||
// form too so graphs saved by the older code still render.
|
||||
isLineGraphType(type){
|
||||
return type === undefined || type === null || type === '' || type === 'undefined'
|
||||
},
|
||||
isLineGraph(graph){
|
||||
return this.isLineGraphType(graph?.type)
|
||||
},
|
||||
isGraphTypeActive(graphId, graph){
|
||||
if(this.isLineGraphType(graphId)){
|
||||
return this.isLineGraph(graph)
|
||||
}
|
||||
return String(graphId) === String(graph?.type)
|
||||
},
|
||||
addGraph(){
|
||||
this.localGraphData.push({})
|
||||
this.saveGraphs()
|
||||
@@ -329,103 +343,6 @@
|
||||
|
||||
return title
|
||||
},
|
||||
graphCurrentData(){
|
||||
|
||||
// try again if dygraphs isn't loaded
|
||||
if( typeof(window.Dygraph) != 'function' ){
|
||||
setTimeout(() => {
|
||||
this.graphCurrentData()
|
||||
}, 100)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const graphOptions = {
|
||||
interactionModel: {},
|
||||
// pointClickCallback: function(e, pt){
|
||||
// console.log(e)
|
||||
// console.log(pt)
|
||||
// console.log(this.getValue(pt.idx, 0))
|
||||
// }
|
||||
}
|
||||
|
||||
// Excel date format YYYYMMDD
|
||||
const convertToExcelDate = (dateCode) => {
|
||||
return dateCode
|
||||
.split('.')
|
||||
.reverse()
|
||||
.map(item => String(item).padStart(2,0))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Generate set of keys for graph length
|
||||
let dataKeys = Object.keys(this.cycleData)
|
||||
dataKeys = dataKeys.splice(0, this.tempChartDays)
|
||||
console.log(dataKeys)
|
||||
|
||||
|
||||
// build CSV data for each graph
|
||||
this.graphs.forEach((graph,index) => {
|
||||
|
||||
// only chart line graphs with dygraphs
|
||||
if( graph.type != undefined ){
|
||||
return
|
||||
}
|
||||
if( !graph.fieldIds ){
|
||||
return
|
||||
}
|
||||
|
||||
// CSV or path to a CSV file.
|
||||
let dataString = ""
|
||||
|
||||
// Lookup graph field titles
|
||||
let graphLabels = ['Date']
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
const graphLabel = this.userFields[fieldId]?.label
|
||||
const escapedLabel = graphLabel.replaceAll(',','')
|
||||
graphLabels.push(escapedLabel)
|
||||
})
|
||||
dataString += graphLabels.join(',') + '\n'
|
||||
|
||||
|
||||
// build each row, for each day
|
||||
for (var i = 0; i < dataKeys.length; i++) {
|
||||
|
||||
let nextFragment = []
|
||||
// push date code to first column
|
||||
nextFragment.push(convertToExcelDate(dataKeys[i]))
|
||||
|
||||
graph.fieldIds.forEach(fieldId => {
|
||||
|
||||
const currentEntry = this.cycleData[dataKeys[i]]
|
||||
let currentValue = currentEntry[fieldId]
|
||||
|
||||
// setup correct float graphing
|
||||
if(fieldId == 'BT'){
|
||||
// parse temp to fixed length float 00.00
|
||||
currentValue = parseFloat(currentValue).toFixed(2)
|
||||
}
|
||||
|
||||
if( currentValue == undefined ){
|
||||
currentValue = -1
|
||||
}
|
||||
|
||||
nextFragment.push(currentValue)
|
||||
|
||||
})
|
||||
|
||||
dataString += nextFragment.join(',') + "\n"
|
||||
}
|
||||
|
||||
|
||||
let graphDiv = document.getElementById("graphdiv"+index)
|
||||
const g = new Dygraph(graphDiv, dataString ,graphOptions)
|
||||
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +81,13 @@
|
||||
|
||||
}
|
||||
|
||||
.days-since {
|
||||
padding: 0.75em 0.5em 0.25em 0.5em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.days-since-row {
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
.pill-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -229,36 +236,47 @@
|
||||
|
||||
|
||||
|
||||
<span class="pill-container" v-for="(entry, dateCode) in getChartData" v-if="dateCode == `${day}.${calendar.month}.${calendar.year}`">
|
||||
<template v-for="(entry, dateCode) in getChartData" :key="dateCode">
|
||||
<span class="pill-container" v-if="dateCode == `${day}.${calendar.month}.${calendar.year}`">
|
||||
<template v-for="(dayData, fieldId) in entry" :key="fieldId">
|
||||
<span
|
||||
v-for="(dayData, fieldId) in entry"
|
||||
v-if="showZeroValuesCheck(dayData.value, fieldId)"
|
||||
class="pill"
|
||||
:class="[$parent.$parent.getFieldColor(fieldId), {
|
||||
:class="[getFieldColor(fieldId), {
|
||||
'did-next':dayData.didNext,
|
||||
'did-last':dayData.didLast,
|
||||
'last-high':dayData.lastHigh,
|
||||
'next-high':dayData.nextHigh,
|
||||
}]">
|
||||
<!-- 'zero-day':dayData.value == lowestGraphValue, -->
|
||||
<!-- <i v-if="dayData.value != 0" :class="`tiny ${$parent.$parent.getFieldColor(fieldId)} ${$parent.$parent.getFieldIcon(fieldId)} icon`"></i> -->
|
||||
<!-- <i v-if="dayData.value != 0" :class="`tiny ${getFieldColor(fieldId)} ${getFieldIcon(fieldId)} icon`"></i> -->
|
||||
<!-- <span v-else class="icon-spacer"></span>
|
||||
:style="{height:(Math.round(dayData.value*5)+'px')}"
|
||||
|
||||
-->
|
||||
<span v-if="dayData.value > lowestGraphValue-1" class="big-day">
|
||||
<i v-if="!hideIcons" :class="`tiny white ${$parent.$parent.getFieldIcon(fieldId)} icon`"></i>
|
||||
<i v-if="!hideIcons" :class="`tiny white ${getFieldIcon(fieldId)} icon`"></i>
|
||||
<span v-if="!hideValues">
|
||||
{{ getDayValue(fieldId, dayData.value) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<!-- <span v-for="fieldId in graph.fieldIds"></span> -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- time since each metric was last recorded -->
|
||||
<div class="days-since" v-if="daysSince.length > 0">
|
||||
<div class="days-since-row" v-for="entry in daysSince" :key="entry.fieldId">
|
||||
<i :class="`${entry.color} ${entry.icon} icon`"></i>
|
||||
<span v-if="entry.days === null">Days Since {{ entry.label }} - no entries yet</span>
|
||||
<span v-else>Days Since {{ entry.label }} - {{ entry.days }} {{ entry.days === 1 ? 'day' : 'days' }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -269,6 +287,7 @@
|
||||
// let chartData = {}
|
||||
|
||||
export default {
|
||||
inject: ['getFieldColor', 'getFieldIcon', 'getFieldLabel'],
|
||||
props: [
|
||||
'graph', // options associated with this graph
|
||||
'userFields', // all field attributes
|
||||
@@ -309,6 +328,53 @@
|
||||
this.setupCalendar(new Date())
|
||||
},
|
||||
computed: {
|
||||
// "Days Since <metric> - <n> days" for every field on this graph.
|
||||
// Counts a day as recorded only if it passes the same
|
||||
// showZeroValuesCheck the pills use, so the "Show/Hide Lowest Value"
|
||||
// toggle moves these numbers too.
|
||||
daysSince(){
|
||||
|
||||
// showZeroValuesCheck reads lowestGraphValue, which getChartData
|
||||
// assigns as a side effect -- touch it so the threshold is current
|
||||
// and so this recomputes whenever the chart data does.
|
||||
void this.getChartData
|
||||
|
||||
const dayMs = 86400000
|
||||
const midnight = (time) => {
|
||||
const date = new Date(time)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
const today = midnight(Date.now())
|
||||
|
||||
return (this.graph?.fieldIds || []).map(fieldId => {
|
||||
|
||||
let latest = null
|
||||
|
||||
Object.keys(this.cycleData || {}).forEach(dateCode => {
|
||||
|
||||
const value = this.cycleData[dateCode]?.[fieldId]
|
||||
if(value === undefined || value === null || value === ''){ return }
|
||||
|
||||
// honour the Lowest Value toggle
|
||||
if(!this.showZeroValuesCheck(value, fieldId)){ return }
|
||||
|
||||
const time = this.dateCodeToTime(dateCode)
|
||||
// ignore unparseable codes and anything dated ahead of today
|
||||
if(time === null || time > today){ return }
|
||||
|
||||
if(latest === null || time > latest){ latest = time }
|
||||
})
|
||||
|
||||
return {
|
||||
fieldId,
|
||||
label: this.getFieldLabel(fieldId),
|
||||
icon: this.getFieldIcon(fieldId),
|
||||
color: this.getFieldColor(fieldId),
|
||||
days: latest === null ? null : Math.round((today - latest) / dayMs),
|
||||
}
|
||||
})
|
||||
},
|
||||
getChartData(){
|
||||
|
||||
let chartData = {}
|
||||
@@ -389,11 +455,26 @@
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Date codes are stored unpadded as D.M.YYYY
|
||||
dateCodeToTime(dateCode){
|
||||
|
||||
const parts = String(dateCode).split('.').map(Number)
|
||||
if(parts.length !== 3 || parts.some(isNaN)){ return null }
|
||||
|
||||
const [day, month, year] = parts
|
||||
const date = new Date(year, month - 1, day)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
return isNaN(date.getTime()) ? null : date.getTime()
|
||||
},
|
||||
showZeroValuesCheck(dayValue, fieldId){
|
||||
|
||||
// Optional chaining: a graph can reference a default field (BT,
|
||||
// CM, ...) that was never customised, so it has no userFields
|
||||
// entry and the bare lookup threw.
|
||||
// if graph type is boolean or there are two options
|
||||
let isBooleanField = this.userFields[fieldId].type == 'boolean'
|
||||
if(this.userFields[fieldId].customOptions){
|
||||
let isBooleanField = this.userFields[fieldId]?.type == 'boolean'
|
||||
if(this.userFields[fieldId]?.customOptions){
|
||||
let options = this.userFields[fieldId].customOptions
|
||||
|
||||
isBooleanField = options.split(',').length == 2
|
||||
@@ -411,6 +492,13 @@
|
||||
}
|
||||
|
||||
|
||||
// lowestGraphValue comes from Math.min of the charted values, so
|
||||
// it is Infinity when the window holds no data -- without the
|
||||
// finite guard nothing would ever clear the threshold.
|
||||
if(!isFinite(this.lowestGraphValue)){
|
||||
return true
|
||||
}
|
||||
|
||||
return this.showZeroValues || dayValue > this.lowestGraphValue
|
||||
},
|
||||
getDayValue(fieldId, value){
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="menu-bottom-half" :class="{ 'hide-text':(openNotes > 1) }">
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/table`)" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('table')" data-tooltip="Insert Table" data-position="bottom center">
|
||||
<i class="border all icon"></i>
|
||||
</div> -->
|
||||
|
||||
@@ -87,13 +87,13 @@
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<!--
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/colors`)" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('colors')" data-tooltip="Note Color" data-position="top center" :style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText']}">
|
||||
<i class="paint brush icon"></i>
|
||||
</div> -->
|
||||
<!-- <div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)" data-tooltip="Tags" data-position="top center">
|
||||
<!-- <div class="edit-button" v-on:click="openMenuRoute('tags')" data-tooltip="Tags" data-position="top center">
|
||||
<i class="tags icon"></i>
|
||||
</div> -->
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/images`)" data-tooltip="Images" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('images')" data-tooltip="Images" data-position="top center">
|
||||
<i class="image icon"></i>
|
||||
<span>Images</span>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="edit-divide"></div>
|
||||
|
||||
<div class="edit-button" v-on:click="$router.push(`/notes/open/${noteid}/menu/options`)" data-tooltip="More Options" data-position="top center">
|
||||
<div class="edit-button" v-on:click="openMenuRoute('options')" data-tooltip="More Options" data-position="top center">
|
||||
|
||||
<i class="ellipsis horizontal icon"></i>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
|
||||
<!-- tags on the side, only show on desktop -->
|
||||
<div class="note-mini-tag-area"
|
||||
v-on:click="$router.push(`/notes/open/${noteid}/menu/tags`)"
|
||||
v-on:click="openMenuRoute('tags')"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }">
|
||||
|
||||
<span class="add-mini-tag" v-if="allTags.length == 0">
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
<!-- Squire Box -->
|
||||
<div
|
||||
id="squire-id"
|
||||
:id="`squire-id-${noteid}`"
|
||||
class="squire-box"
|
||||
ref="squirebox"
|
||||
:style="{ 'background-color':styleObject['noteBackground'], 'color':styleObject['noteText'] }"
|
||||
@@ -359,33 +359,32 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const Squire = require('../assets/squire.js')
|
||||
|
||||
import '../assets/squire.js' // side-effect: sets window.Squire
|
||||
const Squire = window.Squire
|
||||
import axios from 'axios'
|
||||
// const crypto = require('crypto')
|
||||
const DiffMatchPatch = require('../../../server/helpers/DiffMatchPatch')
|
||||
const dmp = new DiffMatchPatch.diff_match_patch()
|
||||
import DiffMatchPatch from 'diff-match-patch'
|
||||
const dmp = new DiffMatchPatch()
|
||||
|
||||
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
|
||||
|
||||
let rawNoteText = '' // Used for comparing and generating diffs
|
||||
|
||||
export default {
|
||||
name: 'NoteInputPanel',
|
||||
props: [ 'noteid', 'position', 'openMenu', 'urlData', 'openNotes'],
|
||||
components:{
|
||||
'note-tag-edit': () => import('@/components/NoteTagEdit.vue'),
|
||||
'color-picker': () => import('@/components/ColorPicker.vue'),
|
||||
'file-upload-button': () => import('@/components/FileUploadButton.vue'),
|
||||
// 'delete-button': () => import('@/components/NoteDeleteButtonComponent.vue'),
|
||||
'side-slide-menu': () => import('@/components/SideSlideMenuComponent.vue'),
|
||||
'simple-attachment-note': () => import('@/components/SimpleAttachmentNoteComponent.vue'),
|
||||
'share-note-component': () => import('@/components/ShareNoteComponent.vue'),
|
||||
'note-tag-edit': defineAsyncComponent(() => import('@/components/NoteTagEdit.vue')),
|
||||
'color-picker': defineAsyncComponent(() => import('@/components/ColorPicker.vue')),
|
||||
'file-upload-button': defineAsyncComponent(() => import('@/components/FileUploadButton.vue')),
|
||||
// 'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
|
||||
'side-slide-menu': defineAsyncComponent(() => import('@/components/SideSlideMenuComponent.vue')),
|
||||
'simple-attachment-note': defineAsyncComponent(() => import('@/components/SimpleAttachmentNoteComponent.vue')),
|
||||
'share-note-component': defineAsyncComponent(() => import('@/components/ShareNoteComponent.vue')),
|
||||
|
||||
'color-tooltip':require('@/components/TextColorTooltipComponent.vue').default,
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default,
|
||||
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
|
||||
'color-tooltip':defineAsyncComponent(() => import('@/components/TextColorTooltipComponent.vue')),
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue')),
|
||||
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
},
|
||||
mixins:[ SquireButtonFunctions ],
|
||||
data(){
|
||||
@@ -419,6 +418,9 @@
|
||||
|
||||
//All the squire settings
|
||||
editor: null,
|
||||
// Baseline for diff generation. Per-instance: as a module-scoped
|
||||
// variable this was shared by every open note's panel.
|
||||
rawNoteText: '',
|
||||
usersOnNote: 0,
|
||||
|
||||
sideMenuOpen: false,
|
||||
@@ -475,25 +477,17 @@
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$bus.$on('new_file_upload', ({noteId, imageCode}) => {
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
})
|
||||
|
||||
this.$bus.$on('close_note_by_id', (noteId) => {
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
})
|
||||
// Named handlers so beforeUnmount can remove only THIS panel's.
|
||||
// mitt's off(type) with no handler removes every panel's.
|
||||
this.$bus.$on('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$on('close_note_by_id', this.onCloseNoteById)
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
this.$io.emit('leave_room', this.rawTextId)
|
||||
this.$io.emit('leave_room', { noteId: this.noteid })
|
||||
|
||||
this.$bus.$off('new_file_upload')
|
||||
this.$bus.$off('new_file_upload', this.onFileUpload)
|
||||
this.$bus.$off('close_note_by_id', this.onCloseNoteById)
|
||||
|
||||
this.destroyAttachmentStyles()
|
||||
|
||||
@@ -533,13 +527,13 @@
|
||||
},
|
||||
methods: {
|
||||
testModify(){
|
||||
const text = document.getElementById('squire-id').children[0].innerHTML
|
||||
const text = this.$refs.squirebox.children[0].innerHTML
|
||||
const prepended = 'Hello-> ' + text
|
||||
document.getElementById('squire-id').children[0].innerHTML = prepended
|
||||
this.$refs.squirebox.children[0].innerHTML = prepended
|
||||
|
||||
const text1 = document.getElementById('squire-id').children[2].innerHTML
|
||||
const text1 = this.$refs.squirebox.children[2].innerHTML
|
||||
const prepended1 = 'Hey-> ' + text1
|
||||
document.getElementById('squire-id').children[2].innerHTML = prepended1
|
||||
this.$refs.squirebox.children[2].innerHTML = prepended1
|
||||
},
|
||||
simulateTyping(index = 0){
|
||||
|
||||
@@ -593,7 +587,7 @@
|
||||
this.setText(this.noteText)
|
||||
|
||||
// Use squire box HTML for diff/patch changes
|
||||
rawNoteText = document.getElementById('squire-id').innerHTML
|
||||
this.rawNoteText = this.$refs.squirebox.innerHTML
|
||||
|
||||
//focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
|
||||
if(!this.$store.getters.getIsUserOnMobile){
|
||||
@@ -867,6 +861,8 @@
|
||||
},
|
||||
//Called on squire event for keyup
|
||||
diffText(event){
|
||||
console.log('Skipping diff text function')
|
||||
return
|
||||
// console.log(event.type)
|
||||
|
||||
const diffEvents = ['keyup','pathChange', 'click']
|
||||
@@ -880,17 +876,17 @@
|
||||
this.diffTextTimeout = setTimeout(() => {
|
||||
|
||||
// Current Editor Text
|
||||
const liveEditorElm = document.getElementById('squire-id')
|
||||
const liveEditorElm = this.$refs.squirebox
|
||||
|
||||
// virtual element for selecting div
|
||||
let virtualEditorElm = document.createElement('div')
|
||||
virtualEditorElm.innerHTML = rawNoteText
|
||||
virtualEditorElm.innerHTML = this.rawNoteText
|
||||
|
||||
// element at cursor
|
||||
const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
// Remove beginngin selector from path, make it more generic
|
||||
const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','')
|
||||
const path = this.generateSelector(elmAtCaret).replace(`div#squire-id-${this.noteid} > `,'')
|
||||
let workingPath = ''
|
||||
|
||||
// default to entire note text, select down if path
|
||||
@@ -938,10 +934,10 @@
|
||||
const patch_text = dmp.patch_toText(patch_list)
|
||||
|
||||
// save raw text for future diffs
|
||||
rawNoteText = liveEditorElm.innerHTML
|
||||
this.rawNoteText = liveEditorElm.innerHTML
|
||||
|
||||
let newPatch = {
|
||||
id: this.rawTextId,
|
||||
noteId: this.noteid,
|
||||
diff: patch_text,
|
||||
path: path,
|
||||
// testing metrics
|
||||
@@ -961,7 +957,10 @@
|
||||
// console.log('incoming patches ', incomingPatchs)
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
|
||||
// Socket-driven: this panel may not be rendered yet, or may be gone
|
||||
if(!editorElement){ return resolve(false) }
|
||||
|
||||
// iterate over incoming patches because they apply to specific divs
|
||||
incomingPatchs.forEach(patch => {
|
||||
@@ -988,10 +987,10 @@
|
||||
})
|
||||
|
||||
// save editor HTML after change for future comparisons
|
||||
rawNoteText = editorElement.innerHTML
|
||||
this.rawNoteText = editorElement.innerHTML
|
||||
|
||||
// update hash on patch
|
||||
this.lastNoteHash = this.hashString( rawNoteText )
|
||||
this.lastNoteHash = this.hashString( this.rawNoteText )
|
||||
|
||||
this.$nextTick(() => {
|
||||
return resolve(true)
|
||||
@@ -1041,7 +1040,7 @@
|
||||
|
||||
//tell websockets to truncate history at this save
|
||||
this.lastNoteHash = currentHash //Update last saved note hash
|
||||
this.$io.emit('truncate_diffs_at_save', {'rawTextId':this.rawTextId, 'hash':currentHash })
|
||||
this.$io.emit('truncate_diffs_at_save', {'noteId':this.noteid, 'hash':currentHash })
|
||||
|
||||
const postData = {
|
||||
'noteId': this.currentNoteId,
|
||||
@@ -1068,6 +1067,10 @@
|
||||
},
|
||||
loadNoteNextFromServer(){
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.reloadNoteDebounce)
|
||||
this.reloadNoteDebounce = setTimeout(() => {
|
||||
|
||||
@@ -1075,13 +1078,28 @@
|
||||
// this.setText('')
|
||||
|
||||
//Focus Regained on Note, check for update
|
||||
console.log('Ajax Call to /api/note/get')
|
||||
axios.post('/api/note/get', { 'noteId': this.noteid })
|
||||
.then(response => {
|
||||
|
||||
// Skip note update if it didn't change.
|
||||
const newNoteHash = this.hashString( response.data.text )
|
||||
if(newNoteHash == this.lastNoteHash){
|
||||
console.log('Skipping any changes. Notes match')
|
||||
return
|
||||
}
|
||||
|
||||
this.setupLoadedNoteData(response)
|
||||
|
||||
//Manually set squire text to show
|
||||
this.setText(this.noteText)
|
||||
//Manually set squire text
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return }
|
||||
editorElement.innerHTML = response.data.text
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
@@ -1098,6 +1116,7 @@
|
||||
//If user leaves page then returns to page, reload the first batch
|
||||
if(checkForUpdateTimeout){
|
||||
|
||||
console.log('Focused then unfocused for greater than 3 seconds. Checking with server.')
|
||||
this.loadNoteNextFromServer()
|
||||
this.lastInteractionTimestamp = now
|
||||
}
|
||||
@@ -1127,6 +1146,15 @@
|
||||
|
||||
return hash;
|
||||
},
|
||||
// Menu routes are pushed (not replaced) because the history entry is
|
||||
// load-bearing: every menu closes via $router.go(-1). Forwarding the
|
||||
// current query keeps ?open= alive across the menu and the pop back.
|
||||
openMenuRoute(menu){
|
||||
this.$router.push({
|
||||
path: `/notes/open/${this.noteid}/menu/${menu}`,
|
||||
query: this.$route.query,
|
||||
})
|
||||
},
|
||||
closeButtonAction(playAnimation = false){
|
||||
this.sizeDown = playAnimation
|
||||
const animationTimeout = (playAnimation ? 300 : 0)
|
||||
@@ -1162,45 +1190,79 @@
|
||||
})
|
||||
},
|
||||
destroyWebSockets(){
|
||||
this.$io.removeListener('past_diffs')
|
||||
this.$io.removeListener('update_user_count')
|
||||
this.$io.removeListener('incoming_diff')
|
||||
this.$io.removeListener('update_note_attachments')
|
||||
|
||||
// By reference: removeListener(event) alone would remove the other
|
||||
// open panels' handlers too. new_note_text_saved was never removed
|
||||
// at all and leaked a handler on every open/close.
|
||||
this.$io.removeListener('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.removeListener('past_diffs', this.onPastDiffs)
|
||||
this.$io.removeListener('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.removeListener('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.removeListener('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
|
||||
clearTimeout(this.idleNoteTimeout)
|
||||
},
|
||||
initWebsocketEvents(){
|
||||
|
||||
//Tell server to push this note into a room
|
||||
this.$io.emit('join_room', this.rawTextId )
|
||||
|
||||
this.$io.on('update_user_count', userCount => {
|
||||
// Socket handlers are named methods rather than inline closures so that
|
||||
// destroyWebSockets() can remove THIS panel's handler by reference.
|
||||
// removeListener(event) with no handler drops every open panel's.
|
||||
onFileUpload({noteId, imageCode}){
|
||||
if(this.noteid == noteId && this.editor){
|
||||
this.editor.moveCursorToEnd()
|
||||
this.editor.insertHTML(imageCode)
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onCloseNoteById(noteId){
|
||||
if(noteId == this.noteid){
|
||||
this.closeButtonAction()
|
||||
}
|
||||
},
|
||||
onUpdateUserCount(userCount){
|
||||
this.usersOnNote = userCount
|
||||
})
|
||||
|
||||
},
|
||||
//Apply all diffs since last save
|
||||
this.$io.on('past_diffs', diffSinceLastUpdate => {
|
||||
onPastDiffs(diffSinceLastUpdate){
|
||||
|
||||
// Every open panel shares one socket, so filter to this note's room
|
||||
if(!this.isDiffForThisNote(diffSinceLastUpdate)){ return }
|
||||
|
||||
if(diffSinceLastUpdate != null){
|
||||
this.diffsApplied = diffSinceLastUpdate.length
|
||||
// console.log('Got Diffs Total -> ', diffSinceLastUpdate)
|
||||
}
|
||||
// console.log(diffSinceLastUpdate)
|
||||
|
||||
this.patchText(diffSinceLastUpdate)
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
})
|
||||
},
|
||||
onIncomingDiff(incomingDiff){
|
||||
|
||||
this.$io.on('incoming_diff', incomingDiff => {
|
||||
if(!this.isDiffForThisNote([incomingDiff])){ return }
|
||||
|
||||
this.patchText([incomingDiff])
|
||||
.then(() => {
|
||||
this.restoreCaretPosition()
|
||||
.catch(e => console.log(e) )
|
||||
})
|
||||
})
|
||||
},
|
||||
// A diff payload carries the raw-text id it belongs to. Anything without
|
||||
// one is dropped rather than guessed at -- applying a patch to the wrong
|
||||
// note is how note bodies got clobbered.
|
||||
isDiffForThisNote(diffs){
|
||||
|
||||
this.$io.on('new_note_text_saved', ({noteId, hash}) => {
|
||||
if(diffs == null){ return false }
|
||||
|
||||
const list = [].concat(diffs).filter(diff => diff != null)
|
||||
if(list.length == 0){ return false }
|
||||
|
||||
return list.every(diff => String(diff.rawTextId) === String(this.rawTextId))
|
||||
},
|
||||
onNewNoteTextSaved({noteId, hash}){
|
||||
|
||||
if(this.statusText != 'saved'){
|
||||
return
|
||||
}
|
||||
|
||||
const sameIdCheck = (this.idleNote && this.noteid == noteId)
|
||||
const differentHashCheck = (hash != this.lastNoteHash)
|
||||
@@ -1209,13 +1271,25 @@
|
||||
if(sameIdCheck && differentHashCheck){
|
||||
this.loadNoteNextFromServer()
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
this.$io.on('update_note_attachments', () => {
|
||||
},
|
||||
onUpdateNoteAttachments(){
|
||||
this.getAttachments()
|
||||
},
|
||||
initWebsocketEvents(){
|
||||
|
||||
//Tell server to push this note into a room
|
||||
// Ask for a note, not a room. The server authorises against the
|
||||
// token and derives the room itself.
|
||||
this.$io.emit('join_room', {
|
||||
noteId: this.noteid,
|
||||
token: localStorage.getItem('loginToken'),
|
||||
})
|
||||
|
||||
this.$io.on('update_user_count', this.onUpdateUserCount)
|
||||
this.$io.on('past_diffs', this.onPastDiffs)
|
||||
this.$io.on('incoming_diff', this.onIncomingDiff)
|
||||
this.$io.on('new_note_text_saved', this.onNewNoteTextSaved)
|
||||
this.$io.on('update_note_attachments', this.onUpdateNoteAttachments)
|
||||
},
|
||||
logNoteInteraction(){
|
||||
|
||||
@@ -1228,6 +1302,8 @@
|
||||
},
|
||||
saveCaretPosition(event){
|
||||
|
||||
return
|
||||
|
||||
//Find index of child element based on past range
|
||||
const element = window.getSelection().getRangeAt(0).startContainer.parentNode
|
||||
|
||||
@@ -1235,6 +1311,8 @@
|
||||
this.lastRange = this.generateSelector(element)
|
||||
this.startOffset = parseInt(event.range.startOffset) || 0
|
||||
|
||||
console.log('Save Range ', [element, this.lastRange, this.startOffset])
|
||||
|
||||
return
|
||||
},
|
||||
restoreCaretPosition(){
|
||||
@@ -1244,7 +1322,9 @@
|
||||
|
||||
if(!this.lastRange){ return resolve(true) }
|
||||
|
||||
const editorElement = document.getElementById('squire-id')
|
||||
const editorElement = this.$refs.squirebox
|
||||
if(!editorElement){ return resolve(true) }
|
||||
|
||||
const lastElement = editorElement.querySelector(this.lastRange)
|
||||
|
||||
if( !lastElement ){ return resolve(true) }
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -191,9 +192,9 @@
|
||||
name: 'NoteTitleDisplayCard',
|
||||
props: [ 'onClick', 'data', 'currentlyOpen', 'textResults', 'attachmentResults', 'tagResults', 'titleView' ],
|
||||
components: {
|
||||
'delete-button': require('@/components/NoteDeleteButtonComponent.vue').default,
|
||||
'note-tag-edit': require('@/components/NoteTagEdit.vue').default,
|
||||
'side-slide-menu': require('@/components/SideSlideMenuComponent.vue').default,
|
||||
'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
|
||||
'note-tag-edit': defineAsyncComponent(() => import('@/components/NoteTagEdit.vue')),
|
||||
'side-slide-menu': defineAsyncComponent(() => import('@/components/SideSlideMenuComponent.vue')),
|
||||
},
|
||||
methods:{
|
||||
cardClicked(){
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</span>
|
||||
</template>
|
||||
@@ -104,7 +103,7 @@
|
||||
this.tagSuggestions = []
|
||||
})
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
this.$bus.$off('reset_fast_filters')
|
||||
},
|
||||
mounted: function(){
|
||||
|
||||
@@ -107,11 +107,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
export default {
|
||||
name: 'SideSlideMenu',
|
||||
props: [ 'name', 'styleObject', 'fullShadow', 'skipHistory' ],
|
||||
components: {
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -129,7 +130,7 @@
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
},
|
||||
mounted(){
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</script>
|
||||
|
||||
<style type="text/css" scoped>
|
||||
.svg-holder >>> svg {
|
||||
.svg-holder :deep(svg) {
|
||||
width: 100%;
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
|
||||
export default {
|
||||
components:{
|
||||
'nm-button':require('@/components/NoteMenuButtonComponent.vue').default
|
||||
'nm-button':defineAsyncComponent(() => import('@/components/NoteMenuButtonComponent.vue'))
|
||||
},
|
||||
props: [ 'lastUsedColor' ],
|
||||
data: function(){
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="update-prompt" v-if="visible">
|
||||
<span class="update-text">A new version is ready.</span>
|
||||
<div class="ui tiny green button" v-on:click="apply()">Update</div>
|
||||
<div class="ui tiny basic button" v-on:click="dismiss()">Later</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'UpdatePrompt',
|
||||
data(){
|
||||
return { visible: false }
|
||||
},
|
||||
// The service worker is configured with skipWaiting:false, so a new build
|
||||
// sits waiting until the user accepts it here rather than swapping assets
|
||||
// out from under an open note mid-edit.
|
||||
beforeMount(){
|
||||
this.$bus.$on('sw-update-available', this.onUpdateAvailable)
|
||||
},
|
||||
beforeUnmount(){
|
||||
this.$bus.$off('sw-update-available', this.onUpdateAvailable)
|
||||
},
|
||||
methods: {
|
||||
onUpdateAvailable(){
|
||||
this.visible = true
|
||||
},
|
||||
apply(){
|
||||
this.visible = false
|
||||
// Imported lazily: this component mounts with the app, but the
|
||||
// worker registers after mount.
|
||||
import('@/registerServiceWorker').then(({ updateSW }) => updateSW(true))
|
||||
},
|
||||
dismiss(){
|
||||
// Stays waiting; the hourly update check will offer it again.
|
||||
this.visible = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-prompt {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1em;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
padding: 0.75em 1em;
|
||||
border: 1px solid var(--border_color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--small_element_bg_color);
|
||||
color: var(--text_color);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
.update-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +1,51 @@
|
||||
// The Vue build version to load with the `import` command
|
||||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
import Vuex from 'vuex'
|
||||
import store from './stores/mainStore';
|
||||
|
||||
import App from './App'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './stores/mainStore'
|
||||
|
||||
//Include entire fomantic ui library
|
||||
// import 'fomantic-ui-css/semantic.css';
|
||||
|
||||
//Required site and reset CSS
|
||||
import 'fomantic-ui-css/components/reset.min.css'
|
||||
import 'fomantic-ui-css/components/site.css' //modified to remove included LATO fonts
|
||||
|
||||
//Only include parts that are used
|
||||
import 'fomantic-ui-css/components/site.css'
|
||||
import 'fomantic-ui-css/components/button.min.css'
|
||||
import 'fomantic-ui-css/components/container.min.css'
|
||||
import 'fomantic-ui-css/components/form.min.css'
|
||||
import 'fomantic-ui-css/components/grid.min.css'
|
||||
import 'fomantic-ui-css/components/header.min.css'
|
||||
import 'fomantic-ui-css/components/icon.css' //Modified to remove brand icons
|
||||
import 'fomantic-ui-css/components/icon.css'
|
||||
import 'fomantic-ui-css/components/input.min.css'
|
||||
import 'fomantic-ui-css/components/segment.min.css'
|
||||
import 'fomantic-ui-css/components/label.min.css'
|
||||
|
||||
import './assets/semantic-helper.css'
|
||||
|
||||
//Overwrite and site styles and themes and good stuff
|
||||
require('./assets/semantic-helper.css')
|
||||
// Fonts
|
||||
require('./assets/roboto-latin.woff2')
|
||||
require('./assets/roboto-latin-bold.woff2')
|
||||
// Last, so Tailwind utilities win specificity ties against Fomantic
|
||||
import './tailwind.css'
|
||||
|
||||
|
||||
|
||||
|
||||
//Import socket io, init using nginx configured socket path
|
||||
import io from 'socket.io-client';
|
||||
const socket = io({ path:'/socket' });
|
||||
|
||||
//integrate connected socket into vue instance
|
||||
Object.defineProperties(Vue.prototype, {
|
||||
$io: {
|
||||
get: () => socket
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// This callback runs before every route change, including on page load.
|
||||
// Sets the title of the page using vue router
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title + ' - Solid Scribe';
|
||||
next();
|
||||
});
|
||||
|
||||
//Attach event bus to main vue object, all components will inherit event bus
|
||||
import io from 'socket.io-client'
|
||||
import EventBus from './EventBus'
|
||||
import Helpers from './Helpers'
|
||||
import { installLaunchRedirect } from './LaunchPage'
|
||||
|
||||
Vue.use(Vuex)
|
||||
Vue.config.productionTip = false
|
||||
const socket = io({ path: '/socket' })
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title + ' - Solid Scribe'
|
||||
next()
|
||||
})
|
||||
|
||||
// Installed launches land on "/" and are redirected to the user's chosen
|
||||
// launch page -- see LaunchPage.js
|
||||
installLaunchRedirect(router)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.config.globalProperties.$io = socket
|
||||
app.config.globalProperties.$bus = EventBus
|
||||
|
||||
app.use(router)
|
||||
app.use(store)
|
||||
app.use(Helpers)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// After mount, so a failed registration can never block the app rendering
|
||||
import('./registerServiceWorker')
|
||||
@@ -171,7 +171,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
this.$router.go(-1)
|
||||
|
||||
setTimeout(()=>{
|
||||
@@ -189,7 +189,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -249,7 +249,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile
|
||||
this.$router.go(-1)
|
||||
@@ -317,7 +317,7 @@ const SquireButtonFunctions = {
|
||||
//
|
||||
|
||||
//Fetch the container
|
||||
let container = document.getElementById('squire-id')
|
||||
let container = this.$refs.squirebox
|
||||
|
||||
//Close menu if user is on mobile, then sort list
|
||||
this.$router.go(-1)
|
||||
@@ -377,13 +377,13 @@ const SquireButtonFunctions = {
|
||||
// this.diffNoteText = this.editor._getHTML()
|
||||
|
||||
//Make sure all list items have draggable property
|
||||
let container = document.getElementById('squire-id')
|
||||
let listItems = container.getElementsByTagName('li')
|
||||
for(let itemIndex in listItems){
|
||||
// console.log(listItems[itemIndex])
|
||||
// listItems[itemIndex].setAttribute('draggable','true')
|
||||
}
|
||||
// console.log(listItems)
|
||||
// let container = this.$refs.squirebox
|
||||
// let listItems = container.getElementsByTagName('li')
|
||||
// for(let itemIndex in listItems){
|
||||
// // console.log(listItems[itemIndex])
|
||||
// // listItems[itemIndex].setAttribute('draggable','true')
|
||||
// }
|
||||
// // console.log(listItems)
|
||||
|
||||
},
|
||||
getText(){
|
||||
|
||||
@@ -102,13 +102,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'attachment-display': require('@/components/AttachmentDisplayCard').default,
|
||||
'counter':require('@/components/AnimatedCounterComponent.vue').default,
|
||||
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
||||
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
|
||||
},
|
||||
data: function(){
|
||||
return {
|
||||
@@ -124,7 +125,7 @@
|
||||
//
|
||||
// Perform Login check
|
||||
//
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted: function(){
|
||||
@@ -140,7 +141,7 @@
|
||||
//Mount notes on load if note ID is set
|
||||
this.searchAttachments()
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
//Remove scroll event on destroy
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
},
|
||||
beforeCreate: function(){
|
||||
// Perform Login check
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted: function(){
|
||||
this.getBookmarklet()
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<style>
|
||||
.calendar {
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
display: inline-block;
|
||||
}
|
||||
.day {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border_color);
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.today {
|
||||
font-weight: bold;
|
||||
}
|
||||
.day ~ .active {
|
||||
|
||||
background-color: var(--main-accent);
|
||||
color: var(--body_bg_color);
|
||||
}
|
||||
.day ~ .has-data {
|
||||
outline: #07f4f4;
|
||||
outline-style: none;
|
||||
outline-width: medium;
|
||||
outline-style: none;
|
||||
outline-offset: -4px;
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="squire-box">
|
||||
|
||||
<h3>Cycle Tracking Beta
|
||||
<span v-on:click="deleteData()">
|
||||
<i class="clickable trash icon"></i>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div class="calendar">
|
||||
<p>{{calendar.monthName}}, {{calendar.year}}</p>
|
||||
<div v-for="day in calendar.weekdays" class="day">
|
||||
{{ day }}
|
||||
</div>
|
||||
<div v-for="day in calendar.days" class="day"
|
||||
:class="{
|
||||
'today':day == calendar.today,
|
||||
'active':dateCode == `${day}.${calendar.month}.${calendar.year}`,
|
||||
'has-data':cycleData[`${day}.${calendar.month}.${calendar.year}`],
|
||||
}"
|
||||
v-on:click="openDayData(`${day}.${calendar.month}.${calendar.year}`)">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui form">
|
||||
<h3><input type="text" v-model="dateCode" v-on:keypress.enter="openDayData(dateCode)"></h3>
|
||||
<div class="field" v-for="field in fields">
|
||||
<label>{{ field.label }}</label>
|
||||
|
||||
<div v-for="(value, key) in openDay">
|
||||
|
||||
<!-- float -->
|
||||
<div v-if="field.type == 'float' && field.id == key" class="ui left icon input">
|
||||
<input type="text" :placeholder="field.label" v-on:keyup="e => saveField(field.id, e.target.value)" :value="value">
|
||||
<i class="thermometer half icon"></i>
|
||||
</div>
|
||||
|
||||
<!-- range -->
|
||||
<div v-if="field.type == 'range' && field.id == key">
|
||||
<div :class="{green:(value == 1)}" v-on:click="saveField(field.id, 1)" class="ui button">1</div>
|
||||
<div :class="{green:(value == 2)}" v-on:click="saveField(field.id, 2)" class="ui button">2</div>
|
||||
<div :class="{green:(value == 3)}" v-on:click="saveField(field.id, 3)" class="ui button">3</div>
|
||||
<div :class="{green:(value == 4)}" v-on:click="saveField(field.id, 4)" class="ui button">4</div>
|
||||
<div :class="{green:(value == 5)}" v-on:click="saveField(field.id, 5)" class="ui button">5</div>
|
||||
</div>
|
||||
|
||||
<!-- text area -->
|
||||
<div v-if="field.type == 'text' && field.id == key">
|
||||
<textarea rows="3" v-on:keyup="e => saveField(field.id, e.target.value)">
|
||||
{{ value }}
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ui green save button" v-on:click="saveDayData">
|
||||
Save
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'CycleTracking',
|
||||
components: {
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
appWorkingDate: null,
|
||||
fields:[],
|
||||
defaultFields:[
|
||||
{'type':'float','label':'Basil Temp', 'id':'BT'},
|
||||
{'type':'range','label':'Cervical Mucus', 'id':'CM'},
|
||||
{'type':'text','label':'Notes', 'id':'NO'},
|
||||
],
|
||||
cycleData: {},
|
||||
dateCode: null,
|
||||
openDay: [],
|
||||
calendar: {
|
||||
monthName: '',
|
||||
month: '',
|
||||
year: '',
|
||||
days: [],
|
||||
weekdays: ['S','M','T','W','T','F','S'],
|
||||
today: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
|
||||
// Perform Login check
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted(){
|
||||
if(!this.$store.getters.getLoggedIn){
|
||||
return
|
||||
}
|
||||
|
||||
// setup date code
|
||||
// day - month - year
|
||||
const now = new Date()
|
||||
this.appWorkingDate = now
|
||||
const dateSetup = [
|
||||
now.getDate(), // 1-31 (Day)
|
||||
now.getMonth()+1, // 0-11 (Month)
|
||||
now.getFullYear(), // 1888-2022 (Year)
|
||||
]
|
||||
|
||||
this.dateCode = dateSetup.join('.')
|
||||
|
||||
this.setupCalendar(this.appWorkingDate)
|
||||
|
||||
this.fetchCycleData()
|
||||
},
|
||||
methods: {
|
||||
saveField(fieldId, value){
|
||||
this.openDay[fieldId] = value
|
||||
},
|
||||
openDayData(dateCode){
|
||||
|
||||
if(String(dateCode).split('.').length != 3){
|
||||
console.log('Invalid Date Code')
|
||||
return
|
||||
}
|
||||
|
||||
this.dateCode = dateCode || this.dateCode
|
||||
|
||||
let currentDay = this.cycleData[this.dateCode] || {}
|
||||
|
||||
//Set up each entry empty or with current value
|
||||
this.fields.forEach(field => {
|
||||
currentDay[field.id] = currentDay[field.id] || ''
|
||||
})
|
||||
|
||||
this.openDay = currentDay
|
||||
|
||||
},
|
||||
saveDayData(){
|
||||
|
||||
// remove empty keys
|
||||
let cleanDayData = {}
|
||||
Object.keys(this.openDay).forEach(key => {
|
||||
if(this.openDay[key] != ''){
|
||||
cleanDayData[key] = this.openDay[key]
|
||||
}
|
||||
})
|
||||
|
||||
this.cycleData[this.dateCode] = cleanDayData
|
||||
|
||||
// Update calendar
|
||||
this.setupCalendar(this.appWorkingDate)
|
||||
|
||||
this.saveCycleData()
|
||||
|
||||
},
|
||||
fetchCycleData(){
|
||||
|
||||
axios.post('/api/cycle-tracking/get')
|
||||
.then(({ data }) => {
|
||||
if(data.hasOwnProperty("text")){
|
||||
|
||||
let appData = null
|
||||
try {
|
||||
appData = JSON.parse(data.text)
|
||||
} catch(e) {
|
||||
console.log('Didnt parse json')
|
||||
}
|
||||
|
||||
console.log(appData)
|
||||
|
||||
this.cycleData = appData?.cycleData || {}
|
||||
this.fields = appData?.fields || []
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.setupFields()
|
||||
this.openDayData(this.dateCode)
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', error) })
|
||||
|
||||
},
|
||||
saveCycleData(){
|
||||
|
||||
const appData = JSON.stringify({
|
||||
fields: this.fields,
|
||||
cycleData: this.cycleData,
|
||||
})
|
||||
axios.post('/api/cycle-tracking/save', { cycleData:appData })
|
||||
.then(response => {
|
||||
{ this.$bus.$emit('notification', 'Data Saved') }
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', error) })
|
||||
},
|
||||
deleteData(){
|
||||
axios.post('/api/cycle-tracking/save', { cycleData:'' })
|
||||
.then(response => {
|
||||
{ this.$bus.$emit('notification', 'Data Deleted') }
|
||||
})
|
||||
},
|
||||
setupFields(){
|
||||
|
||||
// push the first 3 default fields to users set
|
||||
if(this.fields.length == 0){
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.fields.push(this.defaultFields[i])
|
||||
}
|
||||
}
|
||||
},
|
||||
setupCalendar(date){
|
||||
// ------------
|
||||
// setup calendar display
|
||||
var y = date.getFullYear()
|
||||
var m = date.getMonth()
|
||||
|
||||
var firstDay = new Date(y, m, 1);
|
||||
var lastDay = new Date(y, m + 1, 0);
|
||||
|
||||
function getDaysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
const currentYear = date.getFullYear();
|
||||
const currentMonth = date.getMonth() + 1;
|
||||
this.calendar.monthName = date.toLocaleString("en-US", { month: "long" });
|
||||
this.calendar.year = currentYear
|
||||
const daysInCurrentMonth = getDaysInMonth(currentYear, currentMonth);
|
||||
|
||||
const monthStartDay = firstDay.getDay()
|
||||
let days = Array(monthStartDay).fill("."); // Pad days to start on correct weekday
|
||||
for (let i = 0; i < daysInCurrentMonth; i++) {
|
||||
days.push(i+1)
|
||||
}
|
||||
this.calendar.days = days
|
||||
|
||||
// set today
|
||||
this.calendar.today = date.getDate()
|
||||
this.calendar.month = date.getMonth()+1
|
||||
|
||||
|
||||
/*
|
||||
October 2022
|
||||
S M T W T F S
|
||||
1 2 3 4 5 6
|
||||
7 8 9
|
||||
*/
|
||||
|
||||
// -------
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="center aligned sixteen wide column">
|
||||
<router-link to="/terms"></i>Solid Scribe Terms of Use</router-link>
|
||||
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Go to notes button -->
|
||||
<div class="row" v-if="$parent.loggedIn">
|
||||
<div class="row" v-if="$root.loggedIn">
|
||||
<div class="sixteen wide middle algined center aligned column">
|
||||
<h3>You are already logged in</h3>
|
||||
<router-link class="ui huge green labeled icon button" to="/notes">
|
||||
@@ -184,7 +184,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Small login form -->
|
||||
<div class="row" v-if="!$parent.loggedIn">
|
||||
<div class="row" v-if="!$root.loggedIn">
|
||||
<div class="sixteen wide middle algined column">
|
||||
<div class="ui text container">
|
||||
<h2>
|
||||
@@ -216,7 +216,7 @@
|
||||
<h2 style="color: var(--main-accent);">
|
||||
Pick your theme
|
||||
</h2>
|
||||
<h3 v-if="$parent.loggedIn">Go to settings to change theme</h3>
|
||||
<h3 v-if="$root.loggedIn">Go to settings to change theme</h3>
|
||||
<div
|
||||
v-for="color in themeColors"
|
||||
v-bind:key="color"
|
||||
@@ -532,12 +532,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
export default {
|
||||
name: 'WelcomePage',
|
||||
components: {
|
||||
'login-form':require('@/components/LoginFormComponent.vue').default,
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'svg-displayer':require('@/components/SvgDisplayer.vue').default,
|
||||
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
'svg-displayer':defineAsyncComponent(() => import('@/components/SvgDisplayer.vue')),
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
@@ -562,10 +563,10 @@ export default {
|
||||
},
|
||||
beforeCreate(){
|
||||
//Force HTTPS on prod, always. Dev doesn't have certs
|
||||
const isDev = process.env['NODE_ENV'] == 'development'
|
||||
if (!isDev && location.protocol != 'https:'){
|
||||
window.location.replace('https://www.solidscribe.com')
|
||||
}
|
||||
// const isDev = process.env['NODE_ENV'] == 'development'
|
||||
// if (!isDev && location.protocol != 'https:'){
|
||||
// window.location.replace('https://www.solidscribe.com')
|
||||
// }
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
components: {
|
||||
'login-form':require('@/components/LoginFormComponent.vue').default,
|
||||
'login-form':defineAsyncComponent(() => import('@/components/LoginFormComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -355,9 +355,9 @@
|
||||
</div>
|
||||
|
||||
<div class="ui form" :class="{'loading-day':loadingDay}">
|
||||
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
|
||||
<div v-for="field in fields" :key="field"
|
||||
:class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
|
||||
<draggable v-model="fields" class="ui compact grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="field => field">
|
||||
<template #item="{ element: field }">
|
||||
<div :class="userFields[field]?.width ? userFields[field]?.width+' wide stretched column':'eight wide stretched column'">
|
||||
|
||||
<div class="ui very compact grid input-grid">
|
||||
<!-- field label display -->
|
||||
@@ -443,6 +443,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,7 +600,6 @@
|
||||
<div :class="{'green':(tempChartDays == 7)}" v-on:click="tempChartDays = 7; " class="ui button">7</div>
|
||||
</div>
|
||||
|
||||
<!-- <div id="graphdiv" style="width: 100%; min-height: 320px;"></div> -->
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<MetricGraphs
|
||||
@@ -686,8 +686,9 @@
|
||||
<div class="ui small dividing header">
|
||||
Active Fields
|
||||
</div>
|
||||
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable">
|
||||
<div v-for="fieldId in fields" :key="fieldId" class="row">
|
||||
<draggable v-model="fields" class="ui very compact internally celled grid" ghost-class="ghost" @end="onDragEnd" handle=".draggable" :item-key="fieldId => fieldId">
|
||||
<template #item="{ element: fieldId }">
|
||||
<div class="row">
|
||||
<div class="eight wide column">
|
||||
<i class="draggable grip lines icon"></i>
|
||||
{{ userFields[fieldId]?.label }}
|
||||
@@ -708,13 +709,14 @@
|
||||
<!-- {{ openDay[fieldId] }} -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="ui small dividing header">
|
||||
Other Fields
|
||||
</div>
|
||||
<draggable class="ui very compact internally celled grid">
|
||||
<div class="ui row" v-for="(field,fieldId) in userFields" v-if="!fields.includes(fieldId)">
|
||||
<div class="ui very compact internally celled grid">
|
||||
<div class="ui row" v-for="(field,fieldId) in unusedUserFields" :key="fieldId">
|
||||
<div class="eight wide column">
|
||||
<i class="draggable grip lines icon"></i>
|
||||
{{ userFields[fieldId]?.label }}
|
||||
@@ -726,8 +728,7 @@
|
||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui row" v-for="(field,fieldId) in defaultFields"
|
||||
v-if="!fields.includes(fieldId) && !Object.keys(userFields).includes(fieldId)">
|
||||
<div class="ui row" v-for="(field,fieldId) in unusedDefaultFields" :key="'default-'+fieldId">
|
||||
|
||||
<div class="eight wide column">
|
||||
<i class="draggable grip lines icon"></i>
|
||||
@@ -740,7 +741,7 @@
|
||||
<div class="ui tiny compact button" v-on:click="startUsingCustomField(fieldId)">Show</div>
|
||||
</div>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -817,7 +818,8 @@
|
||||
<div class="ui small dividing header">
|
||||
Add/Edit Field
|
||||
</div>
|
||||
<div v-for="(entry, key) in fieldDefinition" class="row" v-if="!['id'].includes(key)">
|
||||
<template v-for="(entry, key) in fieldDefinition" :key="key">
|
||||
<div class="row" v-if="!['id'].includes(key)">
|
||||
|
||||
<div v-if="fieldDefinitionOptionsDef[key]" class="sixteen wide column">
|
||||
{{ mapFormTerm(key) }}
|
||||
@@ -861,6 +863,7 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="row">
|
||||
<div class="eight wide column">
|
||||
<div class="ui fluid green button" v-on:click="saveCustomField(editFieldId)" v-if="validateCustomFieldsForm()">
|
||||
@@ -880,8 +883,6 @@
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
<!-- may or may not need digraph CSS, maybe disect -->
|
||||
<!-- <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/dygraph/2.1.0/dygraph.min.css" /> -->
|
||||
|
||||
<div class="ui basic segment"></div>
|
||||
|
||||
@@ -890,6 +891,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
/*
|
||||
Important Notes:
|
||||
@@ -906,12 +908,22 @@
|
||||
|
||||
export default {
|
||||
name: 'MetricTracking',
|
||||
// Descendant graph components need these; injecting avoids the
|
||||
// $parent / $parent.$parent chains, which break whenever an
|
||||
// intermediate instance is added (async wrappers, router-view).
|
||||
provide() {
|
||||
return {
|
||||
getFieldColor: field => this.getFieldColor(field),
|
||||
getFieldIcon: field => this.getFieldIcon(field),
|
||||
getFieldLabel: field => this.getFieldLabel(field),
|
||||
}
|
||||
},
|
||||
components: {
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'delete-button': require('@/components/NoteDeleteButtonComponent.vue').default,
|
||||
'modal': require('@/components/ModalComponent.vue').default,
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
'delete-button': defineAsyncComponent(() => import('@/components/NoteDeleteButtonComponent.vue')),
|
||||
'modal': defineAsyncComponent(() => import('@/components/ModalComponent.vue')),
|
||||
draggable,
|
||||
'MetricGraphs':require('@/components/Metrictracking/MetricGraphsComponent.vue').default,
|
||||
'MetricGraphs':defineAsyncComponent(() => import('@/components/Metrictracking/MetricGraphsComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -991,7 +1003,7 @@
|
||||
beforeCreate() {
|
||||
|
||||
// Perform Login check
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
},
|
||||
mounted(){
|
||||
@@ -1020,6 +1032,21 @@
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
// vuedraggable's computeNodes walks the default slot's vnodes directly
|
||||
// and cannot handle the fragments that <template v-for> produces, so
|
||||
// the filtering happens here instead of with a v-if in the slot.
|
||||
unusedUserFields(){
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.userFields).filter(([fieldId]) => !this.fields.includes(fieldId))
|
||||
)
|
||||
},
|
||||
unusedDefaultFields(){
|
||||
const userFieldIds = Object.keys(this.userFields)
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.defaultFields).filter(([fieldId]) =>
|
||||
!this.fields.includes(fieldId) && !userFieldIds.includes(fieldId))
|
||||
)
|
||||
},
|
||||
getToday(){
|
||||
return this.generateDateCode(new Date())
|
||||
},
|
||||
@@ -1099,6 +1126,27 @@
|
||||
return color
|
||||
// userFields[field]?.icon
|
||||
},
|
||||
getFieldLabel(field){
|
||||
|
||||
let label = null
|
||||
|
||||
// user defined
|
||||
if(this.userFields[field]?.label){
|
||||
label = this.userFields[field].label
|
||||
}
|
||||
|
||||
// default field value
|
||||
if(!label && this.defaultFields[field]?.label){
|
||||
label = this.defaultFields[field]?.label
|
||||
}
|
||||
|
||||
// fall back to the raw id
|
||||
if(!label){
|
||||
label = field
|
||||
}
|
||||
|
||||
return label
|
||||
},
|
||||
getFieldIcon(field){
|
||||
|
||||
let icon = null
|
||||
@@ -1218,7 +1266,7 @@
|
||||
if(event?.target?.value){
|
||||
option = event.target.value
|
||||
}
|
||||
this.$set(this.editFieldObject, key, option)
|
||||
this.editFieldObject[key] = option
|
||||
},
|
||||
mapFormTerm(term){
|
||||
|
||||
@@ -1371,7 +1419,11 @@
|
||||
if(this.openDay[fieldId] === value){ return }
|
||||
|
||||
// update field to be reactive
|
||||
this.$set(this.openDay, fieldId, value)
|
||||
this.openDay[fieldId] = value
|
||||
|
||||
// Push straight into cycleData so the graphs redraw immediately;
|
||||
// the server write below stays debounced.
|
||||
this.applyOpenDayToCycleData()
|
||||
|
||||
// remove debounce and set to modified
|
||||
this.$nextTick(() => {
|
||||
@@ -1401,12 +1453,14 @@
|
||||
// open day has all fields defined, just set values
|
||||
let currentDay = this.cycleData[dateCode] || {}
|
||||
Object.keys(this.openDay).forEach(fieldId => {
|
||||
this.$set(this.openDay, fieldId, currentDay[fieldId])
|
||||
this.openDay[fieldId] = currentDay[fieldId]
|
||||
})
|
||||
|
||||
|
||||
},
|
||||
saveDayData(){
|
||||
// Local-only: fold the open day's values into cycleData. Cheap and
|
||||
// synchronous, so callers can run it on every keystroke.
|
||||
applyOpenDayToCycleData(){
|
||||
|
||||
// remove empty keys
|
||||
let cleanDayData = {}
|
||||
@@ -1416,12 +1470,15 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Only save entry if there is data
|
||||
// Only keep entry if there is data
|
||||
delete this.cycleData[this.calendar.dateCode]
|
||||
if(Object.keys(cleanDayData).length > 0){
|
||||
this.cycleData[this.calendar.dateCode] = cleanDayData
|
||||
}
|
||||
},
|
||||
saveDayData(){
|
||||
|
||||
this.applyOpenDayToCycleData()
|
||||
this.saveCycleData()
|
||||
|
||||
},
|
||||
@@ -1547,7 +1604,7 @@
|
||||
|
||||
// set up reactive open day object
|
||||
Object.keys(this.userFields).forEach(fieldId => {
|
||||
this.$set(this.openDay, fieldId, '')
|
||||
this.openDay[fieldId] = ''
|
||||
})
|
||||
|
||||
//
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
</div>
|
||||
|
||||
<!-- render each section based on notes in set -->
|
||||
<div v-for="section,index in noteSections" v-if="section.length > 0" class="note-card-section">
|
||||
<template v-for="section,index in noteSections" :key="index">
|
||||
<div v-if="section.length > 0" class="note-card-section">
|
||||
<h5 class="ui tiny dividing header"><i :class="`green ${sectionData[index][0]} icon`"></i>{{ sectionData[index][1] }}</h5>
|
||||
|
||||
<div class="note-card-display-area">
|
||||
@@ -133,6 +134,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="loading-section" v-if="showLoading">
|
||||
@@ -163,20 +165,21 @@
|
||||
|
||||
<!-- flexbox note container evenly spaces open notes -->
|
||||
<div class="note-panel-container" :class="{ 'note-panel-fullwidth':collapseFloatingList}" v-if="openNotes.length">
|
||||
<template v-for="noteId in openNotes" :key="noteId">
|
||||
<note-input-panel
|
||||
v-for="noteId in openNotes"
|
||||
v-if="noteId != null"
|
||||
:key="noteId"
|
||||
:noteid="noteId"
|
||||
:url-data="$route.params"
|
||||
:open-notes="openNotes.length"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -184,15 +187,15 @@
|
||||
name: 'NotesPage',
|
||||
components: {
|
||||
|
||||
'note-input-panel': () => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue'),
|
||||
'note-input-panel': defineAsyncComponent(() => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue')),
|
||||
|
||||
'note-title-display-card': require('@/components/NoteTitleDisplayCard.vue').default,
|
||||
// 'fast-filters': require('@/components/FastFilters.vue').default,
|
||||
'search-input': require('@/components/SearchInput.vue').default,
|
||||
'attachment-display': require('@/components/AttachmentDisplayCard').default,
|
||||
'tag-display':require('@/components/TagDisplayComponent.vue').default,
|
||||
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
|
||||
'paste-button':require('@/components/PasteButton.vue').default,
|
||||
'note-title-display-card': defineAsyncComponent(() => import('@/components/NoteTitleDisplayCard.vue')),
|
||||
// 'fast-filters': defineAsyncComponent(() => import('@/components/FastFilters.vue')),
|
||||
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
||||
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
||||
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
|
||||
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
'paste-button':defineAsyncComponent(() => import('@/components/PasteButton.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -203,6 +206,9 @@
|
||||
searchTags: [],
|
||||
notes: [],
|
||||
openNotes: [],
|
||||
// fullPath of our own last URL write, so the $route watcher can
|
||||
// recognise it and not treat it as an inbound navigation
|
||||
lastSyncedFullPath: null,
|
||||
collapseFloatingList: false,
|
||||
highlights: [],
|
||||
searchDebounce: null,
|
||||
@@ -258,7 +264,7 @@
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
//If user is on title view,
|
||||
this.titleView = this.$store.getters.getIsUserOnMobile
|
||||
@@ -363,7 +369,7 @@
|
||||
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
|
||||
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
|
||||
|
||||
@@ -380,11 +386,26 @@
|
||||
},
|
||||
mounted() {
|
||||
|
||||
//Open note on PAGE LOAD if ID is set
|
||||
if(this.$route.params.id > 1){
|
||||
this.openNote(this.$route.params.id)
|
||||
// Restore every note named in ?open=
|
||||
const ids = this.parseOpenQuery(this.$route.query.open)
|
||||
|
||||
// Fold the legacy /notes/open/:id deep link in, first position
|
||||
const paramId = parseInt(this.$route.params.id, 10)
|
||||
if(Number.isInteger(paramId) && paramId > 1 && !ids.includes(paramId)){
|
||||
ids.unshift(paramId)
|
||||
}
|
||||
|
||||
// Push directly rather than via openNote(), which would write the URL
|
||||
// once per note; one normalising write follows instead.
|
||||
ids.forEach(id => {
|
||||
if(!this.openNotes.includes(id)){
|
||||
this.openNotes.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Unconditional, so a junk ?open=abc gets scrubbed too
|
||||
this.syncOpenNotesToUrl()
|
||||
|
||||
//Loads initial batch and tags
|
||||
this.reset()
|
||||
|
||||
@@ -395,17 +416,31 @@
|
||||
},
|
||||
'$route' (to, from) {
|
||||
|
||||
// Our own URL write, not an inbound navigation
|
||||
if(to.fullPath === this.lastSyncedFullPath){ return }
|
||||
|
||||
// Reload the notes if returning to this page
|
||||
if(to.fullPath == '/notes' && !from.fullPath.includes('/notes/open/')){
|
||||
// The discriminator is the absence of ?open=, NOT the path. A menu's
|
||||
// $router.go(-1) lands on /notes?open=5 with `from` containing
|
||||
// '/notes/open/', so a path comparison here would close every note
|
||||
// every time a side menu is dismissed.
|
||||
const landingOnBareNotes = (to.path === '/notes' && !to.query.open)
|
||||
|
||||
// Reload the notes if arriving at an empty /notes from elsewhere
|
||||
if(landingOnBareNotes && !from.path.startsWith('/notes/open/') && from.path !== '/notes'){
|
||||
this.reset()
|
||||
}
|
||||
|
||||
// Close all notes if returning to /notes page
|
||||
if(to.fullPath == '/notes' && from.fullPath.includes('/notes/open/')){
|
||||
// Explicit navigation to a note-less /notes closes everything
|
||||
if(landingOnBareNotes && this.openNotes.length > 0){
|
||||
this.closeAllNotes()
|
||||
}
|
||||
|
||||
// Stale history entry (e.g. go(-1) after another note was closed):
|
||||
// rewrite the URL from the array. Never read it.
|
||||
if(to.path === '/notes' && to.query.open && to.query.open !== this.openNotes.join(',')){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
//Lookup tags set in URL
|
||||
if(to.params.tag && this.$store.getters.totals && this.$store.getters.totals['tags'][to.params.tag]){
|
||||
|
||||
@@ -432,81 +467,121 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openNote(id, event = null){
|
||||
// Where the open-note URL should point. A menu or a tag search owns the
|
||||
// path, so only a plain notes view gets normalised back to /notes.
|
||||
openNotesTargetPath(){
|
||||
|
||||
//
|
||||
// A side menu is open; its close button is a $router.go(-1), so the
|
||||
// path has to stay put or there is nowhere sane to land.
|
||||
if(this.$route.params.openMenu){ return this.$route.path }
|
||||
|
||||
const intId = parseInt(id)
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Preserve a tag search
|
||||
if(this.$route.params.tag){ return this.$route.path }
|
||||
|
||||
console.log('Open already open note?')
|
||||
return '/notes'
|
||||
},
|
||||
// Mirror openNotes into ?open=. This is the ONLY place the open-note
|
||||
// URL is written.
|
||||
syncOpenNotesToUrl(){
|
||||
|
||||
// const openIndex = this.openNotes.indexOf(intId)
|
||||
// if(openIndex != -1){
|
||||
// console.log('Open note and remove it ', intId + ' on index ' + openIndex)
|
||||
// this.openNotes.splice(openIndex, 1)
|
||||
// }
|
||||
// this.$bus.$emit('close_note_by_id', intId)
|
||||
const query = Object.assign({}, this.$route.query)
|
||||
|
||||
if(this.openNotes.length > 0){
|
||||
query.open = this.openNotes.join(',')
|
||||
}
|
||||
else {
|
||||
delete query.open
|
||||
}
|
||||
|
||||
const target = { path: this.openNotesTargetPath(), query }
|
||||
const targetFullPath = this.$router.resolve(target).fullPath
|
||||
|
||||
// Already correct -- don't navigate at all
|
||||
if(targetFullPath === this.$route.fullPath){
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
return
|
||||
}
|
||||
|
||||
// Set before navigating so the watcher sees it
|
||||
this.lastSyncedFullPath = targetFullPath
|
||||
|
||||
this.$router.replace(target).catch(() => {})
|
||||
},
|
||||
// Tolerant parse of ?open=. Handles 'abc', '1,,2,2', '-4' and the
|
||||
// repeated form ?open=1&open=2 (which arrives as an array).
|
||||
parseOpenQuery(raw){
|
||||
|
||||
if(raw === undefined || raw === null){ return [] }
|
||||
|
||||
// Cap: a hand-crafted URL shouldn't be able to fire 50 note fetches
|
||||
const MAX_RESTORE = 8
|
||||
|
||||
return [].concat(raw)
|
||||
.join(',')
|
||||
.split(',')
|
||||
.map(value => parseInt(value, 10))
|
||||
.filter(value => Number.isInteger(value) && value > 0)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.slice(0, MAX_RESTORE)
|
||||
},
|
||||
openNote(id, event = null){
|
||||
|
||||
const intId = parseInt(id, 10)
|
||||
|
||||
// Also absorbs the undefined the '$route.params.id' watcher now gets
|
||||
// when we normalise /notes/open/5 into /notes?open=5
|
||||
if(!Number.isInteger(intId) || intId < 1){ return }
|
||||
|
||||
//Don't open note if a link is clicked in display card
|
||||
if(event && event.target && event.target.nodeName){
|
||||
const nodeClick = event.target.nodeName
|
||||
if(nodeClick == 'A'){ return }
|
||||
}
|
||||
|
||||
// Push note to stack if not open
|
||||
if(Number.isInteger(intId) && !this.openNotes.includes(intId)){
|
||||
this.openNotes.push(intId)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
// change route if open ID is not the same as current ID
|
||||
if(this.$route.params.id != id){
|
||||
console.log('Open note, change route -> route id ' + this.$route.params.id + ' note id ->' + id + ', ' +(this.$route.params.id == id))
|
||||
this.$router.push('/notes/open/'+id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
if(this.openNotes.includes(intId)){
|
||||
// Already open, but an external push('/notes/open/5') still needs
|
||||
// the URL normalised back to the full set
|
||||
this.syncOpenNotesToUrl()
|
||||
return
|
||||
}
|
||||
|
||||
this.openNotes.push(intId)
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
closeNote(noteId, modified){
|
||||
closeNote(noteId, modified, syncUrl = true){
|
||||
|
||||
console.log('close note', this.$route.fullPath)
|
||||
// parseInt: indexOf is strict, and ids arriving from the URL are
|
||||
// strings. Without this the splice silently misses.
|
||||
const intId = parseInt(noteId, 10)
|
||||
const openIndex = this.openNotes.indexOf(intId)
|
||||
const didClose = (openIndex !== -1)
|
||||
|
||||
const openIndex = this.openNotes.indexOf(noteId)
|
||||
if(openIndex != -1){
|
||||
console.log('Removing note id ', noteId + ' on index ' + openIndex)
|
||||
if(didClose){
|
||||
this.openNotes.splice(openIndex, 1)
|
||||
}
|
||||
|
||||
// //A note has been closed
|
||||
// if(this.$route.fullPath != '/notes'){
|
||||
// this.$router.push('/notes')
|
||||
// }
|
||||
if(this.openNotes.length == 0 && this.$route.fullPath != '/notes'){
|
||||
this.$router.push('/notes')
|
||||
// Only write the URL when the array actually changed. NoteInputPanel
|
||||
// emits close_active_note twice per note (closeButtonAction -> close(),
|
||||
// then beforeUnmount -> close()); the second arrives after the splice.
|
||||
if(didClose && syncUrl){
|
||||
this.syncOpenNotesToUrl()
|
||||
}
|
||||
|
||||
if(modified){
|
||||
console.log('Just closed Note -> ' + noteId + ', modified -> ', modified)
|
||||
this.$store.dispatch('fetchAndUpdateUserTotals')
|
||||
//Focus and animate if modified
|
||||
this.updateSingleNote(noteId, modified)
|
||||
}
|
||||
|
||||
console.log('closeNote(): Open notes length ', this.openNotes.length)
|
||||
},
|
||||
closeAllNotes(){
|
||||
console.log('Close all notes ------------')
|
||||
|
||||
// Backwards, so the in-loop splice can't skip an entry. URL writes are
|
||||
// deferred so this is one navigation rather than N.
|
||||
for (let i = this.openNotes.length - 1; i >= 0; i--) {
|
||||
console.log('Close all notes -> ' + this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i])
|
||||
this.closeNote(this.openNotes[i], false, false)
|
||||
}
|
||||
console.log('----------------')
|
||||
|
||||
this.syncOpenNotesToUrl()
|
||||
},
|
||||
toggleTagFilter(tagId){
|
||||
|
||||
@@ -741,7 +816,10 @@
|
||||
|
||||
return resolve(true)
|
||||
})
|
||||
.catch(error => { this.$bus.$emit('notification', 'Failed to Search Notes') })
|
||||
.catch(error => {
|
||||
console.error('[NotesPage] search failed:', error)
|
||||
this.$bus.$emit('notification', 'Failed to Search Notes')
|
||||
})
|
||||
})
|
||||
},
|
||||
rebuildNoteCategorise(){
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
<div :class="{'one-column':( showOneColumn() )}">
|
||||
|
||||
<!-- render each section based on notes in set -->
|
||||
<div v-for="section,index in noteSections" v-if="section.length > 0" class="note-card-section">
|
||||
<template v-for="section,index in noteSections" :key="index">
|
||||
<div v-if="section.length > 0" class="note-card-section">
|
||||
<h5 class="ui tiny dividing header"><i :class="`green ${sectionData[index][0]} icon`"></i>{{ sectionData[index][1] }}</h5>
|
||||
|
||||
<div class="note-card-display-area">
|
||||
@@ -122,6 +123,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<loading-icon v-if="loadingInProgress" message="Decrypting Notes" />
|
||||
@@ -143,6 +145,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -150,15 +153,15 @@
|
||||
name: 'SearchBar',
|
||||
components: {
|
||||
|
||||
'note-input-panel': () => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue'),
|
||||
'note-input-panel': defineAsyncComponent(() => import(/* webpackChunkName: "NoteInputPanel" */ '@/components/NoteInputPanel.vue')),
|
||||
|
||||
'note-title-display-card': require('@/components/NoteTitleDisplayCard.vue').default,
|
||||
// 'fast-filters': require('@/components/FastFilters.vue').default,
|
||||
'search-input': require('@/components/SearchInput.vue').default,
|
||||
'attachment-display': require('@/components/AttachmentDisplayCard').default,
|
||||
'counter':require('@/components/AnimatedCounterComponent.vue').default,
|
||||
'tag-display':require('@/components/TagDisplayComponent.vue').default,
|
||||
'loading-icon':require('@/components/LoadingIconComponent.vue').default,
|
||||
'note-title-display-card': defineAsyncComponent(() => import('@/components/NoteTitleDisplayCard.vue')),
|
||||
// 'fast-filters': defineAsyncComponent(() => import('@/components/FastFilters.vue')),
|
||||
'search-input': defineAsyncComponent(() => import('@/components/SearchInput.vue')),
|
||||
'attachment-display': defineAsyncComponent(() => import('@/components/AttachmentDisplayCard.vue')),
|
||||
'counter':defineAsyncComponent(() => import('@/components/AnimatedCounterComponent.vue')),
|
||||
'tag-display':defineAsyncComponent(() => import('@/components/TagDisplayComponent.vue')),
|
||||
'loading-icon':defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -222,7 +225,7 @@
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
|
||||
this.$io.on('new_note_created', noteId => {
|
||||
|
||||
@@ -335,7 +338,7 @@
|
||||
// document.addEventListener('visibilitychange', this.visibiltyChangeAction);
|
||||
|
||||
},
|
||||
beforeDestroy(){
|
||||
beforeUnmount(){
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
// document.removeEventListener('visibilitychange', this.visibiltyChangeAction)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
//
|
||||
// Perform Login check
|
||||
//
|
||||
this.$parent.loginGateway()
|
||||
this.$root.loginGateway()
|
||||
},
|
||||
beforeMount(){
|
||||
|
||||
|
||||
@@ -44,6 +44,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App / PWA -->
|
||||
<h4 class="ui header">
|
||||
App
|
||||
</h4>
|
||||
<div class="ui segment">
|
||||
<div class="ui doubling grid">
|
||||
<div class="sixteen wide column">
|
||||
|
||||
<p>
|
||||
Which page the installed app opens to. Set automatically to
|
||||
whichever page you installed from, and changeable here.
|
||||
</p>
|
||||
<div
|
||||
v-for="option in launchOptions"
|
||||
:key="option.path"
|
||||
class="ui compact basic button"
|
||||
:class="{'green':(launchPage == option.path)}"
|
||||
v-on:click="chooseLaunchPage(option.path)">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<p>
|
||||
If the app gets stuck on an old version, this clears the stored
|
||||
copies of its files and reloads. Your notes are not affected.
|
||||
</p>
|
||||
<div class="ui compact basic button" v-on:click="clearAppCache()">
|
||||
<i class="redo icon"></i>
|
||||
Reset App Cache
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable Two Factor -->
|
||||
<h4>Two Factor Authentication</h4>
|
||||
<div class="ui segment">
|
||||
@@ -152,7 +188,7 @@
|
||||
|
||||
<div class="ui grid">
|
||||
<div class="center aligned sixteen wide column">
|
||||
<router-link to="/terms"></i>Solid Scribe Terms of Use</router-link>
|
||||
<router-link to="/terms">Solid Scribe Terms of Use</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,13 +197,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import { LAUNCH_OPTIONS, getLaunchPage, setLaunchPage } from '@/LaunchPage'
|
||||
import { resetAppCache } from '@/registerServiceWorker'
|
||||
|
||||
export default {
|
||||
name: 'SettingsPage',
|
||||
components: {
|
||||
'logo':require('@/components/LogoComponent.vue').default,
|
||||
'logo':defineAsyncComponent(() => import('@/components/LogoComponent.vue')),
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@@ -176,6 +216,8 @@
|
||||
verificationToken: '',
|
||||
showNewNoteConfirm: false,
|
||||
|
||||
launchOptions: LAUNCH_OPTIONS,
|
||||
launchPage: getLaunchPage(),
|
||||
themeColors: [
|
||||
'#21BA45', //Green
|
||||
'#b5cc18', //Lime
|
||||
@@ -197,6 +239,14 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
chooseLaunchPage(path){
|
||||
setLaunchPage(path)
|
||||
this.launchPage = getLaunchPage()
|
||||
this.$bus.$emit('notification', 'App will open to ' + path)
|
||||
},
|
||||
clearAppCache(){
|
||||
resetAppCache()
|
||||
},
|
||||
newQuickNote(){
|
||||
|
||||
this.showNewNoteConfirm = true
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="ui container share-target">
|
||||
|
||||
<div class="ui basic center aligned segment">
|
||||
|
||||
<div v-if="status === 'working'">
|
||||
<loading-icon message="Saving to Scratch Pad" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'error'">
|
||||
<h3 class="ui header">
|
||||
<i class="exclamation triangle icon"></i>
|
||||
Couldn't save that
|
||||
</h3>
|
||||
<p>You may be offline. Your text is safe below — retry when you have a connection.</p>
|
||||
|
||||
<textarea class="shared-text" rows="8" readonly :value="pushText"></textarea>
|
||||
|
||||
<div class="ui buttons">
|
||||
<div class="ui green button" v-on:click="save()">
|
||||
<i class="redo icon"></i> Retry
|
||||
</div>
|
||||
<div class="ui basic button" v-on:click="discard()">
|
||||
Discard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<loading-icon message="Working" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
// Shared text is parked here when the share arrives while logged out. The
|
||||
// share's query string does not survive the bounce through /login, so a
|
||||
// redirect param alone would lose it.
|
||||
const STASH_KEY = 'pendingShareText'
|
||||
|
||||
// Guard against a pathological share wedging localStorage
|
||||
const MAX_STASH = 100 * 1024
|
||||
|
||||
export default {
|
||||
name: 'ShareTargetPage',
|
||||
components: {
|
||||
'loading-icon': defineAsyncComponent(() => import('@/components/LoadingIconComponent.vue')),
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
status: 'working',
|
||||
pushText: '',
|
||||
}
|
||||
},
|
||||
// Deliberately NOT calling $root.loginGateway() the way other pages do --
|
||||
// it would bounce away and discard the shared payload before we read it.
|
||||
mounted(){
|
||||
|
||||
const text = this.composeSharedText()
|
||||
|
||||
if(!text){
|
||||
this.$router.replace('/quick')
|
||||
return
|
||||
}
|
||||
|
||||
this.pushText = text
|
||||
|
||||
// getLoggedIn is reliable here: App.vue's beforeCreate populates the
|
||||
// username from localStorage before any route component mounts.
|
||||
if(!this.$store.getters.getLoggedIn){
|
||||
this.stash(text)
|
||||
this.$router.replace({ path: '/login', query: { redirect: '/share-target' } })
|
||||
return
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
methods: {
|
||||
// Android routinely duplicates content across these fields -- the URL
|
||||
// lands in both `text` and `url`, the page title in both `title` and
|
||||
// `text`. Without the dedupe every shared link is saved twice.
|
||||
composeSharedText(){
|
||||
|
||||
const query = this.$route.query
|
||||
|
||||
const parts = ['title', 'text', 'url']
|
||||
.map(key => String(query[key] ?? '').trim())
|
||||
.filter(value => value.length > 0)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
|
||||
if(parts.length > 0){ return parts.join('\n') }
|
||||
|
||||
// Nothing in the URL: this is the replay after logging in
|
||||
return localStorage.getItem(STASH_KEY) || ''
|
||||
},
|
||||
stash(text){
|
||||
if(text.length > MAX_STASH){ return }
|
||||
localStorage.setItem(STASH_KEY, text)
|
||||
},
|
||||
save(){
|
||||
|
||||
this.status = 'working'
|
||||
|
||||
// Same call the paste button makes. The server prepends to the
|
||||
// scratch pad, strips HTML and auto-links URLs, so nothing needs
|
||||
// formatting here.
|
||||
axios.post('/api/quick-note/update', { 'pushText': this.pushText })
|
||||
.then(() => {
|
||||
localStorage.removeItem(STASH_KEY)
|
||||
this.$bus.$emit('notification', 'Saved To Scratch Pad')
|
||||
this.$router.replace('/quick')
|
||||
})
|
||||
.catch(error => {
|
||||
// Keep the stash: offline is the likely cause and the service
|
||||
// worker means the app boots without a connection.
|
||||
console.error('[ShareTarget] save failed:', error)
|
||||
this.stash(this.pushText)
|
||||
this.status = 'error'
|
||||
})
|
||||
},
|
||||
discard(){
|
||||
localStorage.removeItem(STASH_KEY)
|
||||
this.$router.replace('/quick')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-target {
|
||||
padding-top: 3em;
|
||||
}
|
||||
.shared-text {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
padding: 0.75em;
|
||||
border: 1px solid var(--border_color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--small_element_bg_color);
|
||||
color: var(--text_color);
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import EventBus from './EventBus'
|
||||
|
||||
// How often to ask the server whether a new build exists
|
||||
const UPDATE_INTERVAL = 60 * 60 * 1000
|
||||
|
||||
//
|
||||
// The worker precaches static build output only -- see the SECURITY INVARIANT
|
||||
// comment in vite.config.js. Nothing user-derived ever enters Cache Storage,
|
||||
// which is why caches are deliberately NOT cleared on logout: doing so would
|
||||
// only destroy the offline shell.
|
||||
//
|
||||
export const updateSW = registerSW({
|
||||
onNeedRefresh(){
|
||||
// A new build is waiting. Let the user apply it rather than swapping
|
||||
// assets underneath them mid-edit.
|
||||
EventBus.$emit('sw-update-available')
|
||||
},
|
||||
onOfflineReady(){
|
||||
EventBus.$emit('notification', 'Ready to work offline')
|
||||
},
|
||||
onRegisteredSW(swUrl, registration){
|
||||
if(!registration){ return }
|
||||
setInterval(() => registration.update(), UPDATE_INTERVAL)
|
||||
},
|
||||
onRegisterError(error){
|
||||
console.error('[SW] registration failed:', error)
|
||||
},
|
||||
})
|
||||
|
||||
//
|
||||
// Escape hatch. A wedged service worker is very hard to talk someone through
|
||||
// remotely, so this is surfaced as a button in Settings.
|
||||
//
|
||||
export async function resetAppCache(){
|
||||
|
||||
if('serviceWorker' in navigator){
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
}
|
||||
|
||||
if('caches' in window){
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map(key => caches.delete(key)))
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
@@ -1,133 +1,41 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
//Breaking components into function sections allows webpack to load them dynamically
|
||||
const HomePage = () => import('@/pages/HomePage.vue')
|
||||
const LoginPage = () => import('@/pages/LoginPage.vue')
|
||||
const HelpPage = () => import('@/pages/HelpPage.vue')
|
||||
const TermsPage = () => import('@/pages/TermsPage.vue')
|
||||
const SettingsPage = () => import('@/pages/SettingsPage.vue')
|
||||
const SharePage = () => import('@/pages/SharePage.vue')
|
||||
const NotesPage = () => import('@/pages/NotesPage.vue')
|
||||
const QuickPage = () => import('@/pages/QuickPage.vue')
|
||||
const AttachmentsPage = () => import('@/pages/AttachmentsPage.vue')
|
||||
const OverviewPage = () => import('@/pages/OverviewPage.vue')
|
||||
const BookmarkletPage = () => import('@/pages/BookmarkletPage.vue')
|
||||
const NotFoundPage = () => import('@/pages/NotFoundPage.vue')
|
||||
const MetrictrackingPage = () => import('@/pages/MetrictrackingPage.vue')
|
||||
const ShareTargetPage = () => import('@/pages/ShareTargetPage.vue')
|
||||
|
||||
const HomePage = () => import(/* webpackChunkName: "HomePage" */ '@/pages/HomePage')
|
||||
const LoginPage = () => import(/* webpackChunkName: "LoginPage" */ '@/pages/LoginPage')
|
||||
const HelpPage = () => import(/* webpackChunkName: "HelpPage" */ '@/pages/HelpPage')
|
||||
const TermsPage = () => import(/* webpackChunkName: "TermsPage" */ '@/pages/TermsPage')
|
||||
const SettingsPage = () => import(/* webpackChunkName: "SettingsPage" */ '@/pages/SettingsPage')
|
||||
const SharePage = () => import(/* webpackChunkName: "SharePage" */ '@/pages/SharePage')
|
||||
const NotesPage = () => import(/* webpackChunkName: "NotesPage" */ '@/pages/NotesPage')
|
||||
const QuickPage = () => import(/* webpackChunkName: "QuickPage" */ '@/pages/QuickPage')
|
||||
const AttachmentsPage = () => import(/* webpackChunkName: "AttachmentsPage" */ '@/pages/AttachmentsPage')
|
||||
const OverviewPage = () => import(/* webpackChunkName: "OverviewPage" */ '@/pages/OverviewPage')
|
||||
const BookmarkletPage = () => import(/* webpackChunkName: "BookmarkletPage" */ '@/pages/BookmarkletPage')
|
||||
const NotFoundPage = () => import(/* webpackChunkName: "404Page" */ '@/pages/NotFoundPage')
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'HomePage',
|
||||
meta: {title:'Home'},
|
||||
component: HomePage
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'LoginPage',
|
||||
meta: {title:'Login'},
|
||||
component: LoginPage
|
||||
},
|
||||
{
|
||||
path: '/notes',
|
||||
name: 'Note Page', //don't change this
|
||||
meta: {title:'Notes'},
|
||||
component: NotesPage
|
||||
},
|
||||
{
|
||||
path: '/notes/open/:id',
|
||||
name: 'Open Note',
|
||||
meta: {title: 'Open Note'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/search/tags/:tag',
|
||||
name: 'Search Notes',
|
||||
meta: {title: 'Search Notes'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/notes/open/:id/menu/:openMenu',
|
||||
name: 'Open Note Menu',
|
||||
meta: {title: 'Open Note Menu'},
|
||||
component: NotesPage,
|
||||
},
|
||||
{
|
||||
path: '/help',
|
||||
name: 'Help',
|
||||
meta: {title:'Help'},
|
||||
component: HelpPage
|
||||
},
|
||||
{
|
||||
path: '/terms',
|
||||
name: 'Terms',
|
||||
meta: {title:'Terms'},
|
||||
component: TermsPage
|
||||
},
|
||||
{
|
||||
path: '/bookmarklet',
|
||||
name: 'Bookmarklet',
|
||||
meta: {title:'Bookmarklet'},
|
||||
component: BookmarkletPage
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
meta: {title:'Settings'},
|
||||
component: SettingsPage
|
||||
},
|
||||
{
|
||||
path: '/public/note/:id/:token',
|
||||
name: 'Share',
|
||||
meta: {title:'Shared'},
|
||||
component: SharePage
|
||||
},
|
||||
{
|
||||
path: '/quick',
|
||||
name: 'Quick',
|
||||
meta: {title:'Scratch Pad'},
|
||||
component: QuickPage
|
||||
},
|
||||
{
|
||||
path: '/attachments',
|
||||
name: 'Attachments',
|
||||
meta: {title:'Attachments'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/attachments/note/:id',
|
||||
name: 'Attachments for Note',
|
||||
meta: {title:'Attachments for Note'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/attachments/type/:type',
|
||||
name: 'Attachments by Type',
|
||||
meta: {title:'Attachments by Type'},
|
||||
component: AttachmentsPage
|
||||
},
|
||||
{
|
||||
path: '/overview',
|
||||
name: 'Overview of Notes',
|
||||
meta: {title:'Overview of Notes'},
|
||||
component: OverviewPage
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
name: 'Page Not Found',
|
||||
meta: {title:'404 Page Not Found'},
|
||||
component: NotFoundPage
|
||||
},
|
||||
// Cycle Tracking
|
||||
{
|
||||
path: '/metrictrack',
|
||||
name: 'Metric Tracking',
|
||||
meta: {title:'Metric Tracking'},
|
||||
component: () => import(/* webpackChunkName: "MetrictrackingPage" */ '@/pages/MetrictrackingPage')
|
||||
},
|
||||
]
|
||||
{ path: '/', name: 'HomePage', meta: { title: 'Home' }, component: HomePage },
|
||||
{ path: '/login', name: 'LoginPage', meta: { title: 'Login' }, component: LoginPage },
|
||||
{ path: '/notes', name: 'Note Page', meta: { title: 'Notes' }, component: NotesPage },
|
||||
{ path: '/notes/open/:id', name: 'Open Note', meta: { title: 'Open Note' }, component: NotesPage },
|
||||
{ path: '/search/tags/:tag', name: 'Search Notes', meta: { title: 'Search Notes' }, component: NotesPage },
|
||||
{ path: '/notes/open/:id/menu/:openMenu', name: 'Open Note Menu', meta: { title: 'Open Note Menu' }, component: NotesPage },
|
||||
{ path: '/help', name: 'Help', meta: { title: 'Help' }, component: HelpPage },
|
||||
{ path: '/terms', name: 'Terms', meta: { title: 'Terms' }, component: TermsPage },
|
||||
{ path: '/bookmarklet', name: 'Bookmarklet', meta: { title: 'Bookmarklet' }, component: BookmarkletPage },
|
||||
{ path: '/settings', name: 'Settings', meta: { title: 'Settings' }, component: SettingsPage },
|
||||
{ path: '/public/note/:id/:token', name: 'Share', meta: { title: 'Shared' }, component: SharePage },
|
||||
{ path: '/share-target', name: 'Share Target', meta: { title: 'Saving Share' }, component: ShareTargetPage },
|
||||
{ path: '/quick', name: 'Quick', meta: { title: 'Scratch Pad' }, component: QuickPage },
|
||||
{ path: '/attachments', name: 'Attachments', meta: { title: 'Attachments' }, component: AttachmentsPage },
|
||||
{ path: '/attachments/note/:id', name: 'Attachments for Note', meta: { title: 'Attachments for Note' }, component: AttachmentsPage },
|
||||
{ path: '/attachments/type/:type', name: 'Attachments by Type', meta: { title: 'Attachments by Type' }, component: AttachmentsPage },
|
||||
{ path: '/overview', name: 'Overview of Notes', meta: { title: 'Overview of Notes' }, component: OverviewPage },
|
||||
{ path: '/metrictrack', name: 'Metric Tracking', meta: { title: 'Metric Tracking' }, component: MetrictrackingPage },
|
||||
{ path: '/:pathMatch(.*)*', name: 'Page Not Found', meta: { title: '404 Page Not Found' }, component: NotFoundPage },
|
||||
],
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
},
|
||||
mutations: {
|
||||
},
|
||||
actions: {
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { createStore } from 'vuex'
|
||||
import axios from 'axios'
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
export default createStore({
|
||||
state: {
|
||||
username: null,
|
||||
nightMode: false,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Tailwind 4, wired to coexist with Fomantic UI.
|
||||
*
|
||||
* Deliberately NOT `@import "tailwindcss"` (the whole thing). That pulls in
|
||||
* Preflight as `@layer base`, which zeroes margin/padding/border on every
|
||||
* element, strips heading sizes and list markers, forces img/svg to
|
||||
* display:block and makes buttons and inputs transparent. Dropped on top of
|
||||
* Fomantic it visibly breaks the existing UI.
|
||||
*
|
||||
* Utilities are imported UNLAYERED on purpose. Tailwind's own index.css wraps
|
||||
* them in `@layer utilities`, and an unlayered stylesheet beats any layered
|
||||
* rule regardless of specificity -- since all of Fomantic is unlayered, the
|
||||
* utilities would quietly lose every conflict. Unlayered they compete on normal
|
||||
* specificity instead, and win ties by source order because this file is
|
||||
* imported last in main.js.
|
||||
*
|
||||
* Fomantic mostly uses two-class selectors (.ui.button = specificity 0,2,0), so
|
||||
* it still out-specifies a single utility (0,1,0). To beat Fomantic on a
|
||||
* property it already sets, use the important modifier -- `p-4!`, `bg-red-500!`.
|
||||
*
|
||||
* To try Preflight anyway, add: @import "tailwindcss/preflight.css" layer(base);
|
||||
*/
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css";
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// Opt-in HTTPS for the dev server, off by default so the usual
|
||||
// http://localhost:5173 workflow is unchanged. Only needed if you want to test
|
||||
// the installed PWA from a phone over the LAN *without* using
|
||||
// chrome://flags/#unsafely-treat-insecure-origin-as-secure -- and note Chrome
|
||||
// needs the mkcert root CA installed on the device for the origin to be
|
||||
// service-worker eligible; clicking through a cert warning is not enough.
|
||||
// DEV_HTTPS=1 pnpm dev
|
||||
const certDir = fileURLToPath(new URL('./certs', import.meta.url))
|
||||
const keyPath = `${certDir}/192.168.1.164+4-key.pem`
|
||||
const certPath = `${certDir}/192.168.1.164+4.pem`
|
||||
const useHttps =
|
||||
process.env.DEV_HTTPS === '1' && fs.existsSync(keyPath) && fs.existsSync(certPath)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
transformAssetUrls: { includeAbsolute: false },
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
// Updates are user-driven via the prompt in src/registerServiceWorker.js
|
||||
registerType: 'prompt',
|
||||
injectRegister: false,
|
||||
// Lands at dist/sw.js -- root of the served directory, so the worker gets
|
||||
// root scope without needing a Service-Worker-Allowed header.
|
||||
filename: 'sw.js',
|
||||
// We ship public/manifest.json by hand rather than generating one.
|
||||
manifest: false,
|
||||
workbox: {
|
||||
//
|
||||
// SECURITY INVARIANT -- do not relax without thinking it through.
|
||||
//
|
||||
// Solid Scribe is end-to-end encrypted. The service worker precaches
|
||||
// ONLY static build output and is network-only for /api/*. Caching an
|
||||
// API response would write decrypted note content into Cache Storage on
|
||||
// disk, where it survives logout and sits outside the encryption model.
|
||||
//
|
||||
// Structurally this holds because globPatterns only ever reads dist/,
|
||||
// and user content is served by Express from staticFiles/ at
|
||||
// /api/static -- which never enters dist. The NetworkOnly rule below is
|
||||
// belt-and-braces: it documents the invariant and stops a future
|
||||
// runtimeCaching entry from silently widening the net.
|
||||
//
|
||||
// woff2 is NOT in Workbox's default globPatterns. Without it the Roboto
|
||||
// files are skipped and the offline shell renders in a fallback face.
|
||||
globPatterns: ['**/*.{js,css,html,woff2,png,svg,ico,json}'],
|
||||
// Vendor chunks (fomantic, chart.js) exceed the 2MiB default. An
|
||||
// oversized file is silently OMITTED from the precache, which half
|
||||
// breaks the offline shell -- watch the build output for a warning.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
navigateFallback: '/index.html',
|
||||
// Matters for top-level navigations to /api/static/<file> (clicking an
|
||||
// attachment link) -- without this the worker answers with index.html.
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/socket/],
|
||||
cleanupOutdatedCaches: true,
|
||||
// Let the user decide when to swap assets, rather than doing it
|
||||
// mid-session underneath them.
|
||||
clientsClaim: false,
|
||||
skipWaiting: false,
|
||||
//
|
||||
// runtimeCaching is deliberately EMPTY. Do not add an entry for /api/*
|
||||
// or /socket -- not even NetworkOnly.
|
||||
//
|
||||
// There used to be a NetworkOnly route for both, as belt-and-braces for
|
||||
// the invariant above. It was worse than useless. registerRoute() makes
|
||||
// the worker call event.respondWith() for every matching request, and
|
||||
// Socket.IO's long-poll requests are aborted as a matter of course --
|
||||
// on reconnect, on transport upgrade, and whenever Android backgrounds
|
||||
// the page. An aborted fetch inside NetworkOnly throws Workbox's
|
||||
// `no-response`, the respondWith promise rejects, and Socket.IO sees a
|
||||
// dead transport and reconnects. The installed PWA was dropping its
|
||||
// live note sync on a loop and filling the logs with
|
||||
// "FetchEvent ... resulted in a network error response".
|
||||
//
|
||||
// With no route registered the worker never intercepts these at all and
|
||||
// the browser handles them natively, exactly as in a normal tab.
|
||||
//
|
||||
// Nothing is lost on the security side: the invariant is enforced by
|
||||
// globPatterns only ever reading dist/, and by there being no *caching*
|
||||
// strategy here. NetworkOnly enforced nothing -- it only inserted the
|
||||
// worker into the hot path of every poll.
|
||||
//
|
||||
},
|
||||
// Flip to true temporarily to exercise the worker on the dev server.
|
||||
// Remember to unregister it afterwards -- a lingering dev service worker
|
||||
// is a classic "why is my phone showing an ancient build".
|
||||
devOptions: { enabled: false, type: 'module' },
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
...(useHttps
|
||||
? { https: { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) } }
|
||||
: {}),
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/socket': { target: 'http://localhost:3001', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
#
|
||||
# Working dev server config
|
||||
#
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name 192.168.1.164;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
|
||||
listen 443 ssl;
|
||||
|
||||
ssl_certificate /home/mab/ss/client/certs/nginx-selfsigned.crt;
|
||||
ssl_certificate_key /home/mab/ss/client/certs/nginx-selfsigned.key;
|
||||
ssl_dhparam /home/mab/ss/client/certs/dhparam.pem;
|
||||
|
||||
ssl_session_cache shared:SSL:1m;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSV1.1 TLSV1.2 TLSV1.3;
|
||||
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
access_log /var/log/nginx/httpslocalhost.access.log;
|
||||
error_log /var/log/nginx/httpslocalhost.error.log;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:8081;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Forwarded-Host localhost;
|
||||
proxy_set_header X-Forwarded-Server localhost;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_redirect off;
|
||||
proxy_connect_timeout 90s;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_send_timeout 90s;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /sockjs-node {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_pass https://127.0.0.1:8081;
|
||||
proxy_redirect off;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_redirect off;
|
||||
proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
}
|
||||
|
||||
location /socket {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Prod settings to serve static index
|
||||
# location / {
|
||||
# autoindex on;
|
||||
# #try_files $uri $uri/ /index.html;
|
||||
# }
|
||||
|
||||
# location / {
|
||||
# #autoindex on
|
||||
#
|
||||
# proxy_pass http://127.0.0.1:8444;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection 'upgrade';
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_cache_bypass $http_upgrade;
|
||||
# }
|
||||
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# nginx for Solid Scribe on NixOS.
|
||||
#
|
||||
# Merge this into your configuration.nix and `nixos-rebuild switch`.
|
||||
# Replace REPO_PATH with the absolute path of the repo clone on the server.
|
||||
#
|
||||
# Four things differ from the previous config, and the first two are required
|
||||
# for the Vue 3 client to work at all:
|
||||
#
|
||||
# 1. SPA fallback. The old Vue 2 client used vue-router's default HASH mode
|
||||
# (no `mode:` was set), so every request was just "/" and routing happened
|
||||
# after the "#". That is why share links looked like /#/public/note/...
|
||||
# The Vue 3 client uses createWebHistory, so /notes, /settings and
|
||||
# /share-target are real HTTP requests. Without tryFiles they all 404.
|
||||
#
|
||||
# 2. root moves up to the vhost. It was inside locations."/", which left
|
||||
# /assets/, /sw.js and /manifest.json unresolvable once they needed their
|
||||
# own location blocks.
|
||||
#
|
||||
# It also no longer points into the repo. deploy.sh publishes the built
|
||||
# client to /var/www/ss/releases/<timestamp> and atomically repoints the
|
||||
# /var/www/ss/current symlink at it. Pointing nginx at that stable path
|
||||
# means a deploy is one rename -- nginx resolves the symlink per request,
|
||||
# so no reload is needed and no request sees a half-copied directory.
|
||||
# Rollback is the same rename pointing at an older release.
|
||||
#
|
||||
# 3. proxyWebsockets on /socket. recommendedProxySettings does NOT add the
|
||||
# Upgrade/Connection headers, so Socket.IO has been silently falling back
|
||||
# to long-polling -- that's the live note-sync path running degraded.
|
||||
#
|
||||
# 4. Cache headers. See the warning below.
|
||||
#
|
||||
# !! CLOUDFLARE !!
|
||||
# The tunnel sits in front of this and Cloudflare caches .js by DEFAULT, which
|
||||
# includes /sw.js. If the edge caches the service worker, the update check can
|
||||
# never see a new one and the app is pinned to an old build with no remote way
|
||||
# to recover. The nginx headers below are necessary but NOT sufficient --
|
||||
# add a Cloudflare Cache Rule that BYPASSES cache for /sw.js (and /index.html)
|
||||
# before cutting over.
|
||||
#
|
||||
# Also confirm which Host header cloudflared sends. This vhost is keyed on
|
||||
# "192.168.1.7"; if the tunnel forwards the public hostname instead, nginx will
|
||||
# not match this vhost and will fall through to the default server. Either add
|
||||
# the public hostname to serverAliases or set the tunnel's httpHostHeader.
|
||||
#
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
|
||||
virtualHosts."192.168.1.7" = {
|
||||
# serverAliases = [ "notes.example.com" ]; # if the tunnel forwards the public host
|
||||
|
||||
# Published by deploy.sh; a symlink to the live release. Not in the repo.
|
||||
root = "/var/www/ss/current";
|
||||
|
||||
locations."/" = {
|
||||
tryFiles = "$uri $uri/ /index.html";
|
||||
};
|
||||
|
||||
# Never let the HTTP layer cache the service worker or the app shell.
|
||||
locations."= /sw.js".extraConfig = ''
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
locations."= /index.html".extraConfig = ''
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
# The manifest changes rarely but should not be pinned for a year.
|
||||
locations."= /manifest.json".extraConfig = ''
|
||||
add_header Cache-Control "no-cache";
|
||||
expires -1;
|
||||
'';
|
||||
|
||||
# Vite content-hashes everything under /assets, so these are immutable.
|
||||
locations."/assets/".extraConfig = ''
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
'';
|
||||
|
||||
locations."/api" = {
|
||||
proxyPass = "http://localhost:3000";
|
||||
};
|
||||
|
||||
locations."/socket" = {
|
||||
proxyPass = "http://localhost:3001";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Note on file access: nginx runs as its own user and needs read access to the
|
||||
# web root, with +x on every parent directory. Serving from /var/www/ss is what
|
||||
# makes that a non-issue -- the one-time `chmod 755 /var/www /var/www/ss` in
|
||||
# deploy.sh is the whole of it. This is the reason the root is not the repo
|
||||
# clone: a clone under /home/mab would require loosening the permissions on the
|
||||
# home directory itself.
|
||||
#
|
||||
# Uploads are NOT here. Express serves them from REPO_PATH/staticFiles at
|
||||
# /api/static via the proxy below, so they are untouched by deploys.
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy Solid Scribe. Run this ON the server, from inside the repo.
|
||||
#
|
||||
# cd /path/to/SolidScribe && ./deploy.sh
|
||||
#
|
||||
# Replaces buildAndUpdateProd.sh, which built the old Vue 2 client and rsynced a
|
||||
# tarball to /home/mab/pi -- a directory that no longer exists.
|
||||
#
|
||||
# Assumes:
|
||||
# - this repo is a git clone with a tracking branch
|
||||
# - node + pnpm are on PATH for the user running this (on NixOS that means
|
||||
# environment.systemPackages or the user's profile -- NOT only inside a
|
||||
# nix-shell, or this will fail at the install step)
|
||||
# - pm2 is already managing NoteServer (pm2 resurrect on boot)
|
||||
# - nginx serves $WEBROOT/current with an SPA fallback to index.html
|
||||
#
|
||||
# nginx is NOT touched here. On NixOS it is declarative -- see configs/.
|
||||
#
|
||||
# --- the web root --------------------------------------------------------
|
||||
#
|
||||
# The built client is published to /var/www/ss, NOT served out of the repo.
|
||||
# nginx runs as its own user, so serving from a clone under /home/mab meant
|
||||
# loosening the home directory's permissions for every parent dir. /var/www is
|
||||
# the conventional place for this and sidesteps the whole problem.
|
||||
#
|
||||
# Layout, which is the standard symlink-swap pattern:
|
||||
#
|
||||
# /var/www/ss/
|
||||
# releases/2026-10-28_1147/ <- one directory per deploy
|
||||
# releases/2026-10-28_0930/
|
||||
# current -> releases/2026-10-28_1147
|
||||
#
|
||||
# nginx points at the stable path $WEBROOT/current. Publishing is a rename of
|
||||
# that symlink, which is a single atomic syscall: no request ever sees a
|
||||
# half-copied directory, and no window exists where the root is missing.
|
||||
# Rolling back is the same rename pointing at the previous release.
|
||||
#
|
||||
# Only the *client* moves. The API server still runs from the repo under pm2,
|
||||
# and user uploads are still served by Express from $REPO/staticFiles at
|
||||
# /api/static -- deliberately, so deploys never touch user data.
|
||||
#
|
||||
# ONE-TIME SETUP on a new server (needs root):
|
||||
#
|
||||
# sudo mkdir -p /var/www/ss/releases
|
||||
# sudo chown -R "$USER" /var/www/ss
|
||||
# sudo chmod 755 /var/www /var/www/ss
|
||||
#
|
||||
# Override the location for a staging box with: WEBROOT=/var/www/ss-staging ./deploy.sh
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
WEBROOT="${WEBROOT:-/var/www/ss}"
|
||||
RELEASE="$WEBROOT/releases/$(date +%Y-%m-%d_%H%M%S)"
|
||||
# Number of old releases to keep around for rollback.
|
||||
KEEP_RELEASES=5
|
||||
|
||||
step(){ printf '\n\033[32m::--:: %s\033[0m\n' "$1"; }
|
||||
|
||||
# --- sanity checks before changing anything ------------------------------
|
||||
|
||||
for tool in git pnpm pm2 rsync; do
|
||||
command -v "$tool" >/dev/null || { echo "$tool is not on PATH" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Fail here rather than after a three-minute build. A missing web root is
|
||||
# almost always a server that never had the one-time setup above run on it.
|
||||
if [ ! -d "$WEBROOT/releases" ]; then
|
||||
echo "$WEBROOT/releases does not exist -- run the one-time setup at the top of this script." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w "$WEBROOT/releases" ]; then
|
||||
echo "$WEBROOT/releases is not writable by $(whoami) -- check the chown in the one-time setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
echo "Working tree has local modifications. Commit or stash them first:" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- pull ----------------------------------------------------------------
|
||||
|
||||
step "Pulling $(git rev-parse --abbrev-ref HEAD)..."
|
||||
# --ff-only: refuse to create a merge commit on the server. If this fails the
|
||||
# server has diverged and wants a human.
|
||||
git pull --ff-only
|
||||
|
||||
# --- dependencies --------------------------------------------------------
|
||||
|
||||
step "Installing server dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
step "Installing client dependencies..."
|
||||
pnpm install --frozen-lockfile --dir client
|
||||
|
||||
# --- build ---------------------------------------------------------------
|
||||
|
||||
# dist is gitignored, so the server builds it. The build stays inside the repo
|
||||
# and is only copied to $WEBROOT once it has been checked, so a failed build
|
||||
# cannot touch what is currently being served.
|
||||
step "Building client..."
|
||||
rm -rf client/dist
|
||||
pnpm --dir client exec vite build
|
||||
|
||||
# manifest.json is checked in under client/public and copied verbatim by Vite;
|
||||
# if it is missing, the PWA installs without icons, scope or a share target.
|
||||
for required in index.html sw.js manifest.json; do
|
||||
if [ ! -f "client/dist/$required" ]; then
|
||||
echo "Build output looks wrong (missing $required) -- $WEBROOT left untouched." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- publish -------------------------------------------------------------
|
||||
|
||||
step "Publishing to $RELEASE..."
|
||||
mkdir -p "$RELEASE"
|
||||
# Trailing slash on the source: copy the *contents* of dist into the release
|
||||
# dir. --delete is harmless on a fresh dir but keeps a re-run of the same
|
||||
# timestamp honest.
|
||||
rsync -a --delete client/dist/ "$RELEASE/"
|
||||
|
||||
# nginx reads these as its own user, so they must be world-readable.
|
||||
chmod -R a+rX "$RELEASE"
|
||||
|
||||
step "Pointing $WEBROOT/current at the new release..."
|
||||
# Two steps because `ln -sfn` over an existing symlink unlinks before relinking,
|
||||
# which leaves a brief window with no web root at all. Creating a scratch
|
||||
# symlink and renaming it over the old one is a single atomic rename(2).
|
||||
ln -sfn "$RELEASE" "$WEBROOT/current.new"
|
||||
mv -T "$WEBROOT/current.new" "$WEBROOT/current"
|
||||
|
||||
# --- prune ---------------------------------------------------------------
|
||||
|
||||
# Keep the most recent $KEEP_RELEASES for rollback, drop the rest. Never remove
|
||||
# whatever `current` actually resolves to, however old it is.
|
||||
live="$(readlink -f "$WEBROOT/current")"
|
||||
ls -1d "$WEBROOT"/releases/*/ 2>/dev/null \
|
||||
| sort -r \
|
||||
| tail -n +$((KEEP_RELEASES + 1)) \
|
||||
| while read -r stale; do
|
||||
[ "$(readlink -f "$stale")" = "$live" ] && continue
|
||||
echo " pruning $(basename "$stale")"
|
||||
rm -rf "$stale"
|
||||
done
|
||||
|
||||
# --- restart -------------------------------------------------------------
|
||||
|
||||
step "Reloading the API server..."
|
||||
# reload, not restart: graceful in cluster mode. --update-env so changes to
|
||||
# ecosystem.config.js are picked up.
|
||||
pm2 reload "$REPO/server/ecosystem.config.js" --env production --update-env
|
||||
|
||||
# Persist the process list so `pm2 resurrect` on boot gets the current state.
|
||||
pm2 save
|
||||
|
||||
step "Done."
|
||||
echo " serving $(readlink -f "$WEBROOT/current")"
|
||||
echo
|
||||
echo " To roll back, repoint the symlink at the previous release:"
|
||||
echo " ls $WEBROOT/releases"
|
||||
echo " ln -sfn $WEBROOT/releases/<older> $WEBROOT/current.new && mv -T $WEBROOT/current.new $WEBROOT/current"
|
||||
echo
|
||||
pm2 list
|
||||
@@ -7,7 +7,10 @@ module.exports = {
|
||||
// args: 'one two',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: true,
|
||||
// Off by default so it is safe in production: with watch on, a `git pull`
|
||||
// that touches server files restarts pm2 mid-pull, potentially booting a
|
||||
// half-updated tree. startLocalDev.sh passes --watch for development.
|
||||
watch: false,
|
||||
ignore_watch : ["node_modules", "staticFiles"],
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
@@ -18,14 +21,4 @@ module.exports = {
|
||||
}
|
||||
}],
|
||||
|
||||
deploy : {
|
||||
production : {
|
||||
user : 'node',
|
||||
host : '212.83.163.1',
|
||||
ref : 'origin/master',
|
||||
repo : '[email protected]:repo.git',
|
||||
path : '/var/www/production',
|
||||
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -8,11 +8,17 @@ const ports = {
|
||||
}
|
||||
|
||||
//Allow user of @ in in require calls. Config in package.json
|
||||
require('module-alias/register')
|
||||
// Point module-alias at the repo root explicitly. The bare /register entry
|
||||
// locates package.json by walking up from its own directory, which lands
|
||||
// inside .pnpm/ under pnpm's symlinked store and fails to find it.
|
||||
require('module-alias')(require('path').join(__dirname, '..'))
|
||||
|
||||
//Auth helper, used for decoding users web token
|
||||
let Auth = require('@helpers/Auth')
|
||||
|
||||
//Note model, used to authorise socket room joins
|
||||
let Note = require('@models/Note')
|
||||
|
||||
//Helmet adds additional security to express server
|
||||
const helmet = require('helmet')
|
||||
|
||||
@@ -48,6 +54,14 @@ global.SocketIo = io
|
||||
|
||||
let noteDiffs = {}
|
||||
|
||||
//
|
||||
// Room names are namespaced. Previously note rooms were keyed on the bare
|
||||
// note_raw_text.id and user rooms on the bare user.id, sharing one flat
|
||||
// keyspace -- so user 7 and note_raw_text 7 were the same room.
|
||||
//
|
||||
const userRoom = userId => `user:${userId}`
|
||||
const noteRoom = rawTextId => `note:${rawTextId}`
|
||||
|
||||
io.on('connection', function(socket){
|
||||
|
||||
//When a user connects, add them to their own room
|
||||
@@ -56,11 +70,11 @@ io.on('connection', function(socket){
|
||||
socket.on('user_connect', token => {
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
socket.join(userData.userId)
|
||||
socket.join(userRoom(userData.userId))
|
||||
|
||||
//Track active logged in user accounts
|
||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
||||
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||
|
||||
}).catch(error => {
|
||||
//Don't add user to room if they are not logged in
|
||||
@@ -71,11 +85,11 @@ io.on('connection', function(socket){
|
||||
socket.on('get_active_user_count', token => {
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
socket.join(userData.userId)
|
||||
socket.join(userRoom(userData.userId))
|
||||
|
||||
//Track active logged in user accounts
|
||||
const usersInRoom = io.sockets.adapter.rooms[userData.userId]
|
||||
io.to(userData.userId).emit('update_active_user_count', usersInRoom.length)
|
||||
const usersInRoom = io.sockets.adapter.rooms[userRoom(userData.userId)]
|
||||
io.to(userRoom(userData.userId)).emit('update_active_user_count', usersInRoom.length)
|
||||
|
||||
}).catch(error => {
|
||||
// console.log(error)
|
||||
@@ -104,9 +118,30 @@ io.on('connection', function(socket){
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('join_room', rawTextId => {
|
||||
// Join user to rawtextid room when they enter
|
||||
socket.join(rawTextId)
|
||||
//
|
||||
// The client asks for a NOTE, not a room. The server authorises the request
|
||||
// against the session and derives the room itself, so a client can no longer
|
||||
// name an arbitrary room and be handed its buffered diffs.
|
||||
//
|
||||
socket.on('join_room', ({ noteId, token } = {}) => {
|
||||
|
||||
Auth.decodeToken(token)
|
||||
.then(userData => {
|
||||
|
||||
return Note.getRawTextIdForUser(noteId, userData.userId)
|
||||
.then(rawTextId => {
|
||||
|
||||
// No claim on this note -> no room, no diffs
|
||||
if(rawTextId == null){ return }
|
||||
|
||||
const room = noteRoom(rawTextId)
|
||||
|
||||
// Remember what this socket is allowed into, so leave_room and
|
||||
// note_diff never have to trust a client-supplied room again
|
||||
socket.authorizedRooms = socket.authorizedRooms || {}
|
||||
socket.authorizedRooms[noteId] = { room, rawTextId }
|
||||
|
||||
socket.join(room)
|
||||
|
||||
//If there are past diffs for this note, send them to the user
|
||||
if(noteDiffs[rawTextId] != undefined){
|
||||
@@ -118,53 +153,71 @@ io.on('connection', function(socket){
|
||||
socket.emit('past_diffs', noteDiffs[rawTextId])
|
||||
}
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[rawTextId]
|
||||
const usersInRoom = io.sockets.adapter.rooms[room]
|
||||
if(usersInRoom){
|
||||
//Update users in room count
|
||||
io.to(rawTextId).emit('update_user_count', usersInRoom.length)
|
||||
io.to(room).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
//Not logged in, or no access. Join nothing.
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('leave_room', roomId => {
|
||||
socket.leave(roomId)
|
||||
// console.log('User Left room')
|
||||
socket.on('leave_room', ({ noteId } = {}) => {
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[roomId]
|
||||
// Only rooms this socket was actually authorised into
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[noteId]
|
||||
if(!entry){ return }
|
||||
|
||||
socket.leave(entry.room)
|
||||
delete socket.authorizedRooms[noteId]
|
||||
|
||||
const usersInRoom = io.sockets.adapter.rooms[entry.room]
|
||||
if(usersInRoom){
|
||||
// console.log('Users in room', usersInRoom.length)
|
||||
io.to(roomId).emit('update_user_count', usersInRoom.length)
|
||||
io.to(entry.room).emit('update_user_count', usersInRoom.length)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
socket.on('note_diff', data => {
|
||||
socket.on('note_diff', (data = {}) => {
|
||||
|
||||
// Room comes from what this socket was authorised into, never from the
|
||||
// payload
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[data.noteId]
|
||||
if(!entry){ return }
|
||||
|
||||
const room = entry.room
|
||||
const rawTextId = entry.rawTextId
|
||||
|
||||
//Log each diff for note
|
||||
const noteId = data.id
|
||||
delete data.id
|
||||
if(noteDiffs[noteId] == undefined){ noteDiffs[noteId] = [] }
|
||||
data.time = +new Date
|
||||
if(noteDiffs[rawTextId] == undefined){ noteDiffs[rawTextId] = [] }
|
||||
|
||||
noteDiffs[noteId].push(data)
|
||||
// Carry the raw-text id through. This used to `delete data.id`, stripping
|
||||
// the only identifier before storing and broadcasting -- so a client with
|
||||
// several notes open on one socket could not tell which note a patch
|
||||
// belonged to, and applied every one to whichever editor it found first.
|
||||
const payload = Object.assign({}, data, { rawTextId, time: +new Date })
|
||||
delete payload.noteId
|
||||
|
||||
// Go over each user in this note-room
|
||||
io.in(noteId).clients((error, clients) => {
|
||||
if (error) throw error;
|
||||
noteDiffs[rawTextId].push(payload)
|
||||
|
||||
//Go through each client in note-room and send them the diff
|
||||
clients.forEach(socketId => {
|
||||
// only send off diff if user
|
||||
if(socketId != socket.id){
|
||||
io.to(socketId).emit('incoming_diff', data)
|
||||
}
|
||||
// Everyone in the note room except the sender
|
||||
socket.to(room).emit('incoming_diff', payload)
|
||||
})
|
||||
|
||||
});
|
||||
socket.on('truncate_diffs_at_save', (checkpoint = {}) => {
|
||||
|
||||
})
|
||||
|
||||
socket.on('truncate_diffs_at_save', checkpoint => {
|
||||
const entry = socket.authorizedRooms && socket.authorizedRooms[checkpoint.noteId]
|
||||
if(!entry){ return }
|
||||
checkpoint.rawTextId = entry.rawTextId
|
||||
|
||||
// NOTE: the comparison below is `diffSet[i].hash == checkpoint`, comparing
|
||||
// a hash string against the whole checkpoint object, so it never matches
|
||||
// and sliceTo stays 0 -- meaning the entire diff set is discarded at every
|
||||
// save. That happens to be the fail-safe outcome (the saved text becomes
|
||||
// the new baseline), and the correct slice direction for a real fix is
|
||||
// ambiguous, so it is deliberately left alone rather than guessed at.
|
||||
let diffSet = noteDiffs[checkpoint.rawTextId]
|
||||
if(diffSet && diffSet.length > 0){
|
||||
|
||||
|
||||
@@ -300,10 +300,10 @@ Attachment.scanTextForWebsites = (io, userId, noteId, noteText) => {
|
||||
Attachment.scrapeUrlsCreateAttachments(userId, noteId, foundUrls).then( freshlyScrapedText => {
|
||||
|
||||
//Once everything is done being scraped, emit new attachment events
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||
|
||||
// Tell user to update attachments with scraped text
|
||||
SocketIo.to(userId).emit('update_note_attachments')
|
||||
SocketIo.to(`user:${userId}`).emit('update_note_attachments')
|
||||
|
||||
solrAttachmentText += freshlyScrapedText
|
||||
resolve(solrAttachmentText)
|
||||
@@ -597,10 +597,10 @@ Attachment.pushUrl = (pushkey,url) => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
//Once everything is done being scraped, emit new attachment events
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||
|
||||
// Tell user to update attachments with scraped text
|
||||
SocketIo.to(userId).emit('update_note_attachments')
|
||||
SocketIo.to(`user:${userId}`).emit('update_note_attachments')
|
||||
}
|
||||
|
||||
return resolve(true)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
let db = require('@config/database')
|
||||
|
||||
let Note = require('@models/Note')
|
||||
|
||||
let CycleTracking = module.exports = {}
|
||||
|
||||
|
||||
CycleTracking.get = (userId, masterKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
db.promise()
|
||||
.query(`
|
||||
SELECT note.id FROM note WHERE quick_note = 2 AND user_id = ? LIMIT 1`, [userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
//Quick Note is set, return note object
|
||||
if(rows[0][0] != undefined){
|
||||
|
||||
let noteId = rows[0][0].id
|
||||
const note = Note.get(userId, noteId, masterKey)
|
||||
.then(noteData => {
|
||||
return resolve(noteData)
|
||||
})
|
||||
|
||||
} else {
|
||||
//Or create a new note
|
||||
let finalId = null
|
||||
return Note.create(userId, 'Cycle Tracking', '', masterKey)
|
||||
.then(insertedId => {
|
||||
finalId = insertedId
|
||||
db.promise().query('UPDATE note SET quick_note = 2 WHERE id = ? AND user_id = ?',[insertedId, userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
const note = Note.get(userId, finalId, masterKey)
|
||||
.then(noteData => {
|
||||
return resolve(noteData)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
.catch(console.log)
|
||||
})
|
||||
}
|
||||
|
||||
CycleTracking.save = (userId, cycleData, masterKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
let finalId = null
|
||||
|
||||
CycleTracking.get(userId, masterKey)
|
||||
.then(noteObject => {
|
||||
|
||||
return Note.update(userId, noteObject.id, cycleData, noteObject.title, noteObject.color, noteObject.pinned, noteObject.archived, null, masterKey)
|
||||
|
||||
})
|
||||
.then( saveResults => {
|
||||
return resolve(saveResults)
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
@@ -199,7 +199,7 @@ Note.create = (userId, noteTitle = '', noteText = '', masterKey) => {
|
||||
.then((rows, fields) => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
SocketIo.to(userId).emit('new_note_created', rows[0].insertId)
|
||||
SocketIo.to(`user:${userId}`).emit('new_note_created', rows[0].insertId)
|
||||
}
|
||||
|
||||
// Indexing is done on save
|
||||
@@ -490,7 +490,7 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
||||
}
|
||||
db.promise().query('UPDATE note SET snippet = ? WHERE id = ?', [updatedSnippet, otherNote.id])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(otherNote['user_id']).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
||||
SocketIo.to(`user:${otherNote['user_id']}`).emit('new_note_text_saved', {'noteId':otherNote.id, hash})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -514,7 +514,7 @@ Note.update = (userId, noteId, noteText, noteTitle, color, pinned, archived, has
|
||||
.then((rows, fields) => {
|
||||
|
||||
if(typeof SocketIo != 'undefined'){
|
||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId, hash})
|
||||
SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId, hash})
|
||||
|
||||
//Async attachment reindex
|
||||
Attachment.scanTextForWebsites(SocketIo, userId, noteId, noteText)
|
||||
@@ -538,7 +538,7 @@ Note.setPinned = (userId, noteId, pinnedBoolean) => {
|
||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET pinned = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||
[pinned, now, noteId, userId])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -555,7 +555,7 @@ Note.setArchived = (userId, noteId, archivedBoolead) => {
|
||||
.query('UPDATE note JOIN note_raw_text ON note_raw_text_id = note_raw_text.id SET archived = ?, updated = ? WHERE note.id = ? AND user_id = ?',
|
||||
[archived, now, noteId, userId])
|
||||
.then((rows, fields) => {
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -581,7 +581,7 @@ Note.setTrashed = (userId, noteId, trashedBoolean, masterKey) => {
|
||||
Note.reindex(userId, masterKey, removeFromIndex)
|
||||
|
||||
|
||||
SocketIo.to(userId).emit('note_attribute_modified', noteId)
|
||||
SocketIo.to(`user:${userId}`).emit('note_attribute_modified', noteId)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -653,7 +653,7 @@ Note.delete = (userId, noteId, masterKey = null) => {
|
||||
}
|
||||
})
|
||||
|
||||
SocketIo.to(userId).emit('update_counts')
|
||||
SocketIo.to(`user:${userId}`).emit('update_counts')
|
||||
|
||||
if(masterKey){
|
||||
//Remove note ID from index
|
||||
@@ -1191,3 +1191,29 @@ Note.search = (userId, searchQuery, searchTags, fastFilters, masterKey) => {
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// Resolve a note's raw-text id for a given user.
|
||||
// Doubles as the access check: a user with no claim on this note gets null,
|
||||
// so authorisation and room derivation are the same lookup.
|
||||
//
|
||||
Note.getRawTextIdForUser = (noteId, userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const id = parseInt(noteId, 10)
|
||||
if(!Number.isInteger(id) || id < 1){ return resolve(null) }
|
||||
|
||||
db.promise()
|
||||
.query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ? LIMIT 1', [id, userId])
|
||||
.then((rows) => {
|
||||
|
||||
if(!rows[0] || rows[0].length == 0){ return resolve(null) }
|
||||
|
||||
return resolve( rows[0][0]['note_raw_text_id'] )
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
return resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -75,7 +75,7 @@ ShareNote.addUserToSharedNote = (userId, noteId, shareUserId, masterKey) => {
|
||||
const sharedUserNoteId = rows[0]['insertId']
|
||||
|
||||
//Emit update count event to user shared with - so they see the note in real time
|
||||
SocketIo.to(shareUserId).emit('update_counts')
|
||||
SocketIo.to(`user:${shareUserId}`).emit('update_counts')
|
||||
|
||||
let success = true
|
||||
return resolve({success, shareUserId, sharedUserNoteId})
|
||||
|
||||
@@ -58,7 +58,7 @@ Tag.addToNote = (userId, noteId, tagText) => {
|
||||
Tag.associateWithNote(userId, noteId, newTagId)
|
||||
.then( result => {
|
||||
|
||||
SocketIo.to(userId).emit('new_note_text_saved', {noteId})
|
||||
SocketIo.to(`user:${userId}`).emit('new_note_text_saved', {noteId})
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,208 +1,210 @@
|
||||
const crypto = require('crypto')
|
||||
const crypto = require("crypto");
|
||||
|
||||
const Note = require('@models/Note')
|
||||
const Note = require("@models/Note");
|
||||
|
||||
const db = require('@config/database')
|
||||
const Auth = require('@helpers/Auth')
|
||||
const cs = require('@helpers/CryptoString')
|
||||
const speakeasy = require('speakeasy')
|
||||
const db = require("@config/database");
|
||||
const Auth = require("@helpers/Auth");
|
||||
const cs = require("@helpers/CryptoString");
|
||||
const speakeasy = require("speakeasy");
|
||||
|
||||
let User = module.exports = {}
|
||||
let User = (module.exports = {});
|
||||
|
||||
const version = '3.8.0'
|
||||
const version = "4.0.0";
|
||||
// 3.7.3 - diff/patch update
|
||||
|
||||
//Login a user, if that user does not exist create them
|
||||
//Issues login token
|
||||
User.login = (username, password, authToken = null) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const lowerName = username.toLowerCase()
|
||||
const lowerName = username.toLowerCase();
|
||||
|
||||
let statusObject = {
|
||||
success: false,
|
||||
token: null,
|
||||
userId: null,
|
||||
verificationRequired: false,
|
||||
message: 'Incorrect Username or Password'
|
||||
}
|
||||
message: "Incorrect Username or Password",
|
||||
};
|
||||
|
||||
db.promise()
|
||||
.query('SELECT * FROM user WHERE username = ? LIMIT 1', [lowerName])
|
||||
.query("SELECT * FROM user WHERE username = ? LIMIT 1", [lowerName])
|
||||
.then((rows, fields) => {
|
||||
|
||||
//
|
||||
// Login User
|
||||
//
|
||||
if (rows[0].length == 1) {
|
||||
|
||||
//Pull out user data from database results
|
||||
const lookedUpUser = rows[0][0]
|
||||
const lookedUpUser = rows[0][0];
|
||||
|
||||
//Verify Token if set
|
||||
const tokenValidates = speakeasy.totp.verify({
|
||||
'secret': lookedUpUser['two_fa_secret'],
|
||||
'encoding': 'base32',
|
||||
'token': authToken,
|
||||
'window': 2
|
||||
})
|
||||
secret: lookedUpUser["two_fa_secret"],
|
||||
encoding: "base32",
|
||||
token: authToken,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (lookedUpUser.two_fa_enabled == 1 && !authToken) {
|
||||
statusObject["verificationRequired"] = true;
|
||||
statusObject["message"] = "2FA authentication required.";
|
||||
|
||||
statusObject['verificationRequired'] = true
|
||||
statusObject['message'] = '2FA authentication required.'
|
||||
|
||||
return resolve(statusObject)
|
||||
return resolve(statusObject);
|
||||
}
|
||||
|
||||
if (lookedUpUser.two_fa_enabled == 1 && !tokenValidates) {
|
||||
statusObject["verificationRequired"] = true;
|
||||
statusObject["message"] = "Invalid Authorization Token.";
|
||||
|
||||
statusObject['verificationRequired'] = true
|
||||
statusObject['message'] = 'Invalid Authorization Token.'
|
||||
|
||||
return resolve(statusObject)
|
||||
return resolve(statusObject);
|
||||
}
|
||||
|
||||
if(lookedUpUser.two_fa_enabled == 0 || (lookedUpUser.two_fa_enabled == 1 && tokenValidates) ){
|
||||
|
||||
if (
|
||||
lookedUpUser.two_fa_enabled == 0 ||
|
||||
(lookedUpUser.two_fa_enabled == 1 && tokenValidates)
|
||||
) {
|
||||
//hash the password and check for a match
|
||||
|
||||
const salt = Buffer.from(lookedUpUser.salt, 'binary')
|
||||
crypto.pbkdf2(password, salt, lookedUpUser.iterations, 512, 'sha512', function(err, delivered_key){
|
||||
if(delivered_key.toString('hex') === lookedUpUser.password){
|
||||
|
||||
const salt = Buffer.from(lookedUpUser.salt, "binary");
|
||||
crypto.pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
lookedUpUser.iterations,
|
||||
512,
|
||||
"sha512",
|
||||
function (err, delivered_key) {
|
||||
if (delivered_key.toString("hex") === lookedUpUser.password) {
|
||||
User.generateMasterKey(lookedUpUser.id, password)
|
||||
.then( result => User.getMasterKey(lookedUpUser.id, password))
|
||||
.then(masterKey => {
|
||||
|
||||
User.generateKeypair(lookedUpUser.id, masterKey)
|
||||
.then(({publicKey, privateKey}) => {
|
||||
|
||||
.then((result) =>
|
||||
User.getMasterKey(lookedUpUser.id, password),
|
||||
)
|
||||
.then((masterKey) => {
|
||||
User.generateKeypair(lookedUpUser.id, masterKey).then(
|
||||
({ publicKey, privateKey }) => {
|
||||
//Passback a json web token
|
||||
Auth.createToken(lookedUpUser.id, masterKey)
|
||||
.then(token => {
|
||||
|
||||
statusObject['token'] = token
|
||||
statusObject['userId'] = lookedUpUser.id
|
||||
statusObject['success'] = true
|
||||
|
||||
return resolve(statusObject)
|
||||
})
|
||||
})
|
||||
})
|
||||
Auth.createToken(lookedUpUser.id, masterKey).then(
|
||||
(token) => {
|
||||
statusObject["token"] = token;
|
||||
statusObject["userId"] = lookedUpUser.id;
|
||||
statusObject["success"] = true;
|
||||
|
||||
return resolve(statusObject);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return resolve(statusObject)
|
||||
return resolve(statusObject);
|
||||
}
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
//If user is not found, say two factor authentication is required
|
||||
statusObject['verificationRequired'] = true
|
||||
statusObject['message'] = '2FA authentication required.'
|
||||
statusObject["verificationRequired"] = true;
|
||||
statusObject["message"] = "2FA authentication required.";
|
||||
|
||||
//Show fake auth token message
|
||||
if (authToken) {
|
||||
statusObject['message'] = 'Invalid Authorization Token.'
|
||||
statusObject["message"] = "Invalid Authorization Token.";
|
||||
}
|
||||
|
||||
return resolve(statusObject)
|
||||
return resolve(statusObject);
|
||||
}
|
||||
})
|
||||
.catch(console.log)
|
||||
|
||||
})
|
||||
}
|
||||
.catch(console.log);
|
||||
});
|
||||
};
|
||||
|
||||
//Create user account
|
||||
//Issues login token
|
||||
User.register = (username, password) => {
|
||||
|
||||
//For some reason, username won't get into the promise. But password will @TODO figure this out
|
||||
const lowerName = username.toLowerCase().trim()
|
||||
const lowerName = username.toLowerCase().trim();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
db.promise()
|
||||
.query('SELECT * FROM user WHERE username = ? LIMIT 1', [lowerName])
|
||||
.query("SELECT * FROM user WHERE username = ? LIMIT 1", [lowerName])
|
||||
.then((rows, fields) => {
|
||||
|
||||
if(rows[0].length === 0){ //No users returned, create new one. Start with hashing password
|
||||
if (rows[0].length === 0) {
|
||||
//No users returned, create new one. Start with hashing password
|
||||
|
||||
//Params for hash function
|
||||
let shasum = crypto.createHash('sha512') //Prepare Hash
|
||||
const ran = parseInt(Date.now()) //Get current time in miliseconds
|
||||
const semiRandomInt = Math.floor(Math.random()*11) //Grab a random number
|
||||
const otherRandomInt = (ran*semiRandomInt+ran)*semiRandomInt-ran //Mix things up a bit
|
||||
shasum.update(''+otherRandomInt) //Update Hasd
|
||||
let shasum = crypto.createHash("sha512"); //Prepare Hash
|
||||
const ran = parseInt(Date.now()); //Get current time in miliseconds
|
||||
const semiRandomInt = Math.floor(Math.random() * 11); //Grab a random number
|
||||
const otherRandomInt =
|
||||
(ran * semiRandomInt + ran) * semiRandomInt - ran; //Mix things up a bit
|
||||
shasum.update("" + otherRandomInt); //Update Hasd
|
||||
|
||||
const saltString = shasum.digest('hex')
|
||||
const salt = Buffer.from(saltString, 'binary') //Generate Salt hash
|
||||
const iterations = 25000
|
||||
|
||||
crypto.pbkdf2(password, salt, iterations, 512, 'sha512', function(err, delivered_key) {
|
||||
const saltString = shasum.digest("hex");
|
||||
const salt = Buffer.from(saltString, "binary"); //Generate Salt hash
|
||||
const iterations = 25000;
|
||||
|
||||
crypto.pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
512,
|
||||
"sha512",
|
||||
function (err, delivered_key) {
|
||||
//Create new user object with freshly salted password
|
||||
var currentDate = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
var currentDate = new Date()
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
var new_user = {
|
||||
username: lowerName,
|
||||
password: delivered_key.toString('hex'),
|
||||
password: delivered_key.toString("hex"),
|
||||
salt: salt,
|
||||
iterations: iterations,
|
||||
last_login: currentDate,
|
||||
created: currentDate
|
||||
created: currentDate,
|
||||
};
|
||||
|
||||
let userId = null
|
||||
let newMasterKey = null
|
||||
let userId = null;
|
||||
let newMasterKey = null;
|
||||
|
||||
db.promise()
|
||||
.query('INSERT INTO user SET ?', new_user)
|
||||
.query("INSERT INTO user SET ?", new_user)
|
||||
.then((rows, fields) => {
|
||||
|
||||
userId = rows[0].insertId
|
||||
return User.generateMasterKey(userId, password)
|
||||
userId = rows[0].insertId;
|
||||
return User.generateMasterKey(userId, password);
|
||||
})
|
||||
.then( result => {
|
||||
|
||||
return User.getMasterKey(userId, password)
|
||||
.then((result) => {
|
||||
return User.getMasterKey(userId, password);
|
||||
})
|
||||
.then(masterKey => {
|
||||
newMasterKey = masterKey
|
||||
return User.generateKeypair(userId, newMasterKey)
|
||||
.then((masterKey) => {
|
||||
newMasterKey = masterKey;
|
||||
return User.generateKeypair(userId, newMasterKey);
|
||||
})
|
||||
.then(({ publicKey, privateKey }) => {
|
||||
|
||||
return Auth.createToken(userId, newMasterKey)
|
||||
return Auth.createToken(userId, newMasterKey);
|
||||
})
|
||||
.then(token => {
|
||||
|
||||
return resolve({token, userId})
|
||||
})
|
||||
.catch(console.log)
|
||||
.then((token) => {
|
||||
return resolve({ token, userId });
|
||||
})
|
||||
.catch(console.log);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return reject('Username already in use.')
|
||||
return reject("Username already in use.");
|
||||
} //END user create
|
||||
})
|
||||
.catch(console.log)
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
.catch(console.log);
|
||||
});
|
||||
};
|
||||
|
||||
//Counts notes, pinned notes, archived notes, shared notes, unread notes, total files and types
|
||||
User.getCounts = (userId, extendedOptions) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
let countTotals = {
|
||||
tags: {}
|
||||
}
|
||||
tags: {},
|
||||
};
|
||||
// const userHash = cs.hash(String(userId)).toString('base64')
|
||||
|
||||
db.promise().query(
|
||||
db.promise()
|
||||
.query(
|
||||
`SELECT
|
||||
SUM(archived = 1 && share_user_id IS NULL && trashed = 0) AS archivedNotes,
|
||||
SUM(trashed = 1) AS trashedNotes,
|
||||
@@ -211,22 +213,23 @@ User.getCounts = (userId, extendedOptions) => {
|
||||
SUM(share_user_id != ? && trashed = 0) AS sharedToNotes
|
||||
FROM note
|
||||
LEFT JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
|
||||
WHERE user_id = ?`, [userId, userId, userId, userId])
|
||||
WHERE user_id = ?`,
|
||||
[userId, userId, userId, userId],
|
||||
)
|
||||
.then((rows, fields) => {
|
||||
|
||||
Object.assign(countTotals, rows[0][0]) //combine results
|
||||
Object.assign(countTotals, rows[0][0]); //combine results
|
||||
//
|
||||
// @TODO - Figured out if this is useful
|
||||
// We want, notes shared with user and note user has shared
|
||||
//
|
||||
return db.promise().query(
|
||||
`SELECT count(id) AS sharedFromNotes
|
||||
FROM note WHERE shared = 2 AND user_id = ? AND trashed = 0`, [userId]
|
||||
)
|
||||
FROM note WHERE shared = 2 AND user_id = ? AND trashed = 0`,
|
||||
[userId],
|
||||
);
|
||||
})
|
||||
.then((rows, fields) => {
|
||||
|
||||
Object.assign(countTotals, rows[0][0]) //combine results
|
||||
Object.assign(countTotals, rows[0][0]); //combine results
|
||||
|
||||
return db.promise().query(
|
||||
`SELECT
|
||||
@@ -235,20 +238,26 @@ User.getCounts = (userId, extendedOptions) => {
|
||||
COUNT(id) as totalFiles
|
||||
FROM attachment WHERE visible = 1
|
||||
AND user_id = ?
|
||||
`, [userId]
|
||||
)
|
||||
}).then( (rows, fields) => {
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
})
|
||||
.then((rows, fields) => {
|
||||
Object.assign(countTotals, rows[0][0]); //combine results
|
||||
|
||||
Object.assign(countTotals, rows[0][0]) //combine results
|
||||
|
||||
return db.promise().query('SELECT id AS quickNote FROM note WHERE quick_note = 1 AND user_id = ?', [userId])
|
||||
|
||||
}).then( (rows, fields) => {
|
||||
|
||||
Object.assign(countTotals, rows[0][0]) //combine results
|
||||
return db
|
||||
.promise()
|
||||
.query(
|
||||
"SELECT id AS quickNote FROM note WHERE quick_note = 1 AND user_id = ?",
|
||||
[userId],
|
||||
);
|
||||
})
|
||||
.then((rows, fields) => {
|
||||
Object.assign(countTotals, rows[0][0]); //combine results
|
||||
|
||||
//Count usages of user tags, sort by most popular
|
||||
return db.promise().query(`
|
||||
return db.promise().query(
|
||||
`
|
||||
SELECT
|
||||
tag.text, COUNT(tag_id) AS uses, tag.id
|
||||
FROM note_tag
|
||||
@@ -257,337 +266,345 @@ User.getCounts = (userId, extendedOptions) => {
|
||||
GROUP BY tag_id
|
||||
ORDER BY uses DESC
|
||||
LIMIT 16
|
||||
`, [userId])
|
||||
|
||||
}).then( (rows, fields) => {
|
||||
|
||||
|
||||
|
||||
//Convert everything to an int or 0
|
||||
Object.keys(countTotals).forEach( key => {
|
||||
const count = parseInt(countTotals[key])
|
||||
countTotals[key] = count ? count : 0
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
})
|
||||
.then((rows, fields) => {
|
||||
//Convert everything to an int or 0
|
||||
Object.keys(countTotals).forEach((key) => {
|
||||
const count = parseInt(countTotals[key]);
|
||||
countTotals[key] = count ? count : 0;
|
||||
});
|
||||
|
||||
//Build out tags object
|
||||
let tagsObject = {}
|
||||
rows[0].forEach(tagRow => {
|
||||
tagsObject[tagRow['text']] = {'id':tagRow.id, 'uses':tagRow.uses}
|
||||
})
|
||||
let tagsObject = {};
|
||||
rows[0].forEach((tagRow) => {
|
||||
tagsObject[tagRow["text"]] = { id: tagRow.id, uses: tagRow.uses };
|
||||
});
|
||||
|
||||
//Assign after counts are updated
|
||||
countTotals['tags'] = tagsObject
|
||||
countTotals["tags"] = tagsObject;
|
||||
|
||||
countTotals['currentVersion'] = version
|
||||
countTotals["currentVersion"] = version;
|
||||
|
||||
// Allow for extended options set on page load
|
||||
if (extendedOptions) {
|
||||
|
||||
db.promise().query(
|
||||
db.promise()
|
||||
.query(
|
||||
`SELECT updated FROM note
|
||||
JOIN note_raw_text ON note_raw_text.id = note.note_raw_text_id
|
||||
WHERE note.quick_note = 2
|
||||
AND user_id = ?`, [userId])
|
||||
AND user_id = ?`,
|
||||
[userId],
|
||||
)
|
||||
.then((rows, fields) => {
|
||||
|
||||
|
||||
|
||||
if (rows[0][0] && rows[0][0].updated) {
|
||||
const lastOpened = rows[0][0].updated
|
||||
const timeDiff = Math.round(((+new Date) - (lastOpened))/1000)
|
||||
const hoursInSeconds = (12 * 60 * 60) //12 hours
|
||||
const lastOpened = rows[0][0].updated;
|
||||
const timeDiff = Math.round((+new Date() - lastOpened) / 1000);
|
||||
const hoursInSeconds = 12 * 60 * 60; //12 hours
|
||||
|
||||
// Show metric tracking button if its been 12 hours since last entry
|
||||
if (lastOpened && timeDiff > hoursInSeconds) {
|
||||
countTotals['showTrackMetricsButton'] = true
|
||||
countTotals["showTrackMetricsButton"] = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
resolve(countTotals)
|
||||
})
|
||||
|
||||
|
||||
resolve(countTotals);
|
||||
});
|
||||
} else {
|
||||
resolve(countTotals)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
resolve(countTotals);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
//Log out user by deleting login token for that active session
|
||||
User.logout = (sessionId) => {
|
||||
console.log('Terminate Session -> ', sessionId)
|
||||
return db.promise().query('DELETE FROM user_active_session WHERE (session_id = ?)', [sessionId])
|
||||
}
|
||||
console.log("Terminate Session -> ", sessionId);
|
||||
return db
|
||||
.promise()
|
||||
.query("DELETE FROM user_active_session WHERE (session_id = ?)", [
|
||||
sessionId,
|
||||
]);
|
||||
};
|
||||
|
||||
User.generateMasterKey = (userId, password) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (!userId || !password) {
|
||||
reject('Need userId and password to generate key')
|
||||
reject("Need userId and password to generate key");
|
||||
}
|
||||
|
||||
db.promise()
|
||||
.query('SELECT count(id) as total FROM user_key WHERE user_id = ?', [userId])
|
||||
.query("SELECT count(id) as total FROM user_key WHERE user_id = ?", [
|
||||
userId,
|
||||
])
|
||||
.then((rows, fields) => {
|
||||
|
||||
//Entry already exists, you good.
|
||||
if(rows[0][0]['total'] > 0){
|
||||
return resolve(true)
|
||||
if (rows[0][0]["total"] > 0) {
|
||||
return resolve(true);
|
||||
// throw new Error('User Encryption key already exists')
|
||||
} else {
|
||||
// Generate user key, its big and random
|
||||
const masterPassword = cs.createSmallSalt()
|
||||
const masterPassword = cs.createSmallSalt();
|
||||
|
||||
//Generate a salt because it wants it
|
||||
const salt = cs.createSmallSalt()
|
||||
const salt = cs.createSmallSalt();
|
||||
|
||||
// Encrypt master password
|
||||
const encryptedMasterPassword = cs.encrypt(password, salt, masterPassword)
|
||||
const encryptedMasterPassword = cs.encrypt(
|
||||
password,
|
||||
salt,
|
||||
masterPassword,
|
||||
);
|
||||
|
||||
const created = Math.round((+new Date)/1000)
|
||||
const created = Math.round(+new Date() / 1000);
|
||||
|
||||
db.promise()
|
||||
.query(
|
||||
'INSERT INTO user_key (`user_id`, `salt`, `key`, `created`) VALUES (?, ?, ?, ?);',
|
||||
[userId, salt, encryptedMasterPassword, created]
|
||||
"INSERT INTO user_key (`user_id`, `salt`, `key`, `created`) VALUES (?, ?, ?, ?);",
|
||||
[userId, salt, encryptedMasterPassword, created],
|
||||
)
|
||||
.then(results => {
|
||||
return resolve(true)
|
||||
})
|
||||
.then((results) => {
|
||||
return resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Create Master Password Error')
|
||||
console.log(error)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
.catch((error) => {
|
||||
console.log("Create Master Password Error");
|
||||
console.log(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.getMasterKey = (userId, password) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (!userId || !password) {
|
||||
reject('Need userId and password to fetch key')
|
||||
reject("Need userId and password to fetch key");
|
||||
}
|
||||
|
||||
db.promise().query('SELECT * FROM user_key WHERE user_id = ? LIMIT 1', [userId])
|
||||
db.promise()
|
||||
.query("SELECT * FROM user_key WHERE user_id = ? LIMIT 1", [userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
const row = rows[0][0]
|
||||
const row = rows[0][0];
|
||||
|
||||
if (!rows[0] || rows[0].length == 0 || rows[0][0] == undefined) {
|
||||
return reject('Row or salt or something not set')
|
||||
return reject("Row or salt or something not set");
|
||||
}
|
||||
|
||||
const masterKey = cs.decrypt(password, row['salt'], row['key'])
|
||||
const masterKey = cs.decrypt(password, row["salt"], row["key"]);
|
||||
|
||||
if (masterKey == null) {
|
||||
return reject('Unable to decrypt key')
|
||||
return reject("Unable to decrypt key");
|
||||
}
|
||||
|
||||
return resolve(masterKey)
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
return resolve(masterKey);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.generateKeypair = (userId, masterKey) => {
|
||||
|
||||
let publicKey = null
|
||||
let privateKey = null
|
||||
let publicKey = null;
|
||||
let privateKey = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
db.promise().query('SELECT * FROM user_key WHERE user_id = ?', [userId])
|
||||
db.promise()
|
||||
.query("SELECT * FROM user_key WHERE user_id = ?", [userId])
|
||||
.then((rows, fields) => {
|
||||
const row = rows[0][0];
|
||||
|
||||
const row = rows[0][0]
|
||||
const salt = row["salt"];
|
||||
publicKey = row["public_key"];
|
||||
privateKey = row["private_key_encrypted"];
|
||||
|
||||
const salt = row['salt']
|
||||
publicKey = row['public_key']
|
||||
privateKey = row['private_key_encrypted']
|
||||
|
||||
if(row['public_key'] == null){
|
||||
const keyPair = crypto.generateKeyPairSync('rsa', {
|
||||
if (row["public_key"] == null) {
|
||||
const keyPair = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 1024,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
type: "spki",
|
||||
format: "pem",
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
})
|
||||
type: "pkcs8",
|
||||
format: "pem",
|
||||
},
|
||||
});
|
||||
|
||||
publicKey = keyPair.publicKey
|
||||
privateKey = keyPair.privateKey
|
||||
const privateKeyEncrypted = cs.encrypt(masterKey, salt, privateKey)
|
||||
publicKey = keyPair.publicKey;
|
||||
privateKey = keyPair.privateKey;
|
||||
const privateKeyEncrypted = cs.encrypt(masterKey, salt, privateKey);
|
||||
|
||||
db.promise()
|
||||
.query(
|
||||
'UPDATE user_key SET `public_key` = ?, `private_key_encrypted` = ? WHERE user_id = ?;',
|
||||
[publicKey, privateKeyEncrypted, userId]
|
||||
"UPDATE user_key SET `public_key` = ?, `private_key_encrypted` = ? WHERE user_id = ?;",
|
||||
[publicKey, privateKeyEncrypted, userId],
|
||||
)
|
||||
.then((rows, fields) => {
|
||||
|
||||
return resolve({publicKey, privateKey})
|
||||
|
||||
})
|
||||
|
||||
return resolve({ publicKey, privateKey });
|
||||
});
|
||||
} else {
|
||||
|
||||
//Decrypt private key
|
||||
privateKey = cs.decrypt(masterKey, salt, privateKey)
|
||||
privateKey = cs.decrypt(masterKey, salt, privateKey);
|
||||
|
||||
return resolve({publicKey, privateKey})
|
||||
}
|
||||
})
|
||||
})
|
||||
return resolve({ publicKey, privateKey });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.getPublicKey = (userId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.promise().query('SELECT public_key FROM user_key WHERE user_id = ?', [userId])
|
||||
db.promise()
|
||||
.query("SELECT public_key FROM user_key WHERE user_id = ?", [userId])
|
||||
.then((rows, fields) => {
|
||||
|
||||
const row = rows[0][0]
|
||||
return resolve(row['public_key'])
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
const row = rows[0][0];
|
||||
return resolve(row["public_key"]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.getPrivateKey = (userId, masterKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.promise().query('SELECT salt, private_key_encrypted FROM user_key WHERE user_id = ?', [userId])
|
||||
db.promise()
|
||||
.query(
|
||||
"SELECT salt, private_key_encrypted FROM user_key WHERE user_id = ?",
|
||||
[userId],
|
||||
)
|
||||
.then((rows, fields) => {
|
||||
const row = rows[0][0];
|
||||
|
||||
const row = rows[0][0]
|
||||
|
||||
const salt = row['salt']
|
||||
privateKey = row['private_key_encrypted']
|
||||
const salt = row["salt"];
|
||||
privateKey = row["private_key_encrypted"];
|
||||
|
||||
//Decrypt private key
|
||||
privateKey = cs.decrypt(masterKey, salt, privateKey)
|
||||
privateKey = cs.decrypt(masterKey, salt, privateKey);
|
||||
|
||||
return resolve(privateKey)
|
||||
})
|
||||
})
|
||||
}
|
||||
return resolve(privateKey);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.getByUserName = (username) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.promise().query('SELECT * FROM user WHERE username = ? LIMIT 1', [username.toLowerCase()])
|
||||
db.promise()
|
||||
.query("SELECT * FROM user WHERE username = ? LIMIT 1", [
|
||||
username.toLowerCase(),
|
||||
])
|
||||
.then((rows, fields) => {
|
||||
resolve(rows[0][0])
|
||||
})
|
||||
})
|
||||
}
|
||||
resolve(rows[0][0]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.changePassword = (userId, oldPass, newPass) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
User.getMasterKey(userId, oldPass)
|
||||
.then(masterKey => {
|
||||
User.getPrivateKey(userId, masterKey)
|
||||
.then(privateKey => {
|
||||
.then((masterKey) => {
|
||||
User.getPrivateKey(userId, masterKey).then((privateKey) => {
|
||||
//If success, user has correct password
|
||||
|
||||
// Generate new master pass, encrypt with new password
|
||||
// const masterPassword = cs.createSmallSalt()
|
||||
const salt = cs.createSmallSalt()
|
||||
const encryptedMasterPassword = cs.encrypt(newPass, salt, masterKey)
|
||||
const encryptedPrivateKey = cs.encrypt(masterKey, salt, privateKey)
|
||||
const salt = cs.createSmallSalt();
|
||||
const encryptedMasterPassword = cs.encrypt(newPass, salt, masterKey);
|
||||
const encryptedPrivateKey = cs.encrypt(masterKey, salt, privateKey);
|
||||
|
||||
db.promise()
|
||||
.query(
|
||||
'UPDATE user_key SET salt = ?, `key` = ?, private_key_encrypted = ? WHERE user_id = ? LIMIT 1',
|
||||
[salt, encryptedMasterPassword, encryptedPrivateKey, userId]
|
||||
).then((r,f) => {
|
||||
//Create login using password
|
||||
let shasum = crypto.createHash('sha512') //Prepare Hash
|
||||
const saltString = shasum.digest('hex')
|
||||
const passwordSalt = Buffer.from(saltString, 'binary') //Generate Salt hash
|
||||
const iterations = 25000
|
||||
|
||||
crypto.pbkdf2(newPass, passwordSalt, iterations, 512, 'sha512', function(err, delivered_key) {
|
||||
|
||||
const deliveredPass = delivered_key.toString('hex')
|
||||
|
||||
db.promise().query('UPDATE user SET password = ?, salt = ? WHERE id = ? LIMIT 1', [deliveredPass, passwordSalt, userId])
|
||||
"UPDATE user_key SET salt = ?, `key` = ?, private_key_encrypted = ? WHERE user_id = ? LIMIT 1",
|
||||
[salt, encryptedMasterPassword, encryptedPrivateKey, userId],
|
||||
)
|
||||
.then((r, f) => {
|
||||
return resolve(true)
|
||||
})
|
||||
//Create login using password
|
||||
let shasum = crypto.createHash("sha512"); //Prepare Hash
|
||||
const saltString = shasum.digest("hex");
|
||||
const passwordSalt = Buffer.from(saltString, "binary"); //Generate Salt hash
|
||||
const iterations = 25000;
|
||||
|
||||
})
|
||||
})
|
||||
crypto.pbkdf2(
|
||||
newPass,
|
||||
passwordSalt,
|
||||
iterations,
|
||||
512,
|
||||
"sha512",
|
||||
function (err, delivered_key) {
|
||||
const deliveredPass = delivered_key.toString("hex");
|
||||
|
||||
db.promise()
|
||||
.query(
|
||||
"UPDATE user SET password = ?, salt = ? WHERE id = ? LIMIT 1",
|
||||
[deliveredPass, passwordSalt, userId],
|
||||
)
|
||||
.then((r, f) => {
|
||||
return resolve(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
.catch((error) => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.revokeActiveSessions = (userId, sessionId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const userHash = cs.hash(String(userId)).toString("base64");
|
||||
|
||||
const userHash = cs.hash(String(userId)).toString('base64')
|
||||
|
||||
db.promise().query('DELETE FROM user_active_session WHERE user_hash = ? AND session_id != ?', [userHash, sessionId])
|
||||
db.promise()
|
||||
.query(
|
||||
"DELETE FROM user_active_session WHERE user_hash = ? AND session_id != ?",
|
||||
[userHash, sessionId],
|
||||
)
|
||||
.then((r, f) => {
|
||||
|
||||
resolve(true)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
User.deleteUser = (userId, password) => {
|
||||
|
||||
if (!userId || !password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return resolve('Missing User ID or Password. No Action Taken.')
|
||||
})
|
||||
return resolve("Missing User ID or Password. No Action Taken.");
|
||||
});
|
||||
}
|
||||
|
||||
//Verify user is correct by decryptig master key with password
|
||||
|
||||
let deletePromises = []
|
||||
let deletePromises = [];
|
||||
|
||||
//Delete all notes and raw text
|
||||
let noteDelete = db.promise().query(`
|
||||
let noteDelete = db.promise().query(
|
||||
`
|
||||
DELETE note, note_raw_text
|
||||
FROM note
|
||||
JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id)
|
||||
WHERE note.user_id = ?
|
||||
`,[userId])
|
||||
deletePromises.push(noteDelete)
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
deletePromises.push(noteDelete);
|
||||
|
||||
//Delete user entry
|
||||
let userDelete = db.promise().query(`
|
||||
let userDelete = db.promise().query(
|
||||
`
|
||||
DELETE FROM user WHERE id = ?
|
||||
`,[userId])
|
||||
deletePromises.push(userDelete)
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
deletePromises.push(userDelete);
|
||||
|
||||
//Delete user_key, encrypted search index
|
||||
let tables = ['user_key', 'user_encrypted_search_index']
|
||||
tables.forEach(tableName => {
|
||||
|
||||
const query = `DELETE FROM ${tableName} WHERE user_id = ?`
|
||||
const deleteQuery = db.promise().query(query, [userId])
|
||||
deletePromises.push(deleteQuery)
|
||||
})
|
||||
let tables = ["user_key", "user_encrypted_search_index"];
|
||||
tables.forEach((tableName) => {
|
||||
const query = `DELETE FROM ${tableName} WHERE user_id = ?`;
|
||||
const deleteQuery = db.promise().query(query, [userId]);
|
||||
deletePromises.push(deleteQuery);
|
||||
});
|
||||
|
||||
//Remove all note attachments and files
|
||||
|
||||
return Promise.all(deletePromises)
|
||||
}
|
||||
return Promise.all(deletePromises);
|
||||
};
|
||||
@@ -7,8 +7,6 @@ let router = express.Router()
|
||||
let Attachment = require('@models/Attachment')
|
||||
let Note = require('@models/Note')
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -19,23 +17,33 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/search', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.search(userId, req.body.noteId, req.body.attachmentType, req.body.offset, req.body.setSize, req.body.includeShared)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/textsearch', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.textSearch(userId, req.body.searchTerm)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/update', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.update(userId, req.body.attachmentId, req.body.updatedText, req.body.noteId)
|
||||
.then( result => {
|
||||
Note.reindex(userId, req.body.noteId)
|
||||
@@ -44,11 +52,19 @@ router.post('/update', function (req, res) {
|
||||
})
|
||||
|
||||
router.post('/delete', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Attachment.delete(userId, req.body.attachmentId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/upload', upload.single('file'), function (req, res, next) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
|
||||
//Create attachment with file information and node id
|
||||
const noteId = parseInt(req.body.noteId)
|
||||
@@ -67,6 +83,10 @@ router.post('/upload', upload.single('file'), function (req, res, next) {
|
||||
|
||||
// get push key
|
||||
router.post('/getbookmarklet', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
|
||||
Attachment.getPushkeyBookmarklet(userId)
|
||||
.then( data => res.send(data) )
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// /api/cycle-tracking
|
||||
//
|
||||
|
||||
var express = require('express')
|
||||
var router = express.Router()
|
||||
|
||||
let CycleTracking = require('@models/CycleTracking');
|
||||
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
|
||||
//Session key is required to continue
|
||||
if(!req.headers.sessionId){
|
||||
next('Unauthorized')
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
//Get quick note text
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
CycleTracking.get(userId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Push text to quick note
|
||||
router.post('/save', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
CycleTracking.save(userId, req.body.cycleData, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
|
||||
module.exports = router
|
||||
@@ -7,8 +7,6 @@ var router = express.Router()
|
||||
|
||||
let MetricTracking = require('@models/MetricTracking');
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -19,24 +17,34 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
MetricTracking.get(userId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/create', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
MetricTracking.create(userId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Push text to quick note
|
||||
router.post('/save', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
MetricTracking.save(userId, req.body.cycleData, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ let Note = require('@models/Note')
|
||||
let User = require('@models/User')
|
||||
let ShareNote = require('@models/ShareNote')
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
@@ -27,6 +23,10 @@ router.use(function setUserId (req, res, next) {
|
||||
// Note actions
|
||||
//
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.get(userId, req.body.noteId, masterKey)
|
||||
.then( noteObject => {
|
||||
|
||||
@@ -39,21 +39,37 @@ router.post('/get', function (req, res) {
|
||||
})
|
||||
|
||||
router.post('/delete', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.delete(userId, req.body.noteId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/create', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.create(userId, req.body.title, req.body.text, masterKey)
|
||||
.then( id => res.send({id}) )
|
||||
})
|
||||
|
||||
router.post('/update', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.update(userId, req.body.noteId, req.body.text, req.body.title, req.body.color, req.body.pinned, req.body.archived, req.body.hash, masterKey)
|
||||
.then( id => res.send({id}) )
|
||||
})
|
||||
|
||||
router.post('/search', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.search(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters, masterKey)
|
||||
.then( NoteAndTags => {
|
||||
res.send(NoteAndTags)
|
||||
@@ -61,6 +77,10 @@ router.post('/search', function (req, res) {
|
||||
})
|
||||
|
||||
router.post('/reindex', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.reindex(userId, masterKey)
|
||||
.then( data => {
|
||||
res.send(data)
|
||||
@@ -72,18 +92,30 @@ router.post('/reindex', function (req, res) {
|
||||
// Update single note attributes
|
||||
//
|
||||
router.post('/setpinned', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.setPinned(userId, req.body.noteId, req.body.pinned)
|
||||
.then( results => {
|
||||
res.send(results)
|
||||
})
|
||||
})
|
||||
router.post('/setarchived', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.setArchived(userId, req.body.noteId, req.body.archived)
|
||||
.then( results => {
|
||||
res.send(results)
|
||||
})
|
||||
})
|
||||
router.post('/settrashed', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Note.setTrashed(userId, req.body.noteId, req.body.trashed, masterKey)
|
||||
.then( results => {
|
||||
res.send(results)
|
||||
@@ -94,11 +126,19 @@ router.post('/settrashed', function (req, res) {
|
||||
// Share Note Actions
|
||||
//
|
||||
router.post('/getshareinfo', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
ShareNote.getShareInfo(userId, req.body.noteId, req.body.rawTextId)
|
||||
.then(results => res.send(results))
|
||||
})
|
||||
|
||||
router.post('/shareadduser', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
// ShareNote.addUser(userId, req.body.noteId, req.body.rawTextId, req.body.username, masterKey)
|
||||
User.getByUserName(req.body.username)
|
||||
.then( user => {
|
||||
@@ -111,22 +151,38 @@ router.post('/shareadduser', function (req, res) {
|
||||
})
|
||||
|
||||
router.post('/shareremoveuser', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
// (userId, noteId, shareNoteUserId, shareUserId, masterKey)
|
||||
ShareNote.removeUserFromSharedNote(userId, req.body.noteId, req.body.shareUserNoteId, masterKey)
|
||||
.then(results => res.send(results))
|
||||
})
|
||||
|
||||
router.post('/enableshare', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
//Create Shared Encryption Key for Note
|
||||
ShareNote.migrateToShared(userId, req.body.noteId, masterKey)
|
||||
.then(results => res.send(true))
|
||||
})
|
||||
router.post('/getsharekey', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
//Get Shared Key for a note
|
||||
ShareNote.decryptSharedKey(userId, req.body.noteId, masterKey)
|
||||
.then(results => res.send(results))
|
||||
})
|
||||
router.post('/disableshare', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
//Removed shared encryption key from note
|
||||
ShareNote.migrateToNormal(userId, req.body.noteId, masterKey)
|
||||
.then(results => res.send(true))
|
||||
|
||||
@@ -3,8 +3,6 @@ var router = express.Router()
|
||||
|
||||
let QuickNote = require('@models/QuickNote');
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -15,26 +13,36 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
//Get quick note text
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
QuickNote.get(userId, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Push text to quick note
|
||||
router.post('/update', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
QuickNote.update(userId, req.body.pushText, masterKey)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Push text to quick note
|
||||
router.post('/new', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
QuickNote.newNote(userId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
@@ -3,8 +3,6 @@ var router = express.Router()
|
||||
|
||||
let Tags = require('@models/Tag')
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -15,49 +13,75 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
//Get the latest notes the user has created
|
||||
router.post('/suggest', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.suggest(userId, req.body.noteId, req.body.tagText)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Get latest tags based on when it was put on a note
|
||||
router.post('/latest', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.latest(userId, req.body.noteId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Get the latest notes the user has created
|
||||
router.post('/addtonote', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.addToNote(userId, req.body.noteId, req.body.tagText.toLowerCase())
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
router.post('/removefromnote', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.removeTagFromNote(userId, req.body.tagId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Get the latest notes the user has created
|
||||
router.post('/get', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.get(userId, req.body.noteId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Get the latest notes the user has created
|
||||
router.post('/fornote', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.fornote(userId, req.body.noteId)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
//Get all the tags for this user in order of usage
|
||||
router.post('/usertags', function (req, res) {
|
||||
//Bind per request. These were module-level singletons shared by every
|
||||
//concurrent request, so one user's masterKey could overwrite another's.
|
||||
const { userId, masterKey } = req.headers
|
||||
|
||||
Tags.userTags(userId, req.body.searchQuery, req.body.searchTags, req.body.fastFilters)
|
||||
.then( data => res.send(data) )
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ const User = require('@models/User')
|
||||
const Auth = require('@helpers/Auth')
|
||||
const cs = require('@helpers/CryptoString')
|
||||
|
||||
let userId = null
|
||||
let masterKey = null
|
||||
|
||||
// middleware that is specific to this router
|
||||
router.use(function setUserId (req, res, next) {
|
||||
@@ -17,8 +15,6 @@ router.use(function setUserId (req, res, next) {
|
||||
}
|
||||
|
||||
if(req.headers.userId){
|
||||
userId = req.headers.userId
|
||||
masterKey = req.headers.masterKey
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{ backupFile ? "" }:
|
||||
with import <nixpkgs> { };
|
||||
|
||||
pkgs.mkShell {
|
||||
name = "Solid Scribe Env";
|
||||
|
||||
buildInputs = [
|
||||
nodejs
|
||||
nodemon
|
||||
pnpm
|
||||
pm2
|
||||
mariadb
|
||||
openssl
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Welcome to Solid Scribe"
|
||||
alias npm=pnpm
|
||||
|
||||
MYSQL_DATADIR="$PWD/.mysql/data"
|
||||
MYSQL_SOCKET="$PWD/.mysql/mysql.sock"
|
||||
MYSQL_PIDFILE="$PWD/.mysql/mysql.pid"
|
||||
|
||||
#
|
||||
# Work out what database situation we are in BEFORE touching anything.
|
||||
# This shell is used on the production server too, where a system MariaDB
|
||||
# is already running -- we must not initialise a datadir, start a second
|
||||
# server on port 3306, or inject dev credentials over the real ones.
|
||||
#
|
||||
# ours - the project-local server is already up (another nix-shell)
|
||||
# existing - some other server is listening; not ours to manage
|
||||
# start - nothing is running; this is a dev box, bring one up
|
||||
#
|
||||
SS_DB_MODE="start"
|
||||
|
||||
if mysqladmin --socket="$MYSQL_SOCKET" ping &>/dev/null 2>&1; then
|
||||
SS_DB_MODE="ours"
|
||||
elif (echo >/dev/tcp/127.0.0.1/3306) &>/dev/null \
|
||||
|| [ -S /run/mysqld/mysqld.sock ] \
|
||||
|| [ -S /var/run/mysqld/mysqld.sock ] \
|
||||
|| [ -S /tmp/mysql.sock ]; then
|
||||
SS_DB_MODE="existing"
|
||||
fi
|
||||
|
||||
if [ "$SS_DB_MODE" = "existing" ]; then
|
||||
echo "Existing database detected -- not starting the project one."
|
||||
echo "Leaving DB_* and JSON_KEY alone so the server's own config wins."
|
||||
fi
|
||||
|
||||
STARTED_MYSQL=0
|
||||
|
||||
if [ "$SS_DB_MODE" = "start" ]; then
|
||||
|
||||
mkdir -p "$MYSQL_DATADIR"
|
||||
|
||||
# Initialize data directory on first run
|
||||
if [ ! -d "$MYSQL_DATADIR/mysql" ]; then
|
||||
echo "Initializing MySQL data directory (first run)..."
|
||||
mysql_install_db \
|
||||
--datadir="$MYSQL_DATADIR" \
|
||||
--skip-test-db \
|
||||
--auth-root-authentication-method=normal \
|
||||
2>/dev/null
|
||||
echo "Initialization complete."
|
||||
fi
|
||||
|
||||
STARTED_MYSQL=1
|
||||
echo "Starting MySQL..."
|
||||
mysqld --no-defaults \
|
||||
--datadir="$MYSQL_DATADIR" \
|
||||
--socket="$MYSQL_SOCKET" \
|
||||
--pid-file="$MYSQL_PIDFILE" \
|
||||
--port=3306 \
|
||||
--bind-address=127.0.0.1 \
|
||||
--log-error="$PWD/.mysql/error.log" &
|
||||
|
||||
until mysqladmin --socket="$MYSQL_SOCKET" ping &>/dev/null 2>&1; do
|
||||
sleep 0.5
|
||||
done
|
||||
echo "MySQL ready."
|
||||
|
||||
mysql -u root --socket="$MYSQL_SOCKET" \
|
||||
-e "CREATE DATABASE IF NOT EXISTS application;" 2>/dev/null
|
||||
fi
|
||||
|
||||
#
|
||||
# Dev credentials, injected ONLY when this shell is providing the database
|
||||
# and there is no ~/.env supplying real config.
|
||||
#
|
||||
# This matters because dotenv does NOT override variables already present in
|
||||
# process.env (server/config/database.js loads ~/.env). So anything exported
|
||||
# here silently beats the server's real configuration -- including JSON_KEY,
|
||||
# which would invalidate every existing user session.
|
||||
#
|
||||
if [ "$SS_DB_MODE" != "existing" ] && [ ! -f "$HOME/.env" ]; then
|
||||
|
||||
export DB_HOST=127.0.0.1
|
||||
export DB_USER=root
|
||||
export DB_PASS=
|
||||
|
||||
# Stable dev JWT key, persisted across shell sessions
|
||||
if [ -z "''${JSON_KEY:-}" ]; then
|
||||
if [ -s "$PWD/.mysql/jwt_key" ]; then
|
||||
export JSON_KEY=$(cat "$PWD/.mysql/jwt_key")
|
||||
else
|
||||
export JSON_KEY=$(openssl rand -hex 32)
|
||||
echo "$JSON_KEY" > "$PWD/.mysql/jwt_key"
|
||||
echo "Generated dev JSON_KEY (saved to .mysql/jwt_key)"
|
||||
fi
|
||||
fi
|
||||
|
||||
elif [ -f "$HOME/.env" ]; then
|
||||
echo "~/.env found -- using its DB_* and JSON_KEY, not dev defaults."
|
||||
fi
|
||||
|
||||
# Restore a SQL dump — handles both plain .sql and .gz files
|
||||
restore-db() {
|
||||
local file="''${1:-}"
|
||||
if [ -z "$file" ]; then
|
||||
echo "Usage: restore-db <backup.sql[.gz]>"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Refuse to run against a database this shell does not manage. The dumps
|
||||
# are --all-databases: restoring one would overwrite every schema on the
|
||||
# server and reset root's password. That is fine for a local dev copy and
|
||||
# catastrophic on the production box.
|
||||
if [ "$SS_DB_MODE" = "existing" ]; then
|
||||
echo "Refusing: this shell is talking to a pre-existing database." >&2
|
||||
echo "restore-db only runs against the project-local dev database." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Restoring database from $file ..."
|
||||
if [[ "$file" == *.gz ]]; then
|
||||
gunzip -c "$file" | mysql -u root --socket="$MYSQL_SOCKET"
|
||||
else
|
||||
mysql -u root --socket="$MYSQL_SOCKET" < "$file"
|
||||
fi
|
||||
# The dump is --all-databases, so it includes the `mysql` schema and
|
||||
# overwrites local root with production's credentials. In-memory grants
|
||||
# are still the old ones until FLUSH, so fix root now while we can
|
||||
# still authenticate.
|
||||
mysql -u root --socket="$MYSQL_SOCKET" -e "
|
||||
ALTER USER 'root'@'localhost' IDENTIFIED VIA mysql_native_password USING PASSWORD(''');
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;"
|
||||
|
||||
echo "Restore complete."
|
||||
}
|
||||
export -f restore-db
|
||||
|
||||
# Auto-restore if --argstr backupFile was passed to nix-shell
|
||||
# (restore-db itself refuses when the database is not ours)
|
||||
if [ -n "${backupFile}" ]; then
|
||||
restore-db "${backupFile}"
|
||||
fi
|
||||
|
||||
if [ "$STARTED_MYSQL" = "1" ]; then
|
||||
trap "echo 'Stopping MySQL...'; mysqladmin -u root --socket=$MYSQL_SOCKET shutdown 2>/dev/null" EXIT
|
||||
else
|
||||
echo "MySQL already running (started by another shell) -- leaving it up on exit."
|
||||
fi
|
||||
|
||||
# Do not start the app here.
|
||||
# This same shell.nix is used for hosting the app on dev
|
||||
# use the startDevServerAndClient.sh script
|
||||
'';
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd /home/mab/ss
|
||||
|
||||
echo '::--:: Starting dev server. cd client; npm run serve -> 192.168.1.164:8081'
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local dev: API server + v2client (Vue 3 / Vite).
|
||||
# Run from inside nix-shell, which provides node/pnpm/pm2/mariadb,
|
||||
# starts the local MySQL, and exports DB_* and JSON_KEY.
|
||||
#
|
||||
# Unlike startDevServerAndClient.sh (remote dev box, /home/mab/ss, Vue 2
|
||||
# client) this is path-relative.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
if [ -z "${JSON_KEY:-}" ]; then
|
||||
echo "JSON_KEY is not set -- run this from inside nix-shell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! mysqladmin --socket="$REPO/.mysql/mysql.sock" ping &>/dev/null; then
|
||||
echo "Local MySQL is not running -- run this from inside nix-shell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '::--:: Starting API server (:3000) + socket.io (:3001) under pm2...'
|
||||
cd "$REPO/server"
|
||||
# `env -u name`: nix's mkShell exports its `name` attr ("Solid Scribe Env")
|
||||
# as $name, and pm2 uses $name to override the app name from ecosystem.config.js.
|
||||
pm2 delete NoteServer &>/dev/null || true
|
||||
pm2 flush &>/dev/null || true
|
||||
# --watch is a dev-only convenience; ecosystem.config.js leaves it off so a
|
||||
# production `git pull` can't restart the server mid-update.
|
||||
env -u name pm2 start ecosystem.config.js --watch
|
||||
cd "$REPO"
|
||||
|
||||
echo '::--:: Starting v2client dev server -> http://localhost:5173'
|
||||
cd "$REPO/client"
|
||||
[ -d node_modules ] || pnpm install
|
||||
screen -S "V2ClientScreen" -X quit &>/dev/null || true
|
||||
screen -dmS "V2ClientScreen" bash -c "cd '$REPO/client'; pnpm dev"
|
||||
|
||||
cat <<MSG
|
||||
|
||||
::--:: Up.
|
||||
client http://localhost:5173 (proxies /api -> :3000, /socket -> :3001)
|
||||
api http://localhost:3000
|
||||
|
||||
logs pm2 logs NoteServer
|
||||
screen -r V2ClientScreen (detach: ctrl-a d)
|
||||
stop pm2 delete NoteServer; screen -S V2ClientScreen -X quit
|
||||
MSG
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
*/
|
||||
!.gitignore
|
||||
!assets
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 23 KiB |
@@ -19,6 +19,5 @@
|
||||
],
|
||||
"name": "Solid Scribe",
|
||||
"short_name": "Solid Scribe",
|
||||
"start_url": "/#/notes",
|
||||
"author":"Max"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
# # #!/bin/bash
|
||||
#Syncs local data with sandbox
|
||||
|
||||
# v - Verbose
|
||||
@@ -11,5 +11,6 @@
|
||||
# e - Send Files via SSH
|
||||
# z - Compress for speed
|
||||
# h - Human Readable file sizes
|
||||
# p - port
|
||||
|
||||
rsync -e 'ssh' --exclude-from=dontSync.txt -havzC --update mab@marvin.local:/home/mab/ss/ .
|
||||
rsync -e 'ssh -p 13326' --exclude-from=dontSync.txt -havz --update mab@blarvin.local:/home/mab/ss/ .
|
||||