This commit is contained in:
2026-02-21 08:05:33 -05:00
parent bba007f1dc
commit 9531953b48
114 changed files with 872 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# top level
.vscode
.DS_Store
.git
# shared backend and frontend
*/.DS_Store
*/node_modules
*/package-lock.json
*/.env
# backend
backend/data/anki/decks/*
+43
View File
@@ -0,0 +1,43 @@
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var photosearch = require('./routes/photosearch');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/search/photos', photosearch)
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('backend:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}
+89
View File
@@ -0,0 +1,89 @@
import { readFile } from 'fs/promises'
import { open } from 'sqlite'
import sqlite3 from 'sqlite3'
import AdmZip from 'adm-zip'
// database structure: https://github.com/ankidroid/Anki-Android/wiki/Database-Structure
// python example: https://github.com/patarapolw/ankisync2
// old js example: https://github.com/nornagon/mkanki/blob/master/index.js
// to create a zip file
// const zip = new AdmZip()
// zip.addLocalFolder('./folder_to_zip')
// zip.writeZip('./output.zip')
class Deck {
/**
*
* @param info ID of internal deck, or filename or anki export
* @param process whether or not this is an external anki export to process
*/
constructor(info, process) {
// pull out variables
this.notes = null // this is what is edited; may create multiple cards
this.media = null // image and audio files attached to cards/notes
}
/**
* Loads an internal deck
*/
async load(id) {
// open database
// open media files
}
/**
* Processes an anki export for internal use. Currently only supports
* .apkg files. In the future, may support:
* colpkg
* txt (notes)
* txt (cards)
*
* @param filename path of file to process
*/
async process(filename) {
return await this.processApkg(filename)
}
/**
* Processes a .apkg file into a database
*
* @param filename name of file to open and process
*/
async processApkg(filename) {
// error handling
if (!filename) throw new Error('Must specify a .apkg filename')
// setup
let uuid = crypto.randomUUID()
let location = `../data/anki/decks/${uuid}/`
// make the resource folder if it does not exist already
// let resource = ''
// if (!fs.existsSync(resource)) fs.mkdirSync(resource, { recursive: true })
// extract zip
// https://stuk.github.io/jszip/documentation/howto/read_zip.html#in-nodejs
let zip = new AdmZip(filename)
zip.extractAllTo(location, true)
// extract the notes database
let db = await open({
filename: location + 'collection.anki2',
driver: sqlite3.cached.Database
})
let result = await db.get('SELECT sfld FROM notes WHERE id = :n', 1486785585103)
console.log(result);
// extract media files
// ? process/rename media files and update in db?
// load data into this obj
// return { notes, media }
}
}
export default Deck
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
import Deck from './anki.js'
let deck = new Deck()
await deck.process('test.apkg');
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"66": "rec1486791173.mp3", "12": "paste-5909874999552.jpg", "49": "rec1486790484.mp3", "54": "rec1486789453.mp3", "59": "paste-6051608920308.jpg", "75": "paste-7176890351876.jpg", "50": "paste-5952824672506.jpg", "11": "paste-5342939316470.jpg", "3": "rec1486790515.mp3", "73": "rec1486791444.mp3", "58": "paste-6743098654966.jpg", "26": "rec1486791092.mp3", "60": "paste-5596342386925.jpg", "68": "rec1486787151.mp3", "69": "rec1486790743.mp3", "0": "rec1486791250.mp3", "8": "paste-6219112644848.jpg", "18": "rec1486790648.mp3", "57": "paste-6880537608452.jpg", "61": "rec1486789557.mp3", "33": "paste-6343666696438.jpg", "7": "rec1486791037.mp3", "21": "rec1486786900.mp3", "44": "paste-5476083302645.jpg", "17": "rec1486790321.mp3", "9": "rec1486790979.mp3", "55": "rec1486790707.mp3", "64": "rec1486790888.mp3", "71": "paste-7563437408511.jpg", "53": "paste-6532645257453.jpg", "36": "paste-6322191859961.jpg", "31": "paste-7687991460090.jpg", "28": "rec1486790193.mp3", "47": "rec1486791121.mp3", "39": "rec1486791213.mp3", "14": "paste-7022271529203.jpg", "37": "paste-7288559501563.jpg", "15": "rec1486789738.mp3", "51": "paste-2929167696123.jpg", "25": "paste-7597797146867.jpg", "20": "rec1486790550.mp3", "19": "rec1486790446.mp3", "48": "rec1486788047.mp3", "74": "paste-5849745457397.jpg", "30": "rec1486791007.mp3", "10": "rec1486788946.mp3", "29": "paste-6846177870075.jpg", "4": "rec1486790287.mp3", "56": "rec1486789680.mp3", "62": "paste-5755256176881.jpg", "38": "paste-2143188680961.jpg", "70": "rec1486790804.mp3", "42": "rec1486790858.mp3", "43": "paste-7430293422343.jpg", "23": "paste-6158983102706.jpg", "72": "rec1486789502.mp3", "34": "rec1486790589.mp3", "16": "rec1486791310.mp3", "52": "paste-6266357285108.jpg", "32": "paste-687194767612.jpg", "65": "paste-6708738916592.jpg", "2": "paste-7142530613495.jpg", "40": "paste-816043786497.jpg", "1": "paste-1090921693442.jpg", "67": "rec1486791391.mp3", "5": "rec1486790677.mp3", "6": "rec1486791362.mp3", "46": "rec1486790948.mp3", "22": "paste-7073811136753.jpg", "63": "rec1486790918.mp3", "24": "rec1486790620.mp3", "27": "paste-4939212390648.jpg", "45": "paste-6644314407145.jpg", "41": "paste-5811090751727.jpg", "35": "rec1486789528.mp3", "13": "paste-6442450944247.jpg"}
+1
View File
@@ -0,0 +1 @@
export {}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "backend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node ./bin/www",
"reinstall": "rm -rf node_modules/ package-lock.json && npm install"
},
"dependencies": {
"adm-zip": "^0.5.16",
"axios": "^1.13.5",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"fs": "^0.0.1-security",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
}
}
+9
View File
@@ -0,0 +1,9 @@
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
var express = require('express')
var router = express.Router()
const axios = require('axios')
/**
* Services to add:
* - https://unsplash.com/developers
* - https://pixabay.com/api/docs/
*
* Pronounciation will require $2/mo sub:
* https://api.forvo.com/plans-and-pricing/
*/
async function pexelsSearch(query) {
// https://help.pexels.com/hc/en-us/articles/47678194141337-Can-I-change-the-search-language-when-using-the-Pexels-API
// pexels has a monthly rate limit and an hourly rate limit, only the monthly is returned in the headers
// limit is also 200 per hour, will need to manually track that
const resp = await axios.get(
`https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=5`,
{
headers: {
Authorization: process.env.PEXELS_API,
},
}
)
const { data, headers } = resp
const photos = data.photos
let filtered = photos.map((p) => (
{
url: p.src.medium,
desc: p.alt,
credit: p.photographer,
id: p.id
}
))
let response = {
photos: filtered,
ratelimit: {
limit: headers['x-ratelimit-limit'],
remaining: headers['x-ratelimit-remaining'],
reset: new Date(Number(headers['x-ratelimit-reset']) * 1000)
}
}
return response;
}
async function shutterstockSearch(query) {
// shutterstock has an hourly rate limit and no monthly limit
// https://api-reference.shutterstock.com/?shell#assets-api
// higher quality is available with shutterstock watermarks, look into [photo].assets objects
const SHUTTERSTOCK_API_TOKEN = process.env.SHUTTERSTOCK_API
try {
const resp = await axios.get(
'https://api.shutterstock.com/v2/images/search',
{
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${SHUTTERSTOCK_API_TOKEN}`
},
params: {
query,
per_page: 5,
sort: 'popular',
safe: false,
image_type: 'photo'
}
}
)
let { data, headers } = resp
let filtered = data.data.map((p) => (
{
url: p.assets.mosaic.url,
desc: p.description,
credit: p.contributor.id,
id: Number(p.id)
}
))
let response = {
photos: filtered,
ratelimit: {
limit: headers['ratelimit-limit'],
remaining: headers['ratelimit-remaining'],
reset: new Date(Number(headers['ratelimit-reset']))
}
}
return response;
} catch (error) {
console.error('Error:', error);
}``
}
router.get('/', async function(req, res) {
try {
const queryL1 = req.query.queries[0]
// const queryL2 = req.query.queries[1]
// const pexels = await pexelsSearch(queryL1)
const shutterstock = await shutterstockSearch(queryL1)
response = shutterstock;
res.status(200).send(response)
} catch (err) {
console.log(err)
}
});
module.exports = router
+9
View File
@@ -0,0 +1,9 @@
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.status(200).send('respond with a resource');
});
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+44
View File
@@ -0,0 +1,44 @@
# .
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{vue,js,mjs,jsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
])
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "linguamate",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"reinstall": "rm -rf node_modules/ package-lock.json && npm install",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"axios": "^1.13.5",
"pinia": "^3.0.4",
"primeflex": "^4.0.0",
"vue": "^3.5.27",
"vue-router": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.42.0",
"eslint-plugin-vue": "~10.7.0",
"globals": "^17.3.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.42.0",
"prettier": "3.8.1",
"sass-embedded": "^1.97.3",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<template>
<TheMenu />
<RouterView />
</template>
<script setup>
import TheMenu from '@/components/app/TheMenu.vue'
</script>
+62
View File
@@ -0,0 +1,62 @@
<template>
<Menubar :model="items" class="px-8">
<!-- Menu links -->
<template #item="{ item, props, hasSubmenu }">
<router-link v-if="item.route" v-slot="{ href, navigate }" :to="item.route" custom>
<a :href="href" v-bind="props.action" @click="navigate">
<span :class="item.icon" />
<span>{{ item.label }}</span>
</a>
</router-link>
<a v-else :href="item.url" :target="item.target" v-bind="props.action">
<i :class="item.icon" />
<span>{{ item.label }}</span>
<span v-if="hasSubmenu" class="mdi mdi-chevron-down" />
</a>
</template>
<!-- User profile icon -->
<template #end>
<i class="mdi mdi-account-outline" />
</template>
</Menubar>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{
label: 'Home',
icon: 'mdi mdi-home-outline',
route: '/'
},
{
label: 'Tools',
icon: 'mdi mdi-wrench-outline',
route: '/toolkit'
},
{
label: 'Learn',
icon: 'mdi mdi-lightbulb-outline',
items: [
{
label: 'Pronunciation',
icon: 'mdi mdi-waveform',
route: '/speak'
},
{
label: 'Anki Decks',
icon: 'mdi mdi-card-multiple-outline',
route: '/decks'
},
{
label: 'Self Testing',
icon: 'mdi mdi-send-variant-outline',
route: '/test'
}
]
}
]);
</script>

Some files were not shown because too many files have changed in this diff Show More