├── .eslintrc.cjs ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc.json ├── .stylelintrc.cjs ├── README.md ├── electron-builder.json5 ├── electron ├── electron-env.d.ts ├── main.ts └── preload.ts ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── public ├── img │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── android-chrome-maskable-192x192.png │ │ ├── android-chrome-maskable-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ ├── og-image.jpg │ └── screenshot.png ├── robots.txt └── sql-wasm.wasm ├── src ├── App.vue ├── assets │ ├── css │ │ ├── basic.scss │ │ ├── style.scss │ │ └── variable.scss │ └── js │ │ ├── booklist.ts │ │ ├── highlight.ts │ │ ├── sql.ts │ │ ├── tools.ts │ │ └── type.ts ├── main.ts ├── style.css └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: 'vue-eslint-parser', 7 | parserOptions: { 8 | parser: '@typescript-eslint/parser', 9 | ecmaVersion: 2020, 10 | sourceType: 'module', 11 | ecmaFeatures: { 12 | jsx: true, 13 | }, 14 | }, 15 | // 继承插件的规则配置 16 | extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'plugin:@typescript-eslint/recommended'], 17 | rules: { 18 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 20 | 'vue/require-default-prop': 'off', 21 | 'vue/multi-word-component-names': 'off', 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup pnpm 16 | uses: pnpm/action-setup@v2 17 | with: 18 | version: 'latest' 19 | 20 | - name: Install dependencies 21 | run: | 22 | pnpm install 23 | pnpm run build 24 | 25 | - name: Deploy 26 | uses: JamesIves/github-pages-deploy-action@v4 27 | with: 28 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 29 | BRANCH: gh-pages 30 | FOLDER: dist-page 31 | CLEAN: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .stylelintcache 27 | dist-electron 28 | dist-page 29 | release 30 | 31 | linux-unpacked 32 | win-unpacked 33 | mac 34 | builder-debug.yml 35 | builder-effective-config.yaml -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 150, 4 | "semi": false, 5 | "endOfLine": "lf", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | const sortOrderSmacss = require('stylelint-config-property-sort-order-smacss/generate') 2 | 3 | module.exports = { 4 | root: true, 5 | extends: 'stylelint-config-recommended-vue', 6 | plugins: ['stylelint-order'], 7 | overrides: [ 8 | { 9 | files: ['**/*.scss'], 10 | customSyntax: 'postcss-scss', 11 | }, 12 | { 13 | files: ["*.vue", "**/*.vue"], 14 | customSyntax: 'postcss-html', 15 | }, 16 | ], 17 | rules: { 18 | 'order/properties-order': [sortOrderSmacss()], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kobo-book-exporter 2 | 3 | Export Kobo Book Highlight (.md) & Book List (.csv, .json & .md). 4 | 5 | [https://mollykannn.github.io/kobo-book-exporter](https://mollykannn.github.io/kobo-book-exporter) 6 |  7 | 8 | Retrieved from [Kobo Exporter: 匯出 Kobo 電子書的書籍清單與註記資料 (劃線與筆記) | Vixual](http://www.vixual.net/blog/archives/117) 9 | 10 | ## 安裝 (Install) 11 | 12 | ```shell 13 | yarn install 14 | ``` 15 | 16 | ## 使用方法 (Usage) 17 | 18 | 建立檔案 (Create files) 19 | ```shell 20 | yarn run build 21 | ``` 22 | 23 | 運行 (Run) 24 | ```shell 25 | yarn run serve 26 | ``` 27 | 28 | Icons made by [Freepik](https://www.freepik.com) from [www.flaticon.com](https://www.flaticon.com/) -------------------------------------------------------------------------------- /electron-builder.json5: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.electron.build/configuration/configuration 3 | */ 4 | { 5 | "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", 6 | "appId": "com.kobo-book-exporter.app", 7 | "compression": "maximum", 8 | "asar": true, 9 | "productName": "Kobo Book Exporter", 10 | "directories": { 11 | "output": "release/${version}" 12 | }, 13 | "files": [ 14 | "dist", 15 | "dist-electron" 16 | ], 17 | "mac": { 18 | "target": [ 19 | "7z" 20 | ], 21 | "artifactName": "${productName}-Mac-${version}-Installer.${ext}", 22 | }, 23 | "win": { 24 | "target": [ 25 | { 26 | "target": "7z", 27 | "arch": [ 28 | "x64" 29 | ] 30 | } 31 | ], 32 | "artifactName": "${productName}-Windows-${version}-Setup.${ext}" 33 | }, 34 | "nsis": { 35 | "oneClick": false, 36 | "perMachine": false, 37 | "allowToChangeInstallationDirectory": true, 38 | "deleteAppDataOnUninstall": false 39 | }, 40 | "linux": { 41 | "target": [ 42 | "7z" 43 | ], 44 | "artifactName": "${productName}-Linux-${version}.${ext}" 45 | }, 46 | "directories":{ 47 | "buildResources": "public/img/icons", 48 | "output": "release" 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | /** 6 | * The built directory structure 7 | * 8 | * ```tree 9 | * ├─┬─┬ dist 10 | * │ │ └── index.html 11 | * │ │ 12 | * │ ├─┬ dist-electron 13 | * │ │ ├── main.js 14 | * │ │ └── preload.js 15 | * │ 16 | * ``` 17 | */ 18 | DIST: string 19 | /** /dist/ or /public/ */ 20 | VITE_PUBLIC: string 21 | } 22 | } 23 | 24 | // Used in Renderer process, expose in `preload.ts` 25 | interface Window { 26 | ipcRenderer: import('electron').IpcRenderer 27 | } 28 | -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | import path from 'node:path' 3 | 4 | // The built directory structure 5 | // 6 | // ├─┬─┬ dist 7 | // │ │ └── index.html 8 | // │ │ 9 | // │ ├─┬ dist-electron 10 | // │ │ ├── main.js 11 | // │ │ └── preload.js 12 | // │ 13 | process.env.DIST = path.join(__dirname, '../dist') 14 | process.env.VITE_PUBLIC = app.isPackaged ? process.env.DIST : path.join(process.env.DIST, '../public') 15 | 16 | 17 | let win: BrowserWindow | null 18 | // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x 19 | const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] 20 | 21 | function createWindow() { 22 | win = new BrowserWindow({ 23 | icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'), 24 | webPreferences: { 25 | preload: path.join(__dirname, 'preload.js'), 26 | }, 27 | }) 28 | 29 | // DevTools 30 | if (process.env.NODE_ENV == "development") { 31 | win.webContents.openDevTools(); 32 | } 33 | 34 | // Test active push message to Renderer-process. 35 | win.webContents.on('did-finish-load', () => { 36 | win?.webContents.send('main-process-message', (new Date).toLocaleString()) 37 | }) 38 | 39 | if (VITE_DEV_SERVER_URL) { 40 | win.loadURL(VITE_DEV_SERVER_URL) 41 | } else { 42 | // win.loadFile('dist/index.html') 43 | win.loadFile(path.join(process.env.DIST, 'index.html')) 44 | } 45 | } 46 | 47 | // Quit when all windows are closed, except on macOS. There, it's common 48 | // for applications and their menu bar to stay active until the user quits 49 | // explicitly with Cmd + Q. 50 | app.on('window-all-closed', () => { 51 | if (process.platform !== 'darwin') { 52 | app.quit() 53 | win = null 54 | } 55 | }) 56 | 57 | app.on('activate', () => { 58 | // On OS X it's common to re-create a window in the app when the 59 | // dock icon is clicked and there are no other windows open. 60 | if (BrowserWindow.getAllWindows().length === 0) { 61 | createWindow() 62 | } 63 | }) 64 | 65 | app.whenReady().then(createWindow) 66 | -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | 3 | // --------- Expose some API to the Renderer process --------- 4 | contextBridge.exposeInMainWorld('ipcRenderer', withPrototype(ipcRenderer)) 5 | 6 | // `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it. 7 | function withPrototype(obj: Record) { 8 | const protos = Object.getPrototypeOf(obj) 9 | 10 | for (const [key, value] of Object.entries(protos)) { 11 | if (Object.prototype.hasOwnProperty.call(obj, key)) continue 12 | 13 | if (typeof value === 'function') { 14 | // Some native APIs, like `NodeJS.EventEmitter['on']`, don't work in the Renderer process. Wrapping them into a function. 15 | obj[key] = function (...args: any) { 16 | return value.call(obj, ...args) 17 | } 18 | } else { 19 | obj[key] = value 20 | } 21 | } 22 | return obj 23 | } 24 | 25 | // --------- Preload scripts loading --------- 26 | function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) { 27 | return new Promise(resolve => { 28 | if (condition.includes(document.readyState)) { 29 | resolve(true) 30 | } else { 31 | document.addEventListener('readystatechange', () => { 32 | if (condition.includes(document.readyState)) { 33 | resolve(true) 34 | } 35 | }) 36 | } 37 | }) 38 | } 39 | 40 | const safeDOM = { 41 | append(parent: HTMLElement, child: HTMLElement) { 42 | if (!Array.from(parent.children).find(e => e === child)) { 43 | parent.appendChild(child) 44 | } 45 | }, 46 | remove(parent: HTMLElement, child: HTMLElement) { 47 | if (Array.from(parent.children).find(e => e === child)) { 48 | parent.removeChild(child) 49 | } 50 | }, 51 | } 52 | 53 | /** 54 | * https://tobiasahlin.com/spinkit 55 | * https://connoratherton.com/loaders 56 | * https://projects.lukehaas.me/css-loaders 57 | * https://matejkustec.github.io/SpinThatShit 58 | */ 59 | function useLoading() { 60 | const className = `loaders-css__square-spin` 61 | const styleContent = ` 62 | @keyframes square-spin { 63 | 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } 64 | 50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); } 65 | 75% { transform: perspective(100px) rotateX(0) rotateY(180deg); } 66 | 100% { transform: perspective(100px) rotateX(0) rotateY(0); } 67 | } 68 | .${className} > div { 69 | animation-fill-mode: both; 70 | width: 50px; 71 | height: 50px; 72 | background: #fff; 73 | animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite; 74 | } 75 | .app-loading-wrap { 76 | position: fixed; 77 | top: 0; 78 | left: 0; 79 | width: 100vw; 80 | height: 100vh; 81 | display: flex; 82 | align-items: center; 83 | justify-content: center; 84 | background: #282c34; 85 | z-index: 9; 86 | } 87 | ` 88 | const oStyle = document.createElement('style') 89 | const oDiv = document.createElement('div') 90 | 91 | oStyle.id = 'app-loading-style' 92 | oStyle.innerHTML = styleContent 93 | oDiv.className = 'app-loading-wrap' 94 | oDiv.innerHTML = `` 95 | 96 | return { 97 | appendLoading() { 98 | safeDOM.append(document.head, oStyle) 99 | safeDOM.append(document.body, oDiv) 100 | }, 101 | removeLoading() { 102 | safeDOM.remove(document.head, oStyle) 103 | safeDOM.remove(document.body, oDiv) 104 | }, 105 | } 106 | } 107 | 108 | // ---------------------------------------------------------------------- 109 | 110 | const { appendLoading, removeLoading } = useLoading() 111 | domReady().then(appendLoading) 112 | 113 | window.onmessage = ev => { 114 | ev.data.payload === 'removeLoading' && removeLoading() 115 | } 116 | 117 | setTimeout(removeLoading, 4999) 118 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Kobo Book Exporter 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ] 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-project", 3 | "private": true, 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc && vite build", 8 | "build:electron:mac": "vue-tsc && vite build --mode electron && electron-builder --mac --config", 9 | "build:electron:win": "vue-tsc && vite build --mode electron && electron-builder --win --config", 10 | "build:electron:linux": "vue-tsc && vite build --mode electron && electron-builder --linux --config", 11 | "build:electron:all": "vue-tsc && vite build --mode electron && electron-builder --mac --win --linux --config", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "sql.js": "^1.9.0", 16 | "vue": "^3.3.13" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20.10.5", 20 | "@types/sql.js": "^1.4.9", 21 | "@typescript-eslint/eslint-plugin": "^6.15.0", 22 | "@typescript-eslint/parser": "^6.15.0", 23 | "@vitejs/plugin-vue": "^4.5.2", 24 | "electron": "^28.0.0", 25 | "electron-builder": "^24.9.1", 26 | "eslint": "^8.56.0", 27 | "eslint-config-prettier": "^9.1.0", 28 | "eslint-plugin-prettier": "^5.1.0", 29 | "eslint-plugin-vue": "^9.19.2", 30 | "postcss": "^8.4.32", 31 | "postcss-html": "^1.5.0", 32 | "postcss-scss": "^4.0.9", 33 | "prettier": "^3.1.1", 34 | "sass": "^1.69.5", 35 | "stylelint": "^16.0.2", 36 | "stylelint-config-property-sort-order-smacss": "^10.0.0", 37 | "stylelint-config-recommended-vue": "^1.5.0", 38 | "stylelint-order": "^6.0.4", 39 | "typescript": "^5.3.3", 40 | "vite": "^5.0.10", 41 | "vite-plugin-electron": "^0.15.5", 42 | "vite-plugin-electron-renderer": "^0.14.5", 43 | "vite-plugin-eslint": "^1.8.1", 44 | "vite-plugin-pwa": "^0.17.4", 45 | "vite-plugin-stylelint": "^5.3.1", 46 | "vue-tsc": "^1.8.25" 47 | }, 48 | "main": "dist-electron/main.js" 49 | } -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/icon.icns -------------------------------------------------------------------------------- /public/img/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/icon.ico -------------------------------------------------------------------------------- /public/img/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/icons/icon.png -------------------------------------------------------------------------------- /public/img/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/og-image.jpg -------------------------------------------------------------------------------- /public/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/img/screenshot.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/sql-wasm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mollykannn/kobo-book-exporter/7b4045dc70248ba679d290c2f4b22debd9c7e15f/public/sql-wasm.wasm -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 53 | 54 | Kobo Book Exporter 55 | 56 | 57 | 63 | {{ setting.fileName }} 64 | 65 | 72 | * Connect your kobo reader to a computer. You can find .kobo directory (hidden by default). There should be a KoboReader.sqlite file. Drop a 73 | file here. 74 | 75 | 79 | 80 | 81 | 82 | JSON file 83 | 84 | 85 | Markdown file 86 | 87 | 88 | CSV file 89 | 90 | 91 | 92 | 93 | 99 | {{ data.BookTitle }} 100 | 101 | 102 | 103 | 104 | 105 | 106 | 110 | 111 | {{ highlightData.title }} 112 | 113 | 118 | Export 119 | 120 | 121 | 125 | 126 | 127 | 128 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /src/assets/css/basic.scss: -------------------------------------------------------------------------------- 1 | @import 'variable.scss'; 2 | 3 | *, 4 | *::before, 5 | *::after { 6 | box-sizing: inherit; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | html { 12 | box-sizing: border-box; 13 | height: 100%; 14 | font-size: 62.5%; 15 | } 16 | 17 | body { 18 | height: 100%; 19 | margin: 0; 20 | background-color: var(--background); 21 | font-family: var(--font); 22 | font-size: 1.6rem; 23 | font-weight: 400; 24 | line-height: 1.5; 25 | } 26 | 27 | button { 28 | display: inline-block; 29 | padding: 0.5rem 1.5rem; 30 | transition: 0.3s; 31 | border: none; 32 | outline: none; 33 | background: none; 34 | background-color: hsl(140 100% 38%); 35 | color: inherit; 36 | color: #fff; 37 | font-family: var(--font); 38 | font-size: 1.6rem; 39 | 40 | &:hover { 41 | background-color: hsl(140 100% 28%); 42 | } 43 | } 44 | 45 | #app { 46 | max-width: 1170px; 47 | min-height: 100vh; 48 | margin: 0 auto; 49 | background-color: var(--basic); 50 | 51 | @media #{$Extra-large} { 52 | max-width: 980px; 53 | } 54 | 55 | @media #{$Large} { 56 | max-width: 730px; 57 | } 58 | 59 | @media #{$Medium} { 60 | max-width: 650px; 61 | } 62 | 63 | @media #{$Small} { 64 | flex: 1; 65 | } 66 | } 67 | 68 | .container { 69 | padding: 2rem; 70 | } 71 | 72 | .hide { 73 | display: none; 74 | } 75 | 76 | .mb-2 { 77 | margin-bottom: 1rem !important; 78 | } 79 | 80 | .mb-4 { 81 | margin-bottom: 2rem !important; 82 | } 83 | 84 | .mb-6 { 85 | margin-bottom: 3rem !important; 86 | } 87 | 88 | .ml-1 { 89 | margin-left: 0.5rem !important; 90 | } 91 | 92 | /* switch */ 93 | .switch-column { 94 | color: hsl(0, 0%, 100%); 95 | text-align: right; 96 | 97 | .switch { 98 | display: inline-block; 99 | position: relative; 100 | width: 7rem; 101 | height: 3rem; 102 | 103 | input { 104 | width: 0; 105 | height: 0; 106 | opacity: 0%; 107 | } 108 | 109 | input + .slider::after { 110 | content: "Night"; 111 | position: absolute; 112 | top: 0.4rem; 113 | right: 0.6rem; 114 | font-size: 1.3rem; 115 | text-align: right; 116 | } 117 | 118 | .slider { 119 | position: absolute; 120 | top: 0; 121 | right: 0; 122 | bottom: 0; 123 | left: 0; 124 | transition: 0.4s; 125 | border-radius: 3rem; 126 | background-color: hsl(44deg 100% 50%); 127 | cursor: pointer; 128 | 129 | &::before { 130 | content: ""; 131 | position: absolute; 132 | bottom: 0.4rem; 133 | left: 0.4rem; 134 | width: 2.3rem; 135 | height: 2.3rem; 136 | transition: 0.4s; 137 | border-radius: 50%; 138 | background-color: hsl(0, 0%, 100%); 139 | } 140 | } 141 | 142 | input:checked + .slider { 143 | background-color: hsl(0deg 0% 30%); 144 | } 145 | 146 | input:checked + .slider::after { 147 | content: "Dark"; 148 | position: absolute; 149 | top: 0.4rem; 150 | left: 1rem; 151 | font-size: 1.3rem; 152 | text-align: left; 153 | } 154 | 155 | input:checked + .slider::before { 156 | transform: translateX(39px); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | @import 'basic.scss'; 2 | 3 | .title { 4 | color: var(--title); 5 | text-align: center; 6 | } 7 | 8 | .file { 9 | text-align: center; 10 | 11 | & .file-upload { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | max-width: 600px; 16 | height: 100px; 17 | margin: auto; 18 | border: 0.2rem dashed hsl(0deg 0% 50%); 19 | color: hsl(0deg 0% 50%); 20 | font-size: 2.2rem; 21 | cursor: pointer; 22 | } 23 | & .small { 24 | display: block; 25 | max-width: 600px; 26 | margin: 0 auto; 27 | color: var(--text); 28 | font-size: 90%; 29 | } 30 | } 31 | 32 | .content { 33 | display: flex; 34 | flex-wrap: wrap; 35 | 36 | .content-left, 37 | .content-right { 38 | flex: 0 0 50%; 39 | overflow: hidden; 40 | 41 | @media #{$Large} { 42 | flex: 0 0 100%; 43 | } 44 | } 45 | 46 | .content-left { 47 | padding-right: 2rem; 48 | 49 | @media #{$Large} { 50 | padding-right: 0; 51 | } 52 | } 53 | } 54 | 55 | .download-booklist, 56 | .booklist, 57 | .highlight-preview { 58 | position: relative; 59 | margin-top: 3rem; 60 | padding: 2rem 1.5rem; 61 | border: 0.1rem solid var(--title); 62 | color: var(--title); 63 | } 64 | 65 | .download-booklist::before, 66 | .booklist::before, 67 | .highlight-preview::before { 68 | position: absolute; 69 | top: -1.8rem; 70 | left: 1.5rem; 71 | padding: 0 1.5rem; 72 | background-color: var(--basic); 73 | font-size: 2rem; 74 | } 75 | 76 | .highlight-preview::before { 77 | content: "Highlight Preview"; 78 | } 79 | 80 | .download-booklist { 81 | text-align: center; 82 | &::before { 83 | content: "Export Book List"; 84 | } 85 | 86 | & button { 87 | margin-right: 1rem; 88 | &:last-child { 89 | margin-right: 0; 90 | } 91 | 92 | @media #{$Small} { 93 | width: 100%; 94 | margin-right: 0; 95 | margin-bottom: 1rem; 96 | &:last-child { 97 | margin-bottom: 0; 98 | } 99 | } 100 | } 101 | } 102 | 103 | .booklist { 104 | &::before { 105 | content: "Export Highlight"; 106 | } 107 | 108 | .booklist-scroll { 109 | min-height: 260px; 110 | max-height: calc(100vh - 565px); 111 | padding-right: 1.3rem; 112 | overflow-y: scroll; 113 | } 114 | 115 | .book-title { 116 | padding: 0.5rem; 117 | border-bottom: 0.1rem solid var(--dark-text); 118 | cursor: pointer; 119 | 120 | &:hover { 121 | background-color: var(--text-hover); 122 | } 123 | } 124 | } 125 | 126 | .highlight-preview { 127 | height: calc(100% - 30px); 128 | 129 | .highlight-details { 130 | display: flex; 131 | align-items: center; 132 | 133 | .highlight-title { 134 | flex: 1; 135 | overflow: hidden; 136 | text-overflow: ellipsis; 137 | white-space: nowrap; 138 | } 139 | } 140 | 141 | .highlight-textarea { 142 | width: 100%; 143 | height: calc(100% - 30px); 144 | min-height: 300px; 145 | padding: 1rem; 146 | background-color: var(--basic); 147 | color: var(--title); 148 | font-family: var(--font); 149 | font-size: 1.6rem; 150 | line-height: 1.7; 151 | } 152 | } 153 | 154 | footer { 155 | color: hsl(0deg 0% 50%); 156 | font-size: 85%; 157 | text-align: center; 158 | a { 159 | color: var(--text); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/assets/css/variable.scss: -------------------------------------------------------------------------------- 1 | $Extra-large: "only screen and (max-width : 1200px)"; 2 | $Large: "only screen and (max-width : 992px)"; 3 | $Medium: "only screen and (max-width : 768px)"; 4 | $Small: "only screen and (max-width : 650px)"; 5 | 6 | * { 7 | --font: system-ui, -apple-system, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 8 | --brand-hue: 30; 9 | --brand-saturation: 50%; 10 | --brand-lightness: 97%; 11 | --light-basic: hsl(var(--brand-hue) 0% 100%); 12 | --light-background: 13 | hsl( 14 | var(--brand-hue) var(--brand-saturation) var(--brand-lightness) 15 | ); 16 | --light-title: hsl(var(--brand-hue) 0% 10%); 17 | --light-text: hsl(var(--brand-hue) 0% 30%); 18 | --light-text-hover: hsl(var(--brand-hue) 0% 90%); 19 | --light-button-text: hsl(var(--brand-hue) 0% 100%); 20 | --dark-basic: hsl(var(--brand-hue) 0% 0%); 21 | --dark-background: 22 | hsl( 23 | var(--brand-hue) calc(var(--brand-saturation) / 50) 24 | calc(var(--brand-lightness) / 10) 25 | ); 26 | --dark-title: hsl(var(--brand-hue) 0% 90%); 27 | --dark-text: hsl(var(--brand-hue) 0% 70%); 28 | --dark-text-hover: hsl(var(--brand-hue) 0% 20%); 29 | --dark-button-text: hsl(var(--brand-hue) 0% 0%); 30 | } 31 | 32 | :root { 33 | --background: var(--light-background); 34 | --basic: var(--light-basic); 35 | --title: var(--light-title); 36 | --text: var(--light-text); 37 | --text-hover: var(--light-text-hover); 38 | } 39 | 40 | @media (prefers-color-scheme: dark) { 41 | :root { 42 | --background: var(--dark-background); 43 | --basic: var(--dark-basic); 44 | --title: var(--dark-title); 45 | --text: var(--dark-text); 46 | --text-hover: var(--dark-text-hover); 47 | } 48 | } 49 | 50 | [data-theme="light"] { 51 | --background: var(--light-background); 52 | --basic: var(--light-basic); 53 | --title: var(--light-title); 54 | --text: var(--light-text); 55 | --text-hover: var(--light-text-hover); 56 | } 57 | 58 | [data-theme="dark"] { 59 | --background: var(--dark-background); 60 | --basic: var(--dark-basic); 61 | --title: var(--dark-title); 62 | --text: var(--dark-text); 63 | --text-hover: var(--dark-text-hover); 64 | } -------------------------------------------------------------------------------- /src/assets/js/booklist.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive } from 'vue' 2 | import { exportFile } from './tools'; 3 | import { BookListSQL } from './sql' 4 | import { Setting } from './type'; 5 | 6 | 7 | interface BookListData { 8 | loading: boolean 9 | content: BookListDetails[] 10 | Get: () => void 11 | Export: (action: string) => void 12 | } 13 | 14 | interface BookListDetailExport { 15 | Author: string 16 | BookTitle: string 17 | FileSize: number 18 | ISBN: string 19 | LastRead: string 20 | Publisher: string 21 | Rating: number 22 | ReadPercent: number 23 | ReleaseDate: string 24 | Series: string 25 | SeriesNumber: string 26 | Source: string 27 | SubTitle: string 28 | } 29 | 30 | interface BookListDetails extends BookListDetailExport { 31 | ContentID: string 32 | } 33 | 34 | export const GetBookListData = (setting: Setting) => { 35 | const exportContent = computed(() => { 36 | return bookListData.content.reduce((old, curr) => { 37 | const { ContentID: _, ...details } = curr 38 | old.push(details) 39 | return old 40 | }, [] as BookListDetailExport[]) 41 | }); 42 | const bookListData = reactive({ 43 | loading: false, 44 | content: [], 45 | Get: () => { 46 | const [res] = setting.dbData.exec(BookListSQL) 47 | bookListData.loading = true 48 | bookListData.content = res.values.map(element => 49 | element.reduce( 50 | (old, curr, index) => ({ 51 | ...old, 52 | [res.columns[index]]: curr, 53 | }), 54 | {} as BookListDetails 55 | ) 56 | ) 57 | }, 58 | Export: (action: string) => { 59 | let content = '' 60 | const temp: Array = JSON.parse(JSON.stringify(exportContent)) 61 | switch (action) { 62 | case 'json': 63 | content = JSON.stringify(temp) 64 | break 65 | case 'md': 66 | content += `| ${Object.keys(temp[0]).join(' | ')} |\n` 67 | content += `| ${Object.keys(temp[0]).reduce(old => (old += '--- | '), '')}\n` 68 | delete temp[0] 69 | content += temp.reduce((old, curr) => (old += `| ${Object.values(curr).join(' | ')} |\n`), '') 70 | break 71 | case 'csv': 72 | content += `"${Object.keys(temp[0]).join('","')}"\n` 73 | delete temp[0] 74 | content += temp.reduce((old, curr) => (old += `"${Object.values(curr).join('","')}"\n`), '') 75 | break 76 | } 77 | exportFile(`bookList.${action}`, content) 78 | }, 79 | }) 80 | return bookListData 81 | } -------------------------------------------------------------------------------- /src/assets/js/highlight.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue' 2 | import { exportFile } from './tools'; 3 | import { HighLightSQL } from './sql' 4 | import { Setting } from './type'; 5 | 6 | interface HighlightData { 7 | loading: boolean 8 | title: string 9 | content: string 10 | Get: (contentID: string, title: string) => void 11 | Export: () => void 12 | } 13 | 14 | export const GetHighLightData = (setting: Setting) => { 15 | const highlightData = reactive({ 16 | loading: false, 17 | title: '', 18 | content: '', 19 | Get: (contentID, title) => { 20 | const [res] = setting.dbData.exec(HighLightSQL(contentID)) 21 | highlightData.loading = true 22 | highlightData.title = title 23 | highlightData.content = res?.values.join('\n\n').split(',').join(' \n') 24 | }, 25 | Export: () => { 26 | exportFile(`${highlightData.title}.md`, highlightData.content) 27 | }, 28 | }) 29 | return highlightData 30 | } 31 | -------------------------------------------------------------------------------- /src/assets/js/sql.ts: -------------------------------------------------------------------------------- 1 | const BookListSQL = `SELECT 2 | IFNULL(ContentID,'') as 'ContentID', 3 | IFNULL(Title,'') as 'BookTitle', 4 | IFNULL(Subtitle,'') as 'SubTitle', 5 | IFNULL(Attribution,'') as 'Author', 6 | IFNULL(Publisher,'') as 'Publisher', 7 | IFNULL(ISBN,0) as 'ISBN', 8 | IFNULL(date(DateCreated),'') as 'ReleaseDate', 9 | IFNULL(Series,'') as 'Series', 10 | IFNULL(SeriesNumber,0) as 'SeriesNumber', 11 | IFNULL(AverageRating,0) as 'Rating', 12 | IFNULL(___PercentRead,0) as 'ReadPercent', 13 | IFNULL(CASE WHEN ReadStatus>0 THEN datetime(DateLastRead) END,'') as 'LastRead', 14 | IFNULL(___FileSize,0) as 'FileSize', 15 | IFNULL(CASE WHEN Accessibility=1 THEN 'Store' ELSE CASE WHEN Accessibility=-1 THEN 'Import' ELSE CASE WHEN Accessibility=6 THEN 'Preview' ELSE 'Other' END END END,'') as 'Source' 16 | FROM content 17 | WHERE ContentType=6 AND ___UserId IS NOT NULL AND ___UserId != '' AND ___UserId != 'removed' 18 | ORDER BY Source desc, Title`; 19 | 20 | const HighLightSQL = (contentID: string) :string => { 21 | return `SELECT 22 | '#' || row_number() over (partition by B.Title order by T.ContentID, T.ChapterProgress), 23 | TRIM(REPLACE(REPLACE(T.Text,CHAR(10),''),CHAR(9),'')) 24 | FROM content AS B, bookmark AS T 25 | WHERE B.ContentID = T.VolumeID AND T.Text != '' AND T.Hidden = 'false' AND B.ContentID = '${contentID}' 26 | ORDER BY T.ContentID, T.ChapterProgress;`; 27 | }; 28 | 29 | export { BookListSQL, HighLightSQL }; 30 | -------------------------------------------------------------------------------- /src/assets/js/tools.ts: -------------------------------------------------------------------------------- 1 | export const exportFile = (filename: string, content: string) => { 2 | const element = document.createElement('a') 3 | Object.assign(element, { 4 | href: `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`, 5 | download: filename, 6 | display: 'none', 7 | }) 8 | document.body.appendChild(element) 9 | element.click() 10 | document.body.removeChild(element) 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/js/type.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'sql.js' 2 | 3 | export interface Setting { 4 | fileName: string 5 | dbData: Database | null, 6 | importFile: (e: DragEvent) => void 7 | } 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import '@/assets/css/style.scss' 4 | import initSqlJs from 'sql.js' 5 | 6 | const app = createApp(App) 7 | app.config.globalProperties.SQL = await initSqlJs({ 8 | // locateFile: file => `https://sql.js.org/dist/${file}` 9 | locateFile: file => `./${file}` 10 | }); 11 | app.mount('#app').$nextTick(() => { 12 | postMessage({ payload: 'removeLoading' }, '*') 13 | }) 14 | 15 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | background-color: #242424; 3 | color: rgba(255, 255, 255, 0.87); 4 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 5 | font-size: 16px; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | font-weight: 400; 9 | line-height: 24px; 10 | text-rendering: optimizeLegibility; 11 | color-scheme: light dark; 12 | font-synthesis: none; 13 | -webkit-text-size-adjust: 100%; 14 | } 15 | 16 | a { 17 | color: #646cff; 18 | font-weight: 500; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | display: flex; 27 | min-width: 320px; 28 | min-height: 100vh; 29 | margin: 0; 30 | place-items: center; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | padding: 0.6em 1.2em; 40 | transition: border-color 0.25s; 41 | border: 1px solid transparent; 42 | border-radius: 8px; 43 | background-color: #1a1a1a; 44 | font-family: inherit; 45 | font-size: 1em; 46 | font-weight: 500; 47 | cursor: pointer; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | .card { 58 | padding: 2em; 59 | } 60 | 61 | #app { 62 | max-width: 1280px; 63 | margin: 0 auto; 64 | padding: 2rem; 65 | text-align: center; 66 | } 67 | 68 | @media (prefers-color-scheme: light) { 69 | :root { 70 | background-color: #ffffff; 71 | color: #213547; 72 | } 73 | a:hover { 74 | color: #747bff; 75 | } 76 | button { 77 | background-color: #f9f9f9; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "lib": ["ESNext", "DOM"], 13 | "skipLibCheck": true, 14 | "noEmit": true, 15 | "noImplicitReturns": false, 16 | "strictNullChecks":false 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "electron"], 19 | "references": [{ "path": "./tsconfig.node.json" }] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import { VitePWA } from 'vite-plugin-pwa' 5 | import eslintPlugin from 'vite-plugin-eslint' 6 | import StylelintPlugin from 'vite-plugin-stylelint' 7 | import electron from 'vite-plugin-electron/simple' 8 | import path from 'node:path' 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig(({ mode }) => { 12 | const isElectron = mode === 'electron' 13 | return { 14 | ...(!isElectron && {base: '/kobo-book-exporter/'}), 15 | build: { 16 | ...(!isElectron && { outDir: 'dist-page', emptyOutDir: true }), 17 | target: 'esnext' 18 | }, 19 | plugins: [ 20 | vue(), 21 | electron({ 22 | main: { 23 | entry: 'electron/main.ts', 24 | }, 25 | preload: { 26 | input: path.join(__dirname, 'electron/preload.ts'), 27 | }, 28 | renderer: {}, 29 | }), 30 | VitePWA({ 31 | workbox: { 32 | sourcemap: true 33 | }, 34 | mode: 'development', 35 | registerType: 'autoUpdate', 36 | includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], 37 | manifest: { 38 | name: 'Kobo Book Exporter', 39 | short_name: 'Kobo Book Exporter', 40 | description: 'Kobo Book Exporter', 41 | theme_color: '#0079d2', 42 | start_url: './index.html', 43 | lang: 'zh-Hant-HK', 44 | dir: 'ltr', 45 | orientation: 'portrait', 46 | icons: [ 47 | { 48 | src: 'img/icons/android-chrome-192x192.png', 49 | sizes: '192x192', 50 | type: 'image/png' 51 | }, 52 | { 53 | src: 'img/icons/android-chrome-512x512.png', 54 | sizes: '512x512', 55 | type: 'image/png' 56 | }, 57 | { 58 | src: 'img/icons/android-chrome-maskable-192x192.png', 59 | sizes: '192x192', 60 | type: 'image/png', 61 | purpose: 'maskable' 62 | }, 63 | { 64 | src: 'img/icons/android-chrome-maskable-512x512.png', 65 | sizes: '512x512', 66 | type: 'image/png', 67 | purpose: 'maskable' 68 | } 69 | ] 70 | } 71 | }), 72 | StylelintPlugin({ 73 | fix: true, 74 | }), 75 | eslintPlugin({ 76 | include: ['src/**/*.vue', 'src/**/*.js'], 77 | }), 78 | ], 79 | resolve: { 80 | alias: { 81 | '@': fileURLToPath(new URL('./src', import.meta.url)), 82 | }, 83 | }, 84 | } 85 | }) 86 | --------------------------------------------------------------------------------