diff --git a/electron.vite.config.ts b/electron.vite.config.ts index de5f2e9..5122332 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -58,6 +58,12 @@ export default defineConfig({ stream: fileURLToPath(new URL('./node_modules/stream-browserify', import.meta.url)), util: fileURLToPath(new URL('./node_modules/util', import.meta.url)) } + }, + css: { + preprocessorOptions: { + sass: { api: 'modern-compiler' }, + scss: { api: 'modern-compiler' } + } } } }) diff --git a/package.json b/package.json index 9769563..66c5f4f 100644 --- a/package.json +++ b/package.json @@ -72,27 +72,27 @@ "@types/ini": "^4.1.1", "@types/leveldown": "^4.0.6", "@types/levelup": "^5.1.5", - "@types/node": "^20.17.6", + "@types/node": "^20.17.7", "@types/pouchdb-core": "^7.0.15", "@types/pouchdb-find": "^7.3.3", "@types/ws": "^8.5.13", - "@typescript-eslint/eslint-plugin": "^8.12.2", - "@typescript-eslint/parser": "^8.12.2", - "@vitejs/plugin-vue": "^5.1.4", - "@vitejs/plugin-vue-jsx": "^4.0.1", - "@vitest/coverage-v8": "^2.1.4", + "@typescript-eslint/eslint-plugin": "^8.16.0", + "@typescript-eslint/parser": "^8.16.0", + "@vitejs/plugin-vue": "^5.2.0", + "@vitejs/plugin-vue-jsx": "^4.1.0", + "@vitest/coverage-v8": "^2.1.5", "@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-typescript": "^13.0.0", - "@vue/tsconfig": "^0.5.1", + "@vue/tsconfig": "^0.6.0", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^11.2.0", - "@vueuse/shared": "^11.2.0", + "@vueuse/core": "^11.3.0", + "@vueuse/shared": "^11.3.0", "@zip.js/zip.js": "^2.7.53", "assert": "^2.1.0", "auto-bind": "^5.0.1", "bufferutil": "^4.0.8", - "electron": "^31.7.3", + "electron": "^31.7.5", "electron-builder": "^24.13.3", "electron-unhandled": "^5.0.0", "electron-updater": "^6.3.9", @@ -102,16 +102,16 @@ "eslint-import-resolver-node": "^0.3.9", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-n": "^17.12.0", + "eslint-plugin-n": "^17.14.0", "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-promise": "^7.1.0", - "eslint-plugin-vue": "^9.30.0", + "eslint-plugin-promise": "^7.2.0", + "eslint-plugin-vue": "^9.31.0", "execa": "^9.5.1", - "husky": "^9.1.6", + "husky": "^9.1.7", "ini": "^5.0.0", "levelup": "^5.1.1", "mime": "^4.0.4", - "npm-check-updates": "^17.1.10", + "npm-check-updates": "^17.1.11", "npm-run-all2": "^7.0.1", "pinia": "^2.2.6", "pouchdb-adapter-leveldb-core": "^9.0.0", @@ -119,32 +119,34 @@ "pouchdb-find": "^9.0.0", "prettier": "^3.3.3", "radash": "^12.1.0", - "sass": "^1.80.6", + "sass-embedded": "^1.81.0", "superjson": "^2.2.1", "tslib": "^2.8.1", - "type-fest": "^4.26.1", - "typescript": "^5.6.3", + "type-fest": "^4.28.0", + "typescript": "5.6.3", "typescript-eslint-parser-for-extra-files": "^0.7.0", "utf-8-validate": "^6.0.5", - "vite": "^5.4.10", - "vite-plugin-vue-devtools": "^7.6.2", + "vite": "^5.4.11", + "vite-plugin-vue-devtools": "^7.6.4", "vite-plugin-vuetify": "^2.0.4", - "vite-tsconfig-paths": "^5.0.1", - "vitest": "^2.1.4", - "vue": "^3.5.12", + "vite-tsconfig-paths": "^5.1.3", + "vitest": "^2.1.5", + "vue": "^3.5.13", "vue-eslint-parser": "^9.4.3", "vue-i18n": "^10.0.4", - "vue-router": "^4.4.5", - "vue-tsc": "^2.1.10", - "vuetify": "^3.7.3", + "vue-router": "^4.5.0", + "vue-tsc": "2.1.10", + "vuetify": "^3.7.4", "ws": "^8.18.0", "xdg-basedir": "^5.1.0", "zod": "^3.23.8" }, "dependencies": { "@electron-toolkit/utils": "^3.0.0", - "electron-log": "^5.2.0", + "@types/pouchdb-mapreduce": "^6.1.10", + "electron-log": "^5.2.3", "leveldown": "^6.1.1", + "pouchdb-mapreduce": "^9.0.0", "serialport": "^12.0.0" } } diff --git a/src/core/error-handling.ts b/src/core/error-handling.ts index af37d0f..f33a837 100644 --- a/src/core/error-handling.ts +++ b/src/core/error-handling.ts @@ -46,19 +46,3 @@ export function isNodeError(value: unknown, type: new (...args: any[]) => Error export function raiseError(factory: () => Error): never { throw factory() } - -export function warnPromiseFailures(msg: string, results: PromiseSettledResult[]) { - for (const result of results) { - if (result.status === 'rejected') { - console.warn(msg, result.reason) - } - } -} - -export function logPromiseFailures(msg: string, results: PromiseSettledResult[]) { - for (const result of results) { - if (result.status === 'rejected') { - console.error(msg, result.reason) - } - } -} diff --git a/src/main/boot/01-normalize-source-order.ts b/src/main/boot/01-normalize-source-order.ts new file mode 100644 index 0000000..737ee41 --- /dev/null +++ b/src/main/boot/01-normalize-source-order.ts @@ -0,0 +1,6 @@ +import useSourcesDatabase from '../dao/sources' + +export async function boot() { + const sourcesDb = useSourcesDatabase() + await sourcesDb.normalizeOrder() +} diff --git a/src/main/dao/sources.ts b/src/main/dao/sources.ts index 42b59d0..f9b62c7 100644 --- a/src/main/dao/sources.ts +++ b/src/main/dao/sources.ts @@ -5,6 +5,7 @@ import useTiesDatabase from './ties' import type { DocumentId, RevisionId } from '../services/database' export const SourceModel = z.object({ + order: z.number().nonnegative().finite(), title: z.string().min(1), image: z.string().min(1).nullable() }) @@ -14,6 +15,36 @@ const useSourcesDatabase = memo( new (class extends Database.of('sources', SourceModel) { readonly #ties = useTiesDatabase() + async getNextOrderValue() { + return await this.run(async function getNextOrderValue(db) { + const mapped = await db.query({ + /* v8 ignore next 2 */ // Not executed in a way V8 can see. + map: (doc, emit) => emit?.(doc.order, doc.order), + reduce: (_, values) => 1 + Math.max(...values.map(Number)) + }) + + const row = mapped.rows[0] + if (row == null) return 0 + + return row.value as number + }) + } + + async normalizeOrder() { + const sources = await this.all() + let i = 0 + for (const source of sources.toSorted((a, b) => a.order - b.order)) { + source.order = i + i += 1 + } + + await Promise.all( + sources.map(async (source) => { + await this.update(source) + }) + ) + } + override async remove(id: DocumentId, rev?: RevisionId) { await super.remove(id, rev) diff --git a/src/main/main.ts b/src/main/main.ts index 1743be0..82ed647 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,6 +6,7 @@ import Logger from 'electron-log' import { sleep } from 'radash' import appIcon from '../../resources/icon.png?asset&asarUnpack' import { useAppRouter } from './routes/router' +import useBootOperations from './services/boot' import useMigrations from './services/migration' import { createIpcHandler } from './services/rpc/ipc' import { logError } from './utilities' @@ -131,6 +132,9 @@ await migrate().catch((cause: unknown) => { Logger.error(cause) }) +const boot = useBootOperations() +await boot() + // Set app user model id for windows electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') diff --git a/src/main/migrations/20241124121000-add-source-order.ts b/src/main/migrations/20241124121000-add-source-order.ts new file mode 100644 index 0000000..bc7f5de --- /dev/null +++ b/src/main/migrations/20241124121000-add-source-order.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { Database } from '../services/database' + +export async function migrate() { + const OldModel = z.object({ + order: z.number().nonnegative().finite().optional(), + title: z.string().min(1), + image: z.string().min(1).nullable() + }) + + const sourcesDb = new Database('sources', OldModel) + const sources = await sourcesDb.all() + await Promise.all( + sources.map(async (source, order) => { + await sourcesDb.update({ order, ...source }) + }) + ) + + await sourcesDb.close() +} diff --git a/src/main/routes/data/sources.ts b/src/main/routes/data/sources.ts index 1bf6c29..c775020 100644 --- a/src/main/routes/data/sources.ts +++ b/src/main/routes/data/sources.ts @@ -24,6 +24,11 @@ export default function useSourcesRouter() { }), clear: procedure.mutation(async () => { await sources.clear() + }), + // Utilities + getNextOrderValue: procedure.query(async () => await sources.getNextOrderValue()), + normalizeOrder: procedure.mutation(async () => { + await sources.normalizeOrder() }) }) } diff --git a/src/main/services/boot.ts b/src/main/services/boot.ts new file mode 100644 index 0000000..908e5ee --- /dev/null +++ b/src/main/services/boot.ts @@ -0,0 +1,21 @@ +import { basename } from 'node:path' +import Logger from 'electron-log' +import { z } from 'zod' + +const BootModule = z.object({ + boot: z.function(z.tuple([]), z.unknown()) +}) + +export default function useBootOperations() { + Logger.debug('Loading boot modules') + const bootModules = import.meta.glob('../boot/**/*', { eager: true }) + return async function boot() { + Logger.debug('Starting boot up') + for (const [name, module] of Object.entries(bootModules)) { + const bootable = BootModule.parse(module) + Logger.debug(`Attempting boot: ${basename(name, '.ts')}`) + // eslint-disable-next-line no-await-in-loop -- Design to allow serialization. + await bootable.boot() + } + } +} diff --git a/src/main/services/database.ts b/src/main/services/database.ts index 2562e00..5d68efb 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import PouchDb from 'pouchdb-core' import find from 'pouchdb-find' +import mapReduce from 'pouchdb-mapreduce' import { map } from 'radash' import { z } from 'zod' import { useLevelAdapter } from './level' @@ -95,6 +96,7 @@ export function inferUpsertOf(schema: Schema) { PouchDb.plugin(useLevelAdapter()) PouchDb.plugin(find) +PouchDb.plugin(mapReduce) /** The basis of a database. */ export class Database { @@ -208,6 +210,7 @@ export class Database { }) } + /** Prepares the document. */ protected async prepare(doc: T) { const { _attachments, _conflicts, _revs_info, _revisions, ...document } = doc const result = { ...document, _attachments: await prepareAttachments(_attachments as never) } diff --git a/src/renderer/assets/main.scss b/src/renderer/assets/main.scss index 57b50c4..4b5c311 100644 --- a/src/renderer/assets/main.scss +++ b/src/renderer/assets/main.scss @@ -50,6 +50,7 @@ $widthspx: 50 75 100 125 150 175 200 250 300 400 500 600 700 800 900; width: string.unquote('#{$width}%') !important; } } + @each $width in $widthspx { .minw-#{$width}px { min-width: string.unquote('#{$width}px') !important; @@ -61,9 +62,11 @@ $widthspx: 50 75 100 125 150 175 200 250 300 400 500 600 700 800 900; width: string.unquote('#{$width}px') !important; } } + .colg { column-gap: settings.$grid-gutter; } + .rowg { row-gap: settings.$grid-gutter; } diff --git a/src/renderer/pages/MainDashboard.vue b/src/renderer/pages/MainDashboard.vue index 79abeb0..83930a7 100644 --- a/src/renderer/pages/MainDashboard.vue +++ b/src/renderer/pages/MainDashboard.vue @@ -1,7 +1,8 @@