From bc4d3bebc042424aab255612c0a29a4bfbe3a1c6 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 18 Sep 2024 18:32:41 +0200 Subject: [PATCH] feat: registerConfig support --- cmd/app/electron.go | 13 +++++++ cmd/app/main.go | 21 +++++++---- frontend/src/js/core/generator-ipc.ts | 35 +++++++++++++++++++ frontend/src/js/core/guid.ts | 3 ++ frontend/src/js/core/store.ts | 20 +++++++++++ frontend/src/js/core/templating.ts | 15 ++++++++ frontend/src/js/types/config.ts | 35 +++++++++++++++++++ frontend/src/js/types/generator.ts | 11 ++++++ frontend/src/js/types/settings.ts | 5 +-- frontend/src/js/types/template.ts | 8 +++++ .../src/js/ui/components/editor/generator.ts | 2 ++ .../ui/components/print-preview-template.ts | 2 ++ .../src/js/ui/components/print-preview.ts | 32 ++++++++++++----- .../view-layout/sidebar-print-page.ts | 3 +- frontend/src/js/ui/views/generator/create.ts | 6 +++- frontend/src/js/ui/views/generator/edit.ts | 6 +++- frontend/src/js/ui/views/generator/single.ts | 7 ++-- frontend/src/js/workers/templating-worker.js | 2 +- server/server.go | 18 ++++++++++ 19 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 frontend/src/js/core/generator-ipc.ts diff --git a/cmd/app/electron.go b/cmd/app/electron.go index bbc7384c..f1b61638 100644 --- a/cmd/app/electron.go +++ b/cmd/app/electron.go @@ -4,6 +4,7 @@ package main import ( + "fmt" "io" "io/ioutil" stdlog "log" @@ -40,6 +41,18 @@ func init() { } func startElectron(db database.Database, debug bool) { + // Writing the preload.js file that is needed for the electron web-view + if err := os.WriteFile(filepath.Join(sndDataDir, "/preload.js"), []byte(` +// Helper for Sales & Dungeons to let template/generator preview communicate with the main process +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("api", { + sendData: (type, data) => { + ipcRenderer.sendToHost("data", type, JSON.stringify(data)); + }, +});`), 0666); err != nil { + panic(fmt.Errorf("could not write preload.js file: %w", err)) + } + // Start the S&D Backend in separate go-routine. go func() { startServer(db, debug) diff --git a/cmd/app/main.go b/cmd/app/main.go index 42eeff4b..09fad5b2 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -47,20 +47,27 @@ func isMacAppBundle() bool { return strings.Contains(execDir, "Sales & Dungeons.app") } -func openDatabase() database.Database { - userdata := "./userdata/" +func getSndDataDir() string { + dir := "./" if isMacAppBundle() { home, err := os.UserHomeDir() if err != nil { panic(err) } - userdata = filepath.Join(home, "/Documents/Sales & Dungeons/userdata") - - err = os.MkdirAll(userdata, 0777) - fmt.Println("INFO: changed userdata folder because of app bundle", userdata, err) + dir = filepath.Join(home, "/Documents/Sales & Dungeons") + err = os.MkdirAll(dir, 0777) + fmt.Println("INFO: changed data folder because of app bundle", dir, err) } + return dir +} + +var sndDataDir = getSndDataDir() + +func openDatabase() database.Database { + userdata := filepath.Join(sndDataDir, "userdata") + db, err := badger.New(userdata) if err != nil { panic(err) @@ -85,7 +92,7 @@ func openDatabase() database.Database { func startServer(db database.Database, debug bool) { rand.Seed(time.Now().UnixNano()) - s, err := server.New(db, append(serverOptions, server.WithDebug(debug), server.WithPrinter(&cups.CUPS{}), server.WithPrinter(&remote.Remote{}), server.WithPrinter(&serial.Serial{}), server.WithPrinter(&dump.Dump{}))...) + s, err := server.New(db, append(serverOptions, server.WithDataDir(sndDataDir), server.WithDebug(debug), server.WithPrinter(&cups.CUPS{}), server.WithPrinter(&remote.Remote{}), server.WithPrinter(&serial.Serial{}), server.WithPrinter(&dump.Dump{}))...) if err != nil { panic(err) } diff --git a/frontend/src/js/core/generator-ipc.ts b/frontend/src/js/core/generator-ipc.ts new file mode 100644 index 00000000..d8dc8a94 --- /dev/null +++ b/frontend/src/js/core/generator-ipc.ts @@ -0,0 +1,35 @@ +import m from 'mithril'; + +import { filterOutDynamicConfigValues, mergeConfigValues } from 'js/types/config'; +import Generator, { sanitizeConfig } from 'js/types/generator'; + +/** + * Creates a function that handles IPC messages. These messages can either be from a webview or a iframe. + * They are used to communicate from the generator to the editor. + * @param generator The generator object. + * @param state The state object. + */ +export function createOnMessage(generator?: Generator | null, state?: { config: any }) { + return (type: string, msg: any) => { + console.log('IPCMessage', type, msg); + + if (!generator || !state) { + return; + } + + if (type === 'registerConfig') { + if (Array.isArray(msg)) { + generator.config = mergeConfigValues( + filterOutDynamicConfigValues(generator.config), + msg.map((c) => ({ + ...c, + isDynamic: true, + })), + ); + state.config = sanitizeConfig(generator, state.config); + } + } + + m.redraw(); + }; +} diff --git a/frontend/src/js/core/guid.ts b/frontend/src/js/core/guid.ts index e38668fa..b0f8ffc8 100644 --- a/frontend/src/js/core/guid.ts +++ b/frontend/src/js/core/guid.ts @@ -1 +1,4 @@ +/** + * Generate a unique identifier + */ export default () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); diff --git a/frontend/src/js/core/store.ts b/frontend/src/js/core/store.ts index 00149daa..1520b9b1 100644 --- a/frontend/src/js/core/store.ts +++ b/frontend/src/js/core/store.ts @@ -1,3 +1,4 @@ +import m from 'mithril'; import { flatten } from 'lodash-es'; import Fuse from 'fuse.js'; @@ -62,6 +63,7 @@ type Store = { ai: { token: string; }; + dataDir: string; }; const initialState: Store = { @@ -80,6 +82,7 @@ const initialState: Store = { ai: { token: '', }, + dataDir: '', }; const store = create(initialState, (atom) => ({ @@ -88,6 +91,7 @@ const store = create(initialState, (atom) => ({ */ loadAll(noSettings: boolean = false) { return Promise.all([ + this.loadDataDir(), ...(noSettings ? [] : [this.loadSettings()]), this.loadTemplates(), this.loadGenerators(), @@ -99,6 +103,21 @@ const store = create(initialState, (atom) => ({ ]); }, + /** + * LoadDataDir loads the data directory from the backend. + */ + loadDataDir() { + m.request({ + method: 'GET', + url: '/api/dataDir', + }).then((res) => + atom.update((state) => ({ + ...state, + dataDir: res as string, + })), + ); + }, + /** * LoadSettings loads the settings from the backend. */ @@ -263,3 +282,4 @@ export const printer = store.focus('printer'); export const printerTypes = store.focus('printerTypes'); export const packages = store.focus('publicLists'); export const ai = store.focus('ai'); +export const dataDir = store.focus('dataDir'); diff --git a/frontend/src/js/core/templating.ts b/frontend/src/js/core/templating.ts index 84713b3f..8440617e 100644 --- a/frontend/src/js/core/templating.ts +++ b/frontend/src/js/core/templating.ts @@ -139,6 +139,20 @@ const aiScript = ` `; +const IPCScript = ` + +`; + /** * State object for template rendering. */ @@ -209,6 +223,7 @@ export const render = ( let additional = ''; additional += rngScript(clonedState.config.seed ?? Math.ceil(Math.random() * 500000000)); additional += aiScript; + additional += IPCScript; if (minimal) { additional = ''; diff --git a/frontend/src/js/types/config.ts b/frontend/src/js/types/config.ts index b1d682c3..50eaa91b 100644 --- a/frontend/src/js/types/config.ts +++ b/frontend/src/js/types/config.ts @@ -4,6 +4,11 @@ export type ConfigValue = { description: string; type: string; default: any; + + /** + * Whether the config value is dynamic and should not be saved to the generator. + */ + isDynamic?: boolean; }; /** @@ -18,3 +23,33 @@ export const fillConfigValues = (config: any, configValues: ConfigValue[]) => { } return result; }; + +/** + * Merge two config value arrays. If a value is present in both arrays, the value from the main array is used. + * @param main Main config value array. + * @param merge Config value array to merge into the main array. + */ +export const mergeConfigValues = (main: ConfigValue[], merge: ConfigValue[]) => { + main = filterValidConfigValues(main); + merge = filterValidConfigValues(merge); + + const result: ConfigValue[] = []; + const mainKeys = main.map((c) => c.key); + for (const config of merge) { + if (!mainKeys.includes(config.key)) { + result.push(config); + } + } + return [...main, ...result]; +}; + +/** + * Filter out invalid config values. A check for all values is performed. + */ +export const filterValidConfigValues = (configValues: ConfigValue[]) => + configValues.filter((config) => !!config.key && !!config.name && !!config.description && !!config.type && config.default != undefined); + +/** + * Filter out dynamic config values. + */ +export const filterOutDynamicConfigValues = (configValues: ConfigValue[]) => configValues.filter((config) => !config.isDynamic); diff --git a/frontend/src/js/types/generator.ts b/frontend/src/js/types/generator.ts index 649f541e..301a950e 100644 --- a/frontend/src/js/types/generator.ts +++ b/frontend/src/js/types/generator.ts @@ -12,10 +12,18 @@ type Generator = BasicInfo & { count?: number; }; +/** + * Generates a random seed. + */ function seed() { return Math.ceil(Math.random() * 999999999).toString(); } +/** + * Sanitizes the config object by setting default values for missing fields and removing old fields that are not present in the config anymore. + * @param g Generator object. + * @param configs Config object. + */ const sanitizeConfig = (g: Generator, configs: any) => { // Create base config if (configs === undefined) { @@ -40,6 +48,9 @@ const sanitizeConfig = (g: Generator, configs: any) => { return pickBy(configs, (val, key) => key === 'seed' || g.config.some((conf) => conf.key === key)); }; +/** + * Creates an empty generator object. + */ function createEmptyGenerator(): Generator { return { name: 'Your Generator Name', diff --git a/frontend/src/js/types/settings.ts b/frontend/src/js/types/settings.ts index fc4f36ee..7a6c3308 100644 --- a/frontend/src/js/types/settings.ts +++ b/frontend/src/js/types/settings.ts @@ -14,7 +14,6 @@ type Settings = { printerEndpoint: string; printerWidth: number; commands: Commands; - stylesheets: string[]; spellcheckerLanguages: string[]; packageRepos: string[]; syncKey: string; @@ -29,6 +28,9 @@ type Settings = { aiUrl: string; }; +/** + * Create an empty settings object. + */ export function createEmptySettings(): Settings { return { printerType: 'unknown', @@ -44,7 +46,6 @@ export function createEmptySettings(): Settings { splitHeight: 0, splitDelay: 0, }, - stylesheets: [], spellcheckerLanguages: [], packageRepos: [], syncKey: '', diff --git a/frontend/src/js/types/template.ts b/frontend/src/js/types/template.ts index c6a5e75e..31f24e38 100644 --- a/frontend/src/js/types/template.ts +++ b/frontend/src/js/types/template.ts @@ -13,6 +13,11 @@ type Template = BasicInfo & { count?: number; }; +/** + * Sanitizes the config object by setting default values for missing fields and removing old fields that are not present in the config anymore. + * @param t Template object. + * @param configs Config object. + */ const sanitizeConfig = (t: Template, configs: any) => { // Create base config if (configs === undefined) { @@ -30,6 +35,9 @@ const sanitizeConfig = (t: Template, configs: any) => { return pickBy(configs, (val, key) => key === 'seed' || t.config.some((conf) => conf.key === key)); }; +/** + * Creates an empty template object. + */ function createEmptyTemplate(): Template { return { name: 'Your Template Name', diff --git a/frontend/src/js/ui/components/editor/generator.ts b/frontend/src/js/ui/components/editor/generator.ts index eb5e6be8..12aa429f 100644 --- a/frontend/src/js/ui/components/editor/generator.ts +++ b/frontend/src/js/ui/components/editor/generator.ts @@ -2,6 +2,7 @@ import m from 'mithril'; import { fillConfigValues } from 'js/types/config'; import Generator, { sanitizeConfig, seed } from 'js/types/generator'; +import { createOnMessage } from 'js/core/generator-ipc'; import { createNunjucksCompletionProvider } from 'js/core/monaco/completion-nunjucks'; import { settings } from 'js/core/store'; @@ -203,6 +204,7 @@ export default (): m.Component => { onRendered: (html) => { attrs.onRendered?.(html); }, + onMessage: createOnMessage(attrs.generator, state), }), ]), ]; diff --git a/frontend/src/js/ui/components/print-preview-template.ts b/frontend/src/js/ui/components/print-preview-template.ts index eb07b938..e796d77e 100644 --- a/frontend/src/js/ui/components/print-preview-template.ts +++ b/frontend/src/js/ui/components/print-preview-template.ts @@ -34,6 +34,7 @@ export type PrintPreviewTemplateProps = { width?: number; onRendered?: (html: string) => void; onError?: (error: PrintPreviewError[]) => void; + onMessage?: (type: string, data: any) => void; }; export default (): m.Component => { @@ -124,6 +125,7 @@ export default (): m.Component => { className: attrs.className, content: lastRendered, width: attrs.width ?? 320, + onMessage: attrs.onMessage, loading, }, aiPresent && !attrs.hideAiNotice && settings.value.aiEnabled diff --git a/frontend/src/js/ui/components/print-preview.ts b/frontend/src/js/ui/components/print-preview.ts index b62ee60e..5837c231 100644 --- a/frontend/src/js/ui/components/print-preview.ts +++ b/frontend/src/js/ui/components/print-preview.ts @@ -7,7 +7,7 @@ import { inElectron } from 'js/electron'; import * as API from 'js/core/api'; import guid from 'js/core/guid'; -import { settings } from 'js/core/store'; +import { dataDir, settings } from 'js/core/store'; import Loader from 'js/ui/shoelace/loader'; @@ -25,30 +25,30 @@ const pre = ` border: 0; vertical-align: baseline; } - article, aside, details, figcaption, figure, + article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } body { line-height: 1; } - + #content::-webkit-scrollbar-thumb:hover { background: #d1d1d1; } - + #content::-webkit-scrollbar, ::-webkit-scrollbar { width: 0; } - + #content::-webkit-scrollbar-track, ::-webkit-scrollbar-track { background: #f1f1f1; } - + #content::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb { background: #c2c2c2; } - + #content::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-thumb:hover { background: #d1d1d1; } @@ -88,6 +88,7 @@ export type PrintPreviewProps = { loading?: boolean; overflow?: string; devTools?: boolean; + onMessage?: (type: string, data: any) => void; }; type PrintPreviewState = { @@ -95,6 +96,7 @@ type PrintPreviewState = { loading: boolean; lastContent: string; aiEnabled: boolean; + onMessage?: (type: string, data: any) => void; }; export function openDevTools(dom: HTMLElement) { @@ -203,7 +205,7 @@ export default (): m.Component => { let targetElement = inElectron ? 'webview' : 'iframe'; let onIFrameMessage = (event: any) => { - if (event.data.id !== state.id) { + if (event.type === 'done' && event.data.id !== state.id) { return; } @@ -212,6 +214,10 @@ export default (): m.Component => { state.loading = false; m.redraw(); break; + default: + console.log(event); + state.onMessage?.(event.data.type, event.data.data); + break; } }; @@ -224,12 +230,17 @@ export default (): m.Component => { return; } + state.onMessage = attrs.onMessage; + let scale = calcScale(attrs.width); let overflow = attrs.overflow ?? 'overlay'; if (inElectron) { frame.addEventListener('dom-ready', () => updateContent(frame as HTMLElement, attrs.content, scale, overflow), { once: true, }); + frame.addEventListener('ipc-message', (data: any) => { + state.onMessage?.(data.args[0], JSON.parse(data.args[1] ?? '{}')); + }); } else { window.addEventListener('message', onIFrameMessage); updateContent(frame as HTMLElement, attrs.content, scale, overflow); @@ -241,6 +252,8 @@ export default (): m.Component => { return; } + state.onMessage = attrs.onMessage; + updateContent(frame as HTMLElement, attrs.content, calcScale(attrs.width), attrs.overflow ?? 'overlay'); }, onremove(vnode) { @@ -254,11 +267,14 @@ export default (): m.Component => { view({ attrs, key, children }) { let width = attrs.width + PADDING * 2 + 'px'; + state.onMessage = attrs.onMessage; + let frame: m.Children; if (inElectron) { frame = m('webview.h-100', { src: 'data:text/html,', disablewebsecurity: true, + preload: `file:${dataDir.value}/preload.js`, webpreferences: 'allowRunningInsecureContent, javascript=yes', style: { width: width }, }); diff --git a/frontend/src/js/ui/components/view-layout/sidebar-print-page.ts b/frontend/src/js/ui/components/view-layout/sidebar-print-page.ts index 4872aa29..87c58b25 100644 --- a/frontend/src/js/ui/components/view-layout/sidebar-print-page.ts +++ b/frontend/src/js/ui/components/view-layout/sidebar-print-page.ts @@ -23,6 +23,7 @@ type SidebarPrintProps = { tabs: TabDefinition[]; content: Record m.Component>; onRendered?: (html: string) => void; + onMessage?: (type: string, data: any) => void; hidePreview?: boolean; }; @@ -62,8 +63,8 @@ export default (): m.Component => { config: vnode.attrs.config, width: 380, className: `.bg-black-05.ph1.ba.b--black-10${!vnode.attrs.hidePreview ? '' : '.o-0'}`, - onRendered: vnode.attrs.onRendered, + onMessage: vnode.attrs.onMessage, }) : m('div'), ]), diff --git a/frontend/src/js/ui/views/generator/create.ts b/frontend/src/js/ui/views/generator/create.ts index 8e1085d1..8f0006cf 100644 --- a/frontend/src/js/ui/views/generator/create.ts +++ b/frontend/src/js/ui/views/generator/create.ts @@ -2,6 +2,7 @@ import m from 'mithril'; import { buildId } from 'js/types/basic-info'; import Generator, { createEmptyGenerator } from 'js/types/generator'; +import { filterOutDynamicConfigValues } from 'src/js/types/config'; import * as API from 'js/core/api'; import store from 'js/core/store'; @@ -53,7 +54,10 @@ export default (): m.Component => { error('You cannot duplicate a generator with the same slug as the original.'); return; } - API.exec(API.SAVE_GENERATOR, state) + API.exec(API.SAVE_GENERATOR, { + ...state, + config: filterOutDynamicConfigValues(state.config), + }) .then(() => { if (!state) return; diff --git a/frontend/src/js/ui/views/generator/edit.ts b/frontend/src/js/ui/views/generator/edit.ts index d604f518..895b412e 100644 --- a/frontend/src/js/ui/views/generator/edit.ts +++ b/frontend/src/js/ui/views/generator/edit.ts @@ -1,6 +1,7 @@ import m from 'mithril'; import { buildId } from 'js/types/basic-info'; +import { filterOutDynamicConfigValues } from 'js/types/config'; import Generator from 'js/types/generator'; import * as API from 'js/core/api'; @@ -55,7 +56,10 @@ export default (): m.Component => { intend: 'success', onClick: () => { if (!state) return; - API.exec(API.SAVE_GENERATOR, state) + API.exec(API.SAVE_GENERATOR, { + ...state, + config: filterOutDynamicConfigValues(state.config), + }) .then(() => { if (!state) return; m.route.set(`/generator/${buildId('generator', state)}`); diff --git a/frontend/src/js/ui/views/generator/single.ts b/frontend/src/js/ui/views/generator/single.ts index e09b5b1e..11b7e819 100644 --- a/frontend/src/js/ui/views/generator/single.ts +++ b/frontend/src/js/ui/views/generator/single.ts @@ -1,12 +1,10 @@ import m from 'mithril'; import { cloneDeep, map } from 'lodash-es'; -import HorizontalProperty from '../../components/horizontal-property'; -import { openPromptModal } from '../../components/modals/prompt'; - import { buildId } from 'js/types/basic-info'; import Generator, { sanitizeConfig, seed } from 'js/types/generator'; import * as API from 'js/core/api'; +import { createOnMessage } from 'js/core/generator-ipc'; import store from 'js/core/store'; import Checkbox from 'js/ui/shoelace/checkbox'; @@ -16,10 +14,12 @@ import Loader from 'js/ui/shoelace/loader'; import Tooltip from 'js/ui/components/atomic/tooltip'; import Editor from 'js/ui/components/config/editor'; +import HorizontalProperty from 'js/ui/components/horizontal-property'; import Flex from 'js/ui/components/layout/flex'; import { openAdditionalInfosModal } from 'js/ui/components/modals/additional-infos'; import { openFileModal } from 'js/ui/components/modals/file-browser'; import ImportExport from 'js/ui/components/modals/imexport/import-export'; +import { openPromptModal } from 'js/ui/components/modals/prompt'; import { openDevTools } from 'js/ui/components/print-preview'; import Base from 'js/ui/components/view-layout/base'; import Breadcrumbs from 'js/ui/components/view-layout/breadcrumbs'; @@ -227,6 +227,7 @@ export default (): m.Component => { generator: state.generator, config: state.config, onRendered: (html) => (state.lastRendered = html), + onMessage: createOnMessage(state.generator, state), hidePreview: state.hidePreview, tabs: [ { icon: 'options', label: 'Config' }, diff --git a/frontend/src/js/workers/templating-worker.js b/frontend/src/js/workers/templating-worker.js index 1e9e1e81..ac78d04b 100644 --- a/frontend/src/js/workers/templating-worker.js +++ b/frontend/src/js/workers/templating-worker.js @@ -151,7 +151,7 @@ env.addFilter('shuffle', function (array) { // While there remain elements to shuffle... while (0 !== currentIndex) { // Pick a remaining element... - randomIndex = Math.floor((this.ctx.__rand ?? math.Random)() * currentIndex); + randomIndex = Math.floor((this.ctx.__rand ?? Math.random)() * currentIndex); currentIndex -= 1; // And swap it with the current element. diff --git a/server/server.go b/server/server.go index d1a0e59d..9d453cdf 100644 --- a/server/server.go +++ b/server/server.go @@ -8,6 +8,7 @@ import ( "mime" "net/http" "net/url" + "path/filepath" "strings" "sync" "time" @@ -50,6 +51,7 @@ type Server struct { sync.RWMutex debug bool db database.Database + dataDir string e *echo.Echo m *melody.Melody cache *cache.Cache @@ -91,6 +93,14 @@ func WithPrinter(printer printing.Printer) Option { } } +// WithDataDir sets the data directory of the Server. +func WithDataDir(dir string) Option { + return func(s *Server) error { + s.dataDir = dir + return nil + } +} + // Close closes the server and all its connections. func (s *Server) Close() error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) @@ -130,6 +140,14 @@ func (s *Server) Start(bindAddr string) error { api := s.e.Group("/api") extern := api.Group("/extern") + api.GET("/dataDir", func(c echo.Context) error { + absDir, err := filepath.Abs(s.dataDir) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, absDir) + }) + rpc.RegisterVersion(api) rpc.RegisterKeyValue(api, s.db) rpc.RegisterImageUtilities(api)