diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8ae06284718c98c9c53ecea412f23741ad364fb7..7c19c982e044666bea121a2c42b4ade0c1b528b6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -523,6 +523,9 @@ themeEditor: "テーマエディター" description: "説明" author: "作者" leaveConfirm: "未ä¿å˜ã®å¤‰æ›´ãŒã‚ã‚Šã¾ã™ã€‚ç ´æ£„ã—ã¾ã™ã‹ï¼Ÿ" +manage: "管ç†" +plugins: "プラグイン" +pluginInstallWarn: "ä¿¡é ¼ã§ããªã„プラグインã¯ã‚¤ãƒ³ã‚¹ãƒˆãƒ¼ãƒ«ã—ãªã„ã§ãã ã•ã„。" deck: "デッã‚" undeck: "デッã‚解除" diff --git a/package.json b/package.json index ad55e2e035ba20a6dbd90980977bdb75b7b5bd95..4a81ed963617e279eadacc740035ce33c1d6b892 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@koa/multer": "3.0.0", "@koa/router": "9.3.1", "@sinonjs/fake-timers": "6.0.1", - "@syuilo/aiscript": "0.7.0", + "@syuilo/aiscript": "0.7.2", "@types/bcryptjs": "2.4.2", "@types/bull": "3.14.0", "@types/cbor": "5.0.0", diff --git a/src/client/components/note.vue b/src/client/components/note.vue index badb9f12f39adf4db79e6a95a8b8c1d7453939d9..63a803c7f42bb97e741a6361520ea47357a77318 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -89,7 +89,7 @@ <script lang="ts"> import Vue from 'vue'; -import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { parse } from '../../mfm/parse'; import { sum, unique } from '../../prelude/array'; @@ -108,7 +108,6 @@ import { url } from '../config'; import copyToClipboard from '../scripts/copy-to-clipboard'; export default Vue.extend({ - components: { XSub, XNoteHeader, @@ -145,7 +144,7 @@ export default Vue.extend({ showContent: false, hideThisNote: false, noteBody: this.$refs.noteBody, - faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faEllipsisH + faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug }; }, @@ -612,6 +611,16 @@ export default Vue.extend({ .filter(x => x !== undefined); } + if (this.$store.state.noteActions.length > 0) { + menu = menu.concat([null, ...this.$store.state.noteActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + this.$root.menu({ items: menu, source: this.$refs.menuButton, diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index 392dd8c48bb95147f44f2c6d4cce9ba8ec1f78cc..f0de602c293df6f008c665d5f8dff410ff6bd3f8 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -44,6 +44,7 @@ <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button> <button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button> <button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button> + <button class="_button" @click="showActions" v-tooltip="$t('plugin')" v-if="$store.state.postFormActions.length > 0"><fa :icon="faPlug"/></button> </footer> <input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/> </div> @@ -52,7 +53,7 @@ <script lang="ts"> import Vue from 'vue'; -import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard } from '@fortawesome/free-solid-svg-icons'; +import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; import insertTextAtCursor from 'insert-text-at-cursor'; import { length } from 'stringz'; @@ -133,7 +134,7 @@ export default Vue.extend({ draghover: false, quoteId: null, recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), - faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard + faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug }; }, @@ -580,6 +581,22 @@ export default Vue.extend({ vm.close(); }); }, + + showActions(ev) { + this.$root.menu({ + items: this.$store.state.postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), + source: ev.currentTarget || ev.target, + }); + } } }); </script> diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue index 25937fb3c04427eedb806239f30cf5e89adc7fc6..cbfa7b346d8c832339cd8f877de3648c1adadc74 100644 --- a/src/client/components/user-menu.vue +++ b/src/client/components/user-menu.vue @@ -4,7 +4,7 @@ <script lang="ts"> import Vue from 'vue'; -import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons'; import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import XMenu from './menu.vue'; import copyToClipboard from '../scripts/copy-to-clipboard'; @@ -80,6 +80,16 @@ export default Vue.extend({ }]); } + if (this.$store.state.userActions.length > 0) { + menu = menu.concat([null, ...this.$store.state.userActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(this.user); + } + }))]); + } + return { items: menu }; diff --git a/src/client/init.ts b/src/client/init.ts index d00b4f5ccaad6a76ba0c881ee6e13388c93144da..7e11efe37ce607da65a214afd0dd47cf91d739f4 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -25,6 +25,8 @@ import { isDeviceDarkmode } from './scripts/is-device-darkmode'; import createStore from './store'; import { clientDb, get, count } from './db'; import { setI18nContexts } from './scripts/set-i18n-contexts'; +import { createPluginEnv } from './scripts/aiscript/api'; +import { AiScript } from '@syuilo/aiscript'; Vue.use(Vuex); Vue.use(VueHotkey); @@ -231,6 +233,35 @@ os.init(async () => { //store.commit('instance/set', ); }); + for (const plugin of store.state.deviceUser.plugins) { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + + const aiscript = new AiScript(createPluginEnv(app, { + plugin: plugin, + storageKey: 'plugins:' + plugin.id + }), { + in: (q) => { + return new Promise(ok => { + app.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + }); + + store.commit('initPlugin', { plugin, aiscript }); + + aiscript.exec(plugin.ast); + } + if (store.getters.isSignedIn) { const main = os.stream.useSharedConnection('main'); diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue index 2b34513865b2f346edca2a41dc3ea055c3af764a..56c4a5699e198a2a38d2e672a082944431860007 100644 --- a/src/client/pages/preferences/index.vue +++ b/src/client/pages/preferences/index.vue @@ -9,6 +9,8 @@ <x-sidebar/> + <x-plugins/> + <section class="_card"> <div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div> <div class="_content"> @@ -115,6 +117,7 @@ import MkRadio from '../../components/ui/radio.vue'; import MkRange from '../../components/ui/range.vue'; import XTheme from './theme.vue'; import XSidebar from './sidebar.vue'; +import XPlugins from './plugins.vue'; import { langs } from '../../config'; import { clientDb, set } from '../../db'; @@ -146,11 +149,12 @@ export default Vue.extend({ components: { XTheme, XSidebar, + XPlugins, MkButton, MkSwitch, MkSelect, MkRadio, - MkRange + MkRange, }, data() { diff --git a/src/client/pages/preferences/plugins.vue b/src/client/pages/preferences/plugins.vue new file mode 100644 index 0000000000000000000000000000000000000000..afe7c8cafa206e461e2bf638a33e4fbe0a6aedf2 --- /dev/null +++ b/src/client/pages/preferences/plugins.vue @@ -0,0 +1,134 @@ +<template> +<section class="_card"> + <div class="_title"><fa :icon="faPlug"/> {{ $t('plugins') }}</div> + <div class="_content"> + <details> + <summary><fa :icon="faDownload"/> {{ $t('install') }}</summary> + <mk-info warn>{{ $t('pluginInstallWarn') }}</mk-info> + <mk-textarea v-model="script" tall> + <span>{{ $t('script') }}</span> + </mk-textarea> + <mk-button @click="install()" primary><fa :icon="faSave"/> {{ $t('install') }}</mk-button> + </details> + </div> + <div class="_content"> + <details> + <summary><fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary> + <mk-select v-model="selectedPluginId"> + <option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option> + </mk-select> + <template v-if="selectedPlugin"> + <div class="_keyValue"> + <div>{{ $t('version') }}:</div> + <div>{{ selectedPlugin.version }}</div> + </div> + <div class="_keyValue"> + <div>{{ $t('author') }}:</div> + <div>{{ selectedPlugin.author }}</div> + </div> + <div class="_keyValue"> + <div>{{ $t('description') }}:</div> + <div>{{ selectedPlugin.description }}</div> + </div> + <mk-button @click="uninstall()" style="margin-top: 8px;"><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button> + </template> + </details> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkSelect from '../../components/ui/select.vue'; +import MkInfo from '../../components/ui/info.vue'; +import { AiScript, parse } from '@syuilo/aiscript'; + +export default Vue.extend({ + components: { + MkButton, + MkTextarea, + MkSelect, + MkInfo, + }, + + data() { + return { + script: '', + selectedPluginId: null, + faPlug, faSave, faTrashAlt, faFolderOpen, faDownload + } + }, + + computed: { + selectedPlugin() { + if (this.selectedPluginId == null) return null; + return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId); + }, + }, + + methods: { + install() { + let ast; + try { + ast = parse(this.script); + } catch (e) { + this.$root.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + const meta = AiScript.collectMetadata(ast); + console.log(meta); + if (meta == null) { + this.$root.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const data = meta.get(null); + if (data == null) { + this.$root.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const { id, name, version, author, description } = data; + if (id == null || name == null || version == null || author == null) { + this.$root.dialog({ + type: 'error', + text: 'Required property not found :(' + }); + return; + } + this.$store.commit('deviceUser/installPlugin', { + meta: { + id, name, version, author, description + }, + ast + }); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + uninstall() { + this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue index 81d4e6045993a1e33c44220988141246185b4d82..025505295bb18759a771257aa51e7e970ecbd0ab 100644 --- a/src/client/pages/scratchpad.vue +++ b/src/client/pages/scratchpad.vue @@ -30,7 +30,7 @@ import PrismEditor from 'vue-prism-editor'; import { AiScript, parse, utils, values } from '@syuilo/aiscript'; import MkContainer from '../components/ui/container.vue'; import MkButton from '../components/ui/button.vue'; -import { createAiScriptEnv } from '../scripts/create-aiscript-env'; +import { createAiScriptEnv } from '../scripts/aiscript/api'; export default Vue.extend({ metaInfo() { diff --git a/src/client/scripts/create-aiscript-env.ts b/src/client/scripts/aiscript/api.ts similarity index 71% rename from src/client/scripts/create-aiscript-env.ts rename to src/client/scripts/aiscript/api.ts index dfa38be385593ddde59eaf8647c8419e79208e79..29baa25b1a43469672c5d654eebf7ec3dc278290 100644 --- a/src/client/scripts/create-aiscript-env.ts +++ b/src/client/scripts/aiscript/api.ts @@ -40,3 +40,18 @@ export function createAiScriptEnv(vm, opts) { }), }; } + +export function createPluginEnv(vm, opts) { + return { + ...createAiScriptEnv(vm, opts), + 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); + }), + }; +} diff --git a/src/client/scripts/hpml/evaluator.ts b/src/client/scripts/hpml/evaluator.ts index f1fcdde0e5adcc09b79c4aa5480469260abe57b9..a056884368bb5adfa53cea289063246d28d85a59 100644 --- a/src/client/scripts/hpml/evaluator.ts +++ b/src/client/scripts/hpml/evaluator.ts @@ -3,7 +3,7 @@ import * as seedrandom from 'seedrandom'; import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.'; import { version } from '../../config'; import { AiScript, utils, values } from '@syuilo/aiscript'; -import { createAiScriptEnv } from '../create-aiscript-env'; +import { createAiScriptEnv } from '../aiscript/api'; import { collectPageVars } from '../collect-page-vars'; import { initLib } from './lib'; diff --git a/src/client/store.ts b/src/client/store.ts index eaa8ea6a69349da2667a98df81373b9f9ce00b62..31febc782b8148728a70cd675d3da2c18836f11d 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -3,6 +3,7 @@ import createPersistedState from 'vuex-persistedstate'; import * as nestedProperty from 'nested-property'; import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; +import { AiScript, utils, values } from '@syuilo/aiscript'; import { apiUrl, deckmode } from './config'; import { erase } from '../prelude/array'; @@ -43,6 +44,7 @@ export const defaultDeviceUserSettings = { columns: [], layout: [], }, + plugins: [], }; export const defaultDeviceSettings = { @@ -93,7 +95,13 @@ export default () => new Vuex.Store({ state: { i: null, pendingApiRequestsCount: 0, - spinner: null + spinner: null, + + // Plugin + pluginContexts: new Map<string, AiScript>(), + postFormActions: [], + userActions: [], + noteActions: [], }, getters: { @@ -224,8 +232,38 @@ export default () => new Vuex.Store({ state.i = x; }, - updateIKeyValue(state, x) { - state.i[x.key] = x.value; + updateIKeyValue(state, { key, value }) { + state.i[key] = value; + }, + + initPlugin(state, { plugin, aiscript }) { + state.pluginContexts.set(plugin.id, aiscript); + }, + + registerPostFormAction(state, { pluginId, title, handler }) { + state.postFormActions.push({ + title, handler: (form, update) => { + state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + update(key.value, value.value); + })]); + } + }); + }, + + registerUserAction(state, { pluginId, title, handler }) { + state.userActions.push({ + title, handler: (user) => { + state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + } + }); + }, + + registerNoteAction(state, { pluginId, title, handler }) { + state.noteActions.push({ + title, handler: (note) => { + state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + } + }); }, }, @@ -546,6 +584,21 @@ export default () => new Vuex.Store({ column = x; }, //#endregion + + installPlugin(state, { meta, ast }) { + state.plugins.push({ + id: meta.id, + name: meta.name, + version: meta.version, + author: meta.author, + description: meta.description, + ast: ast + }); + }, + + uninstallPlugin(state, id) { + state.plugins = state.plugins.filter(x => x.id != id); + }, } }, diff --git a/yarn.lock b/yarn.lock index 2fd02e8dbccc50e42d97bad53f2df103ef386035..ad6cb9ca37a58446b035bf309f4450d1151b60fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -197,10 +197,10 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@syuilo/aiscript@0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.0.tgz#1394511a789891e844d32e536a203fe0d92b3039" - integrity sha512-X4TaP/FO7RD8MpFSPDFwKAI4KX7byn8ApqmSSmf2bxcwCTcdevsbyxsLrvkbNaWclIoqTgXwtJjY+2Tc2exeXA== +"@syuilo/aiscript@0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.2.tgz#2f30adb14ffa9f1180af83c059927ab306b175a5" + integrity sha512-l8HVTJTq9KLzDqGswOIGlBepkacudUp70EScrLjL7nEL2NKcti7Ui5fwZCrmxazxgGz6NrVNX5UBIOFFyrwr0A== dependencies: autobind-decorator "2.4.0" chalk "4.0.0"