├── scripts ├── action │ ├── editor │ │ ├── Replace │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── Paste │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ └── PreviewMarkdown │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ ├── clipboard │ │ ├── OpenUrl │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── Tokenize │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── CleanClipboard │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── Translate │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── B23Clean │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── DownloadFromUrl │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── RawRepoConverter │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── GetFromWin │ │ │ ├── config.json │ │ │ ├── README.md │ │ │ └── main.js │ │ └── SendToWin │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ ├── uncategorized │ │ ├── DeleteClips │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── DisplayClipboard │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ ├── ExportAllContent │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ │ └── ActionEditPreview │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ └── main.js │ ├── template.js │ ├── README.md │ ├── secure.js │ └── action.js ├── ui │ ├── action │ │ ├── action-config.js │ │ └── editor.js │ ├── components │ │ ├── today-actions.js │ │ ├── action-scripts.js │ │ ├── selectActions.js │ │ ├── keyboard-scripts.js │ │ └── editor.js │ └── clips │ │ ├── search.js │ │ └── views.js ├── widget │ ├── Clips.js │ ├── Favorite.js │ ├── Actions.js │ └── list-widget.js ├── setting │ ├── general │ │ ├── general.js │ │ ├── today.js │ │ ├── widget.js │ │ ├── editor.js │ │ ├── action.js │ │ ├── clip.js │ │ └── keyboard.js │ ├── experimental │ │ ├── experimental.js │ │ ├── taio.js │ │ └── webdav.js │ └── setting.js ├── libs │ └── aes.js ├── widget.js ├── dao │ ├── webdav-sync-action.js │ ├── webdav-sync.js │ └── webdav-sync-clip.js ├── app-lite.js ├── app.js ├── app-main.js └── compatibility.js ├── assets ├── icon.png └── icon.white.png ├── .gitignore ├── caio.code-workspace ├── package.json ├── widget-options.json ├── config.json ├── template.json ├── main.js ├── LICENSE ├── README_CN.md ├── README.md └── strings ├── zh-Hans.strings └── en.strings /scripts/action/editor/Replace/README.md: -------------------------------------------------------------------------------- 1 | ## Replace 2 | 3 | 查找替换 -------------------------------------------------------------------------------- /scripts/action/editor/Paste/README.md: -------------------------------------------------------------------------------- 1 | ## Paste 2 | 3 | 向输入框输入剪切板内的内容 -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipuppet/CAIO/HEAD/assets/icon.png -------------------------------------------------------------------------------- /scripts/action/clipboard/OpenUrl/README.md: -------------------------------------------------------------------------------- 1 | ## OpenUrl 2 | 3 | 提取文本中的URL并打开。 -------------------------------------------------------------------------------- /scripts/action/clipboard/Tokenize/README.md: -------------------------------------------------------------------------------- 1 | ## Tokenize 2 | 3 | 将文本分词处理后复制。 -------------------------------------------------------------------------------- /scripts/action/clipboard/CleanClipboard/README.md: -------------------------------------------------------------------------------- 1 | ## CleanClipboard 2 | 3 | 清空当前剪切板内容。 -------------------------------------------------------------------------------- /scripts/action/clipboard/Translate/README.md: -------------------------------------------------------------------------------- 1 | # Translate 2 | 3 | 使用谷歌翻译API翻译文本。 4 | -------------------------------------------------------------------------------- /scripts/action/editor/PreviewMarkdown/README.md: -------------------------------------------------------------------------------- 1 | ## PreviewMarkdown 2 | 3 | 预览Markdown。 -------------------------------------------------------------------------------- /assets/icon.white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipuppet/CAIO/HEAD/assets/icon.white.png -------------------------------------------------------------------------------- /scripts/action/uncategorized/DeleteClips/README.md: -------------------------------------------------------------------------------- 1 | ## DeleteClips 2 | 3 | 删除所有已保存的剪切板(不包括收藏)。 -------------------------------------------------------------------------------- /scripts/action/clipboard/B23Clean/README.md: -------------------------------------------------------------------------------- 1 | ## B23Clean 2 | 3 | 清除 b23.tv 分享链接中的追踪参数,转换为 BV 视频链接。 -------------------------------------------------------------------------------- /scripts/action/clipboard/DownloadFromUrl/README.md: -------------------------------------------------------------------------------- 1 | ## DownloadFromUrl 2 | 3 | 从链接下载内容,如 js 文件内容等。 -------------------------------------------------------------------------------- /scripts/action/uncategorized/DisplayClipboard/README.md: -------------------------------------------------------------------------------- 1 | ## DisplayClipboard 2 | 3 | 显示当前剪切板内的内容。 -------------------------------------------------------------------------------- /scripts/action/uncategorized/ExportAllContent/README.md: -------------------------------------------------------------------------------- 1 | ## ExportAllContent 2 | 3 | 导出所有保存的数据。 -------------------------------------------------------------------------------- /scripts/action/uncategorized/ActionEditPreview/README.md: -------------------------------------------------------------------------------- 1 | ## ActionEditPreview 2 | 3 | 编辑动作即时预览示例。 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .output 3 | .idea 4 | .vscode 5 | node_modules 6 | .parcel-cache 7 | test.js -------------------------------------------------------------------------------- /caio.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "scripts": { 4 | "build": "node build.js" 5 | } 6 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/RawRepoConverter/README.md: -------------------------------------------------------------------------------- 1 | ## RawRepoConverter 2 | 3 | Github Raw 链接和 Repository 链接转换器 4 | -------------------------------------------------------------------------------- /scripts/action/clipboard/OpenUrl/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "link", 3 | "color": "#FF0099", 4 | "name": "打开链接" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/B23Clean/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "trash", 3 | "color": "#FF0000", 4 | "name": "b23 清除追踪" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/CleanClipboard/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "trash", 3 | "color": "#FF0000", 4 | "name": "清除剪切板" 5 | } -------------------------------------------------------------------------------- /scripts/action/editor/Paste/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "doc.on.clipboard", 3 | "color": "#FF0000", 4 | "name": "Paste" 5 | } -------------------------------------------------------------------------------- /scripts/action/editor/PreviewMarkdown/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "book", 3 | "color": "#9900CC", 4 | "name": "预览Markdown" 5 | } -------------------------------------------------------------------------------- /scripts/action/editor/Replace/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square.and.arrow.up", 3 | "color": "#FF3300", 4 | "name": "查找替换" 5 | } -------------------------------------------------------------------------------- /scripts/action/uncategorized/DeleteClips/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "trash", 3 | "color": "#FF0000", 4 | "name": "删除所有剪切板" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/Translate/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "globe", 3 | "color": "#007AFF", 4 | "name": "Translate" 5 | } 6 | -------------------------------------------------------------------------------- /scripts/action/uncategorized/DisplayClipboard/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "option", 3 | "color": "#FF6633", 4 | "name": "显示剪切板" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/DownloadFromUrl/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square.and.arrow.down", 3 | "color": "#FF0099", 4 | "name": "从链接下载" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/GetFromWin/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square.and.arrow.down", 3 | "color": "#33CC33", 4 | "name": "读取 clipsync" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/RawRepoConverter/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "link", 3 | "color": "#FF0099", 4 | "name": "RawRepoConverter" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/SendToWin/README.md: -------------------------------------------------------------------------------- 1 | ## SendToWin 2 | 3 | 通 clipsync 推送剪切板至 PC。 4 | 5 | 服务端:[clipsync](https://github.com/ipuppet/clipsync) 6 | -------------------------------------------------------------------------------- /scripts/action/clipboard/SendToWin/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square.and.arrow.up", 3 | "color": "#33CC33", 4 | "name": "推送 clipsync" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/Tokenize/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "pencil.and.ellipsis.rectangle", 3 | "color": "#0099FF", 4 | "name": "分词复制" 5 | } -------------------------------------------------------------------------------- /scripts/action/uncategorized/ActionEditPreview/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "play", 3 | "color": "#FF0000", 4 | "name": "编辑动作即时预览示例" 5 | } -------------------------------------------------------------------------------- /scripts/action/clipboard/GetFromWin/README.md: -------------------------------------------------------------------------------- 1 | ## GetFromWin 2 | 3 | 通 clipsync 读取 PC 剪切板。 4 | 5 | 服务端:[clipsync](https://github.com/ipuppet/clipsync) 6 | -------------------------------------------------------------------------------- /scripts/action/uncategorized/ExportAllContent/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square.and.arrow.up", 3 | "color": "#FF3300", 4 | "name": "导出数据" 5 | } -------------------------------------------------------------------------------- /widget-options.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Clips", 4 | "value": "Clips" 5 | }, 6 | { 7 | "name": "Favorite", 8 | "value": "Favorite" 9 | }, 10 | { 11 | "name": "Actions", 12 | "value": "Actions" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /scripts/action/editor/Paste/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | do() { 6 | const text = $clipboard.text 7 | if (!text || text === "") return 8 | $keyboard.insert(text) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/action/editor/PreviewMarkdown/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | do() { 3 | this.pageSheet({ 4 | view: { 5 | type: "markdown", 6 | props: { content: this.text }, 7 | layout: $layout.fill 8 | } 9 | }) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /scripts/ui/action/action-config.js: -------------------------------------------------------------------------------- 1 | // TODO ActionConfig 2 | class ActionConfig { 3 | constructor() {} 4 | 5 | getView() { 6 | return { 7 | type: "view", 8 | props: {}, 9 | views: [], 10 | layout: $layout.fill 11 | } 12 | } 13 | } 14 | 15 | module.exports = ActionConfig 16 | -------------------------------------------------------------------------------- /scripts/action/uncategorized/ExportAllContent/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | do() { 3 | const data = this.getAllClips() 4 | if (data.clips.length > 0 || data.favorite.length > 0) { 5 | $share.sheet(JSON.stringify(data, null, 2)) 6 | } else { 7 | $ui.alert("无数据") 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/action/template.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | 5 | class MyAction extends Action { 6 | /** 7 | * 系统会调用 do() 方法 8 | * 9 | * 可用数据如下: 10 | * this.config 配置文件内容 11 | * this.text 当前复制的文本或剪切板页面选中的文本亦或者编辑器内的文本 12 | * this.uuid 该文本的 uuid 13 | */ 14 | do() { 15 | console.log(this.text) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /scripts/widget/Clips.js: -------------------------------------------------------------------------------- 1 | const ListWidget = require("./list-widget.js") 2 | 3 | class ClipsWidget extends ListWidget { 4 | constructor({ setting, storage } = {}) { 5 | super({ 6 | setting, 7 | storage, 8 | source: "clips", 9 | label: $l10n("CLIPS") 10 | }) 11 | } 12 | } 13 | 14 | module.exports = { Widget: ClipsWidget } 15 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "CAIO", 4 | "version": "2.4.6.1", 5 | "author": "ipuppet", 6 | "module": false 7 | }, 8 | "settings": { 9 | "theme": "auto", 10 | "minSDKVer": "2.19.0", 11 | "minOSVer": "14.0.0", 12 | "idleTimerDisabled": false, 13 | "keyboardToolbarEnabled": true, 14 | "rotateDisabled": false 15 | } 16 | } -------------------------------------------------------------------------------- /scripts/widget/Favorite.js: -------------------------------------------------------------------------------- 1 | const ListWidget = require("./list-widget.js") 2 | 3 | class FavoritesWidget extends ListWidget { 4 | constructor({ setting, storage } = {}) { 5 | super({ 6 | setting, 7 | storage, 8 | source: "favorite", 9 | label: $l10n("FAVORITE") 10 | }) 11 | } 12 | } 13 | 14 | module.exports = { Widget: FavoritesWidget } 15 | -------------------------------------------------------------------------------- /scripts/action/uncategorized/DeleteClips/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | 5 | class MyAction extends Action { 6 | async do() { 7 | try { 8 | const action = await this.clearAllClips() 9 | if (action) { 10 | $ui.success($l10n("DONE")) 11 | } 12 | } catch (error) { 13 | $ui.error(error) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /scripts/setting/general/general.js: -------------------------------------------------------------------------------- 1 | const { UIKit } = require("../../libs/easy-jsbox") 2 | 3 | const clip = require("./clip") 4 | const action = require("./action") 5 | const editor = require("./editor") 6 | const keyboard = require("./keyboard") 7 | const widget = require("./widget") 8 | const today = require("./today") 9 | 10 | module.exports = { 11 | items: [clip, action, editor].concat(UIKit.isTaio ? [] : [keyboard, widget, today]) 12 | } 13 | -------------------------------------------------------------------------------- /scripts/setting/experimental/experimental.js: -------------------------------------------------------------------------------- 1 | const { UIKit, SettingChild } = require("../../libs/easy-jsbox") 2 | const webdav = require("./webdav") 3 | 4 | const children = [{ items: [webdav] }] 5 | if (!UIKit.isTaio) { 6 | const taio = require("./taio") 7 | children.push({ items: [taio] }) 8 | } 9 | 10 | module.exports = { 11 | items: [ 12 | new SettingChild({ 13 | icon: "wrench.and.screwdriver", 14 | title: "EXPERIMENTAL" 15 | }).with({ children }) 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scripts/setting/experimental/taio.js: -------------------------------------------------------------------------------- 1 | const { SettingScript } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingScript({ 4 | icon: ["square.and.arrow.up", "#FF9900"], 5 | title: "ADD_TO_TAIO" 6 | }).with({ 7 | script: () => { 8 | $ui.alert({ 9 | title: $l10n("ADD_TO_TAIO"), 10 | message: $l10n("SELECT_TAIO_APP") 11 | }) 12 | $share.sheet([ 13 | { 14 | name: `CAIO.json`, 15 | data: $file.read(`dist/CAIO.json`) 16 | } 17 | ]) 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /scripts/action/clipboard/CleanClipboard/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | l10n() { 6 | return { 7 | "zh-Hans": { 8 | "clipboard.clean.success": "剪切板已清空" 9 | }, 10 | en: { 11 | "clipboard.clean.success": "Clipboard is cleaned" 12 | } 13 | } 14 | } 15 | 16 | /** 17 | * 系统会调用 do() 方法 18 | */ 19 | do() { 20 | $clipboard.clear() 21 | $ui.success($l10n("clipboard.clean.success")) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/action/uncategorized/ActionEditPreview/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | * @typedef {import("../../action").ActionData} ActionData 4 | * @typedef {import("../../action").ActionEnv} ActionEnv 5 | */ 6 | 7 | class MyAction extends Action { 8 | preview() { 9 | return new ActionData({ 10 | text: "hello word" 11 | }) 12 | } 13 | 14 | async do() { 15 | try { 16 | if (this.env !== ActionEnv.build) { 17 | $ui.toast("action editor only") 18 | return 19 | } 20 | return this.text 21 | } catch (error) { 22 | $ui.alert(error) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /template.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [ 3 | { 4 | "type": "@comment", 5 | "parameters": { 6 | "text": { 7 | "value": "GitHub: https://github.com/ipuppet/CAIO" 8 | } 9 | } 10 | }, 11 | { 12 | "type": "@flow.javascript", 13 | "parameters": { 14 | "script": { 15 | "value": "" 16 | } 17 | } 18 | } 19 | ], 20 | "buildVersion": 1, 21 | "clientMinVersion": 1, 22 | "clientVersion": 592, 23 | "icon": { 24 | "glyph": "heart.text.square", 25 | "color": "#FF6633" 26 | }, 27 | "summary": "A Clipboard tool based on JSBox." 28 | } -------------------------------------------------------------------------------- /scripts/action/uncategorized/DisplayClipboard/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | 5 | class MyAction extends Action { 6 | do() { 7 | const image = $clipboard.image 8 | if (image) { 9 | this.quickLookImage(image) 10 | } else { 11 | this.pageSheet({ 12 | view: { 13 | type: "text", 14 | props: { 15 | editable: false, 16 | text: $clipboard.text, 17 | insets: $insets(10, 10, 10, 10) 18 | }, 19 | layout: $layout.fill 20 | } 21 | }) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scripts/setting/general/today.js: -------------------------------------------------------------------------------- 1 | const { SettingScript, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["filemenu.and.selection", "#ebcc34"], 5 | title: "TODAY_WIDGET" 6 | }).with({ 7 | children: [ 8 | { 9 | items: [ 10 | new SettingScript({ 11 | icon: "rectangle.3.offgrid.fill", 12 | title: "PREVIEW" 13 | }).with({ script: "this.method.previewTodayWidget" }) 14 | ] 15 | }, 16 | { 17 | items: [ 18 | new SettingScript({ 19 | icon: "bolt.circle", 20 | title: "ACTIONS" 21 | }).with({ script: "this.method.setTodayWidgetActions" }) 22 | ] 23 | } 24 | ] 25 | }) 26 | -------------------------------------------------------------------------------- /scripts/ui/components/today-actions.js: -------------------------------------------------------------------------------- 1 | const { Sheet } = require("../../libs/easy-jsbox") 2 | const { SelectActions } = require("./selectActions") 3 | 4 | /** 5 | * @typedef {import("../../app-main").AppKernel} AppKernel 6 | */ 7 | 8 | class TodayPinActions extends SelectActions { 9 | static shared = new TodayPinActions() 10 | cacheKey = "today.actions" 11 | 12 | /** 13 | * @param {AppKernel} kernel 14 | */ 15 | constructor(kernel) { 16 | super(kernel) 17 | this.listId = "today-action-list" 18 | } 19 | 20 | sheet() { 21 | const sheet = new Sheet() 22 | sheet.setView(this.getListView()).addNavBar({ 23 | title: $l10n("ACTIONS"), 24 | popButton: { title: $l10n("CLOSE") }, 25 | rightButtons: this.getNavButtons() 26 | }) 27 | 28 | sheet.init().present() 29 | } 30 | } 31 | 32 | module.exports = { TodayPinActions } 33 | -------------------------------------------------------------------------------- /scripts/setting/general/widget.js: -------------------------------------------------------------------------------- 1 | const { SettingScript, SettingMenu, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["rectangle.3.offgrid.fill", "#1899c4"], 5 | title: "WIDGET" 6 | }).with({ 7 | children: [ 8 | { 9 | items: [ 10 | new SettingScript({ 11 | icon: "rectangle.3.offgrid.fill", 12 | title: "PREVIEW" 13 | }).with({ script: "this.method.previewWidget" }) 14 | ] 15 | }, 16 | { 17 | title: "2x2", 18 | items: [ 19 | new SettingMenu({ 20 | icon: "link", 21 | title: "CLICK_ACTION", 22 | key: "widget.2x2.widgetURL", 23 | value: 2 24 | }).with({ pullDown: true, items: ["ADD", "ACTIONS", "CLIPS"] }) 25 | ] 26 | } 27 | ] 28 | }) 29 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | let AppInstance 2 | 3 | switch ($app.env) { 4 | case $env.app: 5 | case $env.action: 6 | AppInstance = require("./scripts/app-main") 7 | break 8 | case $env.today: 9 | case $env.notification: 10 | case $env.keyboard: 11 | case $env.siri: 12 | AppInstance = require("./scripts/app-lite") 13 | break 14 | case $env.widget: 15 | AppInstance = require("./scripts/widget") 16 | break 17 | 18 | default: 19 | $intents.finish("不支持在此环境中运行") 20 | $ui.render({ 21 | views: [ 22 | { 23 | type: "label", 24 | props: { 25 | text: "不支持在此环境中运行", 26 | align: $align.center 27 | }, 28 | layout: $layout.fill 29 | } 30 | ] 31 | }) 32 | break 33 | } 34 | 35 | //AppInstance = require("./scripts/widget") 36 | if (AppInstance) { 37 | AppInstance.run() 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Samuel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/action/clipboard/DownloadFromUrl/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | async downloadContent(url) { 3 | const response = await $http.get({ 4 | url, 5 | showsProgress: true 6 | }) 7 | if (response.error) { 8 | $ui.alert(response.error.localizedDescription) 9 | } else { 10 | return response 11 | } 12 | } 13 | 14 | async do() { 15 | const url = this.getUrls() 16 | let response = undefined 17 | if (url.length > 1) { 18 | $ui.menu({ 19 | items: url, 20 | handler: async (title, index) => { 21 | response = await this.downloadContent(url[index]) 22 | } 23 | }) 24 | } else if (url.length === 1) { 25 | response = await this.downloadContent(url[0]) 26 | } else { 27 | $ui.warning("未检测到链接") 28 | return 29 | } 30 | $share.sheet([ 31 | { 32 | name: response.response.suggestedFilename, 33 | data: response.data 34 | } 35 | ]) 36 | return response 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scripts/action/clipboard/OpenUrl/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | l10n() { 3 | return { 4 | "zh-Hans": { 5 | "openLink.nourl": "未检测到链接" 6 | }, 7 | en: { 8 | "openLink.nourl": "No link detected" 9 | } 10 | } 11 | } 12 | 13 | openUrl(url) { 14 | $app.openURL(url.trim()) 15 | } 16 | 17 | do() { 18 | const url = this.getUrls() 19 | if (this.env === ActionEnv.siri) { 20 | // 快捷指令 Number 无法输入符号,所以使用字符串代替 21 | const idx = Number(this.args) 22 | if (idx === -1) { 23 | // 返回对象方便快捷指令处理 24 | return Object.fromEntries(url.map((v, i) => [i.toString(), v])) 25 | } 26 | this.openUrl(url[idx]) 27 | return url[idx] 28 | } 29 | if (url.length > 1) { 30 | $ui.menu({ 31 | items: url, 32 | handler: (title, index) => { 33 | this.openUrl(url[index]) 34 | } 35 | }) 36 | } else if (url.length === 1) { 37 | this.openUrl(url[0]) 38 | } else { 39 | $ui.warning($l10n("openLink.nourl")) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scripts/libs/aes.js: -------------------------------------------------------------------------------- 1 | // @parcel/packager-js: External modules are not supported when building for browser 2 | const CryptoJS = require("./crypto-js") 3 | 4 | class AES { 5 | key 6 | iv 7 | 8 | constructor(key, iv) { 9 | this.key = CryptoJS.enc.Utf8.parse(key) //16位 10 | this.iv = CryptoJS.enc.Utf8.parse(iv) 11 | } 12 | 13 | encrypt(text) { 14 | const wordArray = CryptoJS.enc.Utf8.parse(text) 15 | let encrypted = CryptoJS.AES.encrypt(wordArray, this.key, { 16 | iv: this.iv, 17 | mode: CryptoJS.mode.CBC, 18 | padding: CryptoJS.pad.Pkcs7 19 | }) 20 | return CryptoJS.enc.Base64.stringify(encrypted.ciphertext) 21 | } 22 | 23 | decrypt(base64String) { 24 | const wordArray = CryptoJS.enc.Base64.parse(base64String) 25 | const cipherParams = CryptoJS.lib.CipherParams.create({ 26 | ciphertext: wordArray 27 | }) 28 | const decrypt = CryptoJS.AES.decrypt(cipherParams, this.key, { 29 | iv: this.iv, 30 | mode: CryptoJS.mode.CBC, 31 | padding: CryptoJS.pad.Pkcs7 32 | }) 33 | 34 | const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8) 35 | return decryptedStr 36 | } 37 | } 38 | 39 | module.exports = AES 40 | -------------------------------------------------------------------------------- /scripts/setting/general/editor.js: -------------------------------------------------------------------------------- 1 | const { SettingSwitch, SettingInput, SettingNumber, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["pencil.circle", "#CC0099"], 5 | title: "EDITOR" 6 | }).with({ 7 | children: [ 8 | { 9 | title: "CLIPS", 10 | items: [ 11 | new SettingNumber({ 12 | icon: ["wand.and.stars", "#FF6633"], 13 | title: "TEXT_INSETS", 14 | key: "editor.text.insets", 15 | value: 300 16 | }) 17 | ] 18 | }, 19 | { 20 | title: "CODE", 21 | items: [ 22 | new SettingSwitch({ 23 | icon: ["list.number", "#6699CC"], 24 | title: "SHOW_LINE_NUMBER", 25 | key: "editor.code.lineNumbers", 26 | value: false 27 | }), 28 | new SettingInput({ 29 | icon: ["wand.and.stars", "#FF6633"], 30 | title: "LIGHT_MODE_THEME", 31 | key: "editor.code.lightTheme", 32 | value: "atom-one-light" 33 | }), 34 | new SettingInput({ 35 | icon: ["wand.and.stars", "#FF6633"], 36 | title: "DARK_MODE_THEME", 37 | key: "editor.code.darkTheme", 38 | value: "atom-one-dark" 39 | }) 40 | ] 41 | } 42 | ] 43 | }) 44 | -------------------------------------------------------------------------------- /scripts/setting/experimental/webdav.js: -------------------------------------------------------------------------------- 1 | const { SettingSwitch, SettingInput, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["cloud", "#FF9900"], 5 | title: "WebDAV" 6 | }).with({ 7 | children: [ 8 | { 9 | items: [ 10 | new SettingSwitch({ 11 | icon: ["cloud", "#FF9900"], 12 | title: "WebDAV", 13 | key: "webdav.status", 14 | value: false 15 | }) 16 | ] 17 | }, 18 | { 19 | items: [ 20 | new SettingInput({ 21 | icon: "link", 22 | title: "HOST", 23 | key: "webdav.host", 24 | value: "" 25 | }), 26 | new SettingInput({ 27 | icon: "person", 28 | title: "USER", 29 | type: "input", 30 | key: "webdav.user", 31 | value: "" 32 | }), 33 | new SettingInput({ 34 | icon: "person.badge.key", 35 | title: "PASSWORD", 36 | type: "input", 37 | key: "webdav.password", 38 | value: "" 39 | }), 40 | new SettingInput({ 41 | icon: "link", 42 | title: "BASEPATH", 43 | type: "input", 44 | key: "webdav.basepath", 45 | value: "" 46 | }) 47 | ] 48 | } 49 | ] 50 | }) 51 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # CAIO 2 | 3 | > Clipboard all in one. 4 | > An iOS clipboard tool based on JSBox. 5 | 6 | [查看我的博客](https://blog.ultagic.com/#/detail/42/) 7 | 8 | 支持桌面小组件和通知中心小组件 9 | 10 | ## 构建 Taio 动作 11 | 12 | 构建脚本依赖 [Parcel](https://parceljs.org/) 13 | 14 | ```shell 15 | npm i -g parcel 16 | npm run build 17 | ``` 18 | 19 | 您也可以直接使用已打包好的文件 [dist/CAIO.json](./dist/CAIO.json) 20 | 21 | ## Actions 22 | 23 | > 编写方式详见 `scripts/action/README.md` 或应用内 `Action` 编辑页面右上角图书按钮。 24 | 25 | ### 不同环境中 `Action` 数据区别 26 | 27 | - 首页顶部 `Action` 按钮处理的数据为当前复制的内容 28 | - 长按列表弹出的 `Action` 菜单处理的数据为被选中的内容 29 | - 编辑器中顶部 `Action` 按钮(闪电图形按钮)处理的数据为正在编辑的所有内容 30 | 31 | ## Today Widget 32 | 33 | > 点击复制,长按触发动作。 34 | 35 | 请尽量避免在 JSBox 运行 CAIO 时使用 Today Widget 36 | 37 | ## WebDAV 38 | 39 | > 通过 WebDAV 同步数据 40 | 41 | 示例配置: 42 | Host: `https://example.com/dav` 43 | User: `guest` 44 | Password: `password123` 45 | Base Path: `/path/to/save` 46 | 47 | ## 快捷指令 48 | 49 | 添加一个名为 `运行 JSBox 脚本` 的动作,并将 `脚本名` 参数设置为 `CAIO`。 50 | 51 | 然后将 `参数词典` 设置为一个 `字典`。 52 | 53 | ### 剪切板相关 54 | 55 | | 参数 | 类型 | 56 | | ------ | ------ | 57 | | set | Text | 58 | | get | Number | 59 | | delete | Number | 60 | | table | Text | 61 | 62 | - `set`:将把内容保存到CAIO中,除非已存在相同内容的项。 63 | - `get`:将返回指定索引(例如:0)处的项。 64 | - `delete`:将删除指定索引(例如:0)处的项,返回该项内容。 65 | - `table`:将指定要设置或获取项的表格,可选项为 `["favorite", "clips"]`。此项可省略,默认值为 `clips`。 66 | 67 | ### 运行动作 68 | 69 | 支持所有 `ActionData` 参数,如 `args`。 70 | 71 | | 参数 | 类型 | 72 | | --------- | ---- | 73 | | runAction | Text | 74 | | text | Text | 75 | 76 | - `runAction`:在 JSBox 动作页面长按动作,复制 URL Schema,填写到此栏。 77 | - `text`:可选参数,传递给动作的内容。快捷指令中运行 JSBox 时脚本无法获取剪切板内容,需要通过此参数传递(使用快捷指令的 `获取剪切板` 动作)。 78 | -------------------------------------------------------------------------------- /scripts/setting/general/action.js: -------------------------------------------------------------------------------- 1 | const { SettingScript, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["bolt.circle", "#FF6633"], 5 | title: "ACTIONS" 6 | }).with({ 7 | children: [ 8 | { 9 | items: [ 10 | new SettingScript({ 11 | icon: ["bolt.circle", "#FF6633"], 12 | title: "IMPORT_EXAMPLE_ACTIONS", 13 | value: "this.method.importExampleAction" 14 | }).with({ 15 | script: "this.method.importExampleAction" 16 | }) 17 | ] 18 | }, 19 | { 20 | items: [ 21 | new SettingScript({ 22 | icon: "square.and.arrow.up", 23 | title: "EXPORT" 24 | }).with({ 25 | script: "this.method.exportAction" 26 | }), 27 | new SettingScript({ 28 | icon: ["square.and.arrow.down", "#FFCC33"], 29 | title: "IMPORT" 30 | }).with({ 31 | script: "this.method.importAction" 32 | }) 33 | ] 34 | }, 35 | { 36 | items: [ 37 | new SettingScript({ 38 | icon: ["square.and.pencil", "blue"], 39 | title: "DEIT_CATEGORY" 40 | }).with({ 41 | script: "this.method.editCategory" 42 | }) 43 | ] 44 | }, 45 | { 46 | items: [ 47 | new SettingScript({ 48 | icon: ["arrow.2.circlepath", "red"], 49 | title: "REBUILD_ACTION_DATABASE" 50 | }).with({ 51 | script: "this.method.rebuildAction" 52 | }) 53 | ] 54 | } 55 | ] 56 | }) 57 | -------------------------------------------------------------------------------- /scripts/action/editor/Replace/main.js: -------------------------------------------------------------------------------- 1 | function HtmlTemplate(html) { 2 | return ` 3 | 4 | 5 | 6 | 7 | 8 | ${html} 9 | 10 | 11 | ` 12 | } 13 | 14 | class MyAction extends Action { 15 | do() { 16 | $ui.menu({ 17 | items: ["忽略大小写", "大小写敏感", "正则表达式"], 18 | handler: async (title, idx) => { 19 | const patternText = await $input.text({ 20 | placeholder: "查找内容" 21 | }) 22 | const replaceString = await $input.text({ 23 | placeholder: "替换内容" 24 | }) 25 | let pattern = undefined 26 | if (idx === 0) { 27 | pattern = new RegExp(`(${patternText})+`, "gi") 28 | } else if (idx === 1) { 29 | pattern = new RegExp(`(${patternText})+`, "g") 30 | } else if (idx === 2) { 31 | pattern = new RegExp(patternText, "g") 32 | } 33 | 34 | const matchResultPreview = this.text.replaceAll(pattern, `${replaceString}`) 35 | const matchResult = this.text.replaceAll(pattern, replaceString) 36 | this.pageSheet({ 37 | title: "替换预览", 38 | doneText: "替换", 39 | view: { 40 | type: "web", 41 | props: { 42 | html: HtmlTemplate(matchResultPreview) 43 | }, 44 | layout: $layout.fill 45 | }, 46 | done: () => { 47 | this.setContent(matchResult) 48 | } 49 | }) 50 | } 51 | }) 52 | // this.setContent("Hello world!") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /scripts/setting/setting.js: -------------------------------------------------------------------------------- 1 | const { UIKit, SettingInfo, SettingScript, SettingTab } = require("../libs/easy-jsbox") 2 | 3 | const generalSection = require("./general/general") 4 | const experimentalSection = require("./experimental/experimental") 5 | 6 | const displaySection = { 7 | items: [ 8 | new SettingScript({ 9 | icon: ["folder.fill", "#FF9900"], 10 | title: "FILE_MANAGEMENT" 11 | }).with({ script: "this.method.fileManager" }) 12 | ].concat( 13 | UIKit.isTaio 14 | ? [] 15 | : [ 16 | new SettingTab({ 17 | icon: ["rectangle.topthird.inset.filled", "#A569BD"], 18 | title: "DISPLAY_MODE", 19 | key: "mainUIDisplayMode", 20 | value: 0 21 | }) 22 | .with({ items: ["CLASSIC", "MODERN"] }) 23 | .onSet(() => $delay(0.3, () => $addin.restart())) 24 | ] 25 | ) 26 | } 27 | 28 | const aboutSection = { 29 | items: [ 30 | new SettingInfo({ 31 | icon: ["icon_177", "black"], 32 | title: "Github", 33 | value: ["ipuppet/CAIO", "https://github.com/ipuppet/CAIO"] 34 | }), 35 | new SettingInfo({ 36 | icon: ["icon_172", "#1888bf"], 37 | title: "Telegram", 38 | value: ["JSBoxTG", "https://t.me/JSBoxTG"] 39 | }), 40 | new SettingInfo({ 41 | icon: ["person.fill", "#FF9900"], 42 | title: "AUTHOR", 43 | value: ["ipuppet", "https://blog.ultagic.com"] 44 | }), 45 | new SettingScript({ 46 | icon: "arrow.2.circlepath", 47 | title: "CHECK_UPDATE" 48 | }).with({ script: "this.method.checkUpdate" }), 49 | new SettingScript({ 50 | icon: ["book.fill", "#A569BD"], 51 | title: "README" 52 | }).with({ script: "this.method.readme" }) 53 | ] 54 | } 55 | 56 | module.exports = [generalSection, displaySection, experimentalSection, aboutSection] 57 | -------------------------------------------------------------------------------- /scripts/action/clipboard/RawRepoConverter/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | #component 6 | 7 | get component() { 8 | if (!this.#component) { 9 | const url = String(this.text) 10 | const path = url.substring(url.indexOf("/", "https://".length) + 1) 11 | this.#component = path.split("/") 12 | } 13 | return this.#component 14 | } 15 | 16 | l10n() { 17 | return { 18 | "zh-Hans": { 19 | "openLink.nourl": "未检测到链接" 20 | }, 21 | en: { 22 | "openLink.nourl": "No link detected" 23 | } 24 | } 25 | } 26 | 27 | githubusercontent() { 28 | const user = this.component[0], 29 | repository = this.component[1], 30 | branch = this.component[2], 31 | file = this.component.slice(3).join("/") 32 | return `https://github.com/${user}/${repository}/blob/${branch}/${file}` 33 | } 34 | 35 | github() { 36 | const user = this.component[0], 37 | repository = this.component[1], 38 | blob = this.component[2], 39 | branch = this.component[3], 40 | file = this.component.slice(4).join("/") 41 | return `https://raw.githubusercontent.com/${user}/${repository}/${branch}/${file}` 42 | } 43 | 44 | do() { 45 | let result 46 | const url = String(this.text) 47 | if (url.includes("raw.githubusercontent.com")) { 48 | result = this.githubusercontent(url) 49 | } else if (url.includes("github.com")) { 50 | if (url.includes("?raw=true")) { 51 | result = url.replace("?raw=true", "") 52 | } else { 53 | result = this.github() 54 | } 55 | } else { 56 | $ui.warning($l10n("openLink.nourl")) 57 | return 58 | } 59 | 60 | $ui.success($l10n("COPIED")) 61 | $clipboard.text = result 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /scripts/action/clipboard/Translate/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | isChinese(text) { 3 | const englishRegex = /[a-zA-Z]/g 4 | const chineseRegex = /[\u4e00-\u9fa5]/g 5 | 6 | const englishMatches = text.match(englishRegex) ?? [] 7 | const chineseMatches = text.match(chineseRegex) ?? [] 8 | 9 | const englishCount = englishMatches.length 10 | const chineseCount = chineseMatches.length 11 | 12 | return chineseCount > englishCount 13 | } 14 | 15 | async translate(text) { 16 | try { 17 | const query = { 18 | client: "gtx", 19 | dt: "t", 20 | sl: "auto", 21 | tl: this.isChinese(text) ? "en" : "zh-CN", 22 | q: text 23 | } 24 | const queryStr = Object.keys(query) 25 | .map(key => `${key}=${$text.URLEncode(query[key])}`) 26 | .join("&") 27 | const resp = await $http.post({ 28 | url: `https://translate.google.com/translate_a/single?${queryStr}`, 29 | header: { 30 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" 31 | } 32 | }) 33 | if (resp.response.statusCode !== 200) { 34 | if (resp.error) throw new Error(resp.error.localizedDescription) 35 | else throw new Error(resp.response.statusCode) 36 | } 37 | const data = resp.data 38 | return data[0][0][0] 39 | } catch (error) { 40 | throw new Error(`Failed to translate: ${error.message}`) 41 | } 42 | } 43 | 44 | async do() { 45 | const text = this.selectedText ?? this.text 46 | const translated = await this.translate(text) 47 | if (!translated) return 48 | if (this.env === ActionEnv.keyboard) { 49 | this.replaceKeyboardText(text, translated) 50 | } else if (this.env === ActionEnv.siri) { 51 | return translated 52 | } else { 53 | this.showTextContent(translated) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/action/clipboard/SendToWin/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | key = $cache.get("caio.action.clipsync.key") 6 | iv = $cache.get("caio.action.clipsync.iv") 7 | address = $cache.get("caio.action.clipsync.address") 8 | 9 | async getInfo(refresh = false) { 10 | if (refresh || !this.address || !this.key || !this.iv) { 11 | const result = await this.input([ 12 | { 13 | key: "key", 14 | value: this.key, 15 | placeholder: "key" 16 | }, 17 | { 18 | key: "iv", 19 | value: this.iv, 20 | placeholder: "iv" 21 | }, 22 | { 23 | key: "address", 24 | value: this.address, 25 | placeholder: "Address" 26 | } 27 | ]) 28 | this.key = result.key 29 | this.iv = result.iv 30 | this.address = result.address 31 | 32 | $cache.set("caio.action.clipsync.key", this.key) 33 | $cache.set("caio.action.clipsync.iv", this.iv) 34 | $cache.set("caio.action.clipsync.address", this.address) 35 | } 36 | 37 | if (!this.address.startsWith("http")) { 38 | this.address = "http://" + this.address 39 | } 40 | } 41 | 42 | async do() { 43 | await this.getInfo() 44 | 45 | $ui.toast("Loading...", 5) 46 | try { 47 | const aes = this.aes(this.key, this.iv) 48 | const data = this.selectedText ?? this.text ?? "" 49 | const resp = await this.request(this.address + "/api/clip", "POST", { 50 | data: aes.encrypt(data) 51 | }) 52 | if (resp.data.status) { 53 | $ui.success("success") 54 | } 55 | } catch (error) { 56 | $ui.clearToast() 57 | $ui.alert({ 58 | title: "Error", 59 | message: String(error), 60 | actions: [ 61 | { title: "OK" }, 62 | { 63 | title: "Reset Adress", 64 | handler: () => this.getInfo(true) 65 | } 66 | ] 67 | }) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /scripts/widget.js: -------------------------------------------------------------------------------- 1 | const { AppKernelBase } = require("./app") 2 | 3 | /** 4 | * @typedef {AppKernel} AppKernel 5 | */ 6 | class AppKernel extends AppKernelBase { 7 | constructor() { 8 | super() 9 | 10 | this.setting.setReadonly() 11 | } 12 | 13 | get logFile() { 14 | return "widget.log" 15 | } 16 | 17 | get isWebdavEnabled() { 18 | // 在小组件中不启用 WebDAV 19 | return false 20 | } 21 | } 22 | 23 | class Widget { 24 | static kernel = new AppKernel() 25 | 26 | static widgetInstance(widget, ...data) { 27 | if ($file.exists(`/scripts/widget/${widget}.js`)) { 28 | try { 29 | const { Widget } = require(`./widget/${widget}.js`) 30 | this.kernel.logger.info(`Loading widget: ${widget}`) 31 | return new Widget(...data) 32 | } catch (error) { 33 | this.kernel.logger.error(`Error loading widget: ${widget}`) 34 | this.kernel.logger.error(error) 35 | } 36 | } else { 37 | this.kernel.logger.error(`Widget not found: ${widget}`) 38 | return false 39 | } 40 | } 41 | 42 | static renderError() { 43 | $widget.setTimeline({ 44 | render: () => ({ 45 | type: "text", 46 | props: { 47 | text: "Invalid argument" 48 | } 49 | }) 50 | }) 51 | } 52 | 53 | static renderClips() { 54 | const widget = Widget.widgetInstance("Clips", Widget.kernel) 55 | widget.render() 56 | } 57 | 58 | static renderFavorite() { 59 | const widget = Widget.widgetInstance("Favorite", Widget.kernel) 60 | widget.render() 61 | } 62 | 63 | static renderActions() { 64 | const widget = Widget.widgetInstance("Actions", Widget.kernel) 65 | widget.render() 66 | } 67 | 68 | static render(widgetName = $widget.inputValue ?? "Actions") { 69 | switch (widgetName) { 70 | case "Clips": 71 | Widget.renderClips() 72 | break 73 | case "Favorite": 74 | Widget.renderFavorite() 75 | break 76 | case "Actions": 77 | Widget.renderActions() 78 | break 79 | default: 80 | Widget.renderError() 81 | } 82 | } 83 | } 84 | 85 | module.exports = { 86 | Widget, 87 | run: () => { 88 | Widget.render() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scripts/action/clipboard/GetFromWin/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | key = $cache.get("caio.action.clipsync.key") 6 | iv = $cache.get("caio.action.clipsync.iv") 7 | address = $cache.get("caio.action.clipsync.address") 8 | 9 | async getInfo(refresh = false) { 10 | if (refresh || !this.address || !this.key || !this.iv) { 11 | const result = await this.input([ 12 | { 13 | key: "key", 14 | value: this.key, 15 | placeholder: "key" 16 | }, 17 | { 18 | key: "iv", 19 | value: this.iv, 20 | placeholder: "iv" 21 | }, 22 | { 23 | key: "address", 24 | value: this.address, 25 | placeholder: "Address" 26 | } 27 | ]) 28 | this.key = result.key 29 | this.iv = result.iv 30 | this.address = result.address 31 | 32 | $cache.set("caio.action.clipsync.key", this.key) 33 | $cache.set("caio.action.clipsync.iv", this.iv) 34 | $cache.set("caio.action.clipsync.address", this.address) 35 | } 36 | 37 | if (!this.address.startsWith("http")) { 38 | this.address = "http://" + this.address 39 | } 40 | } 41 | 42 | async do() { 43 | await this.getInfo() 44 | 45 | $ui.toast("Loading...", 5) 46 | try { 47 | const resp = await this.request(this.address + "/api/clip", "GET") 48 | if (resp.data.status) { 49 | const aes = this.aes(this.key, this.iv) 50 | const data = aes.decrypt(resp.data.data) 51 | $clipboard.text = data 52 | if ($app.env === $env.keyboard) { 53 | $keyboard.insert(data) 54 | } 55 | $ui.success("success") 56 | } 57 | } catch (error) { 58 | $ui.clearToast() 59 | $ui.alert({ 60 | title: "Error", 61 | message: String(error), 62 | actions: [ 63 | { title: "OK" }, 64 | { 65 | title: "Reset", 66 | handler: () => this.getIp(true) 67 | } 68 | ] 69 | }) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CAIO 2 | 3 | > Clipboard all in one. 4 | > An iOS clipboard tool based on JSBox. 5 | 6 | [See My Blog](https://blog.ultagic.com/#/detail/42/) 7 | 8 | Support for home widgets and notification center widgets. 9 | 10 | ## Build Taio Action 11 | 12 | The build script depends on [Parcel](https://parceljs.org/). 13 | 14 | ```shell 15 | npm i -g parcel 16 | npm run build 17 | ``` 18 | 19 | You can also use the packaged files directly [dist/CAIO.json](./dist/CAIO.json). 20 | 21 | ## Actions 22 | 23 | > For details on how to write, see `scripts/action/README.md` or the book button in the upper right corner of the `Action` edit page in the app. 24 | 25 | ### `Action` data differences in different environments 26 | 27 | - The data processed by the `Action` button at the top of the home page is the currently copied content. 28 | - The data processed by the `Action` menu that pops up by long-pressing the list is the selected content. 29 | - The data processed by the `Action` button in the editor is whatever is being edited. 30 | 31 | ## Today Widget 32 | 33 | > Click to copy, long press to trigger the action. 34 | 35 | Please try to avoid using the Today Widget when JSBox is running CAIO. 36 | 37 | ## WebDAV 38 | 39 | > Sync data with WebDAV. 40 | 41 | Config Example: 42 | Host: `https://example.com/dav` 43 | User: `guest` 44 | Password: `password123` 45 | Base Path: `/path/to/save` 46 | 47 | ## Shortcuts 48 | 49 | Please add a new action called `Run JSBox script` and set the `Name` parameter to `CAIO`. 50 | 51 | Next, set the `Parameter Dictionary` to a `Dictionary`. 52 | 53 | ### Clips 54 | 55 | | Parameter | Type | 56 | | --------- | ------ | 57 | | set | Text | 58 | | get | Number | 59 | | delete | Number | 60 | | table | Text | 61 | 62 | - `set`: The content will be saved to CAIO unless there is already an existing item with the same content. 63 | - `get`: The item at the specified index (e.g., 0) will be returned. 64 | - `delete`: The item at the specified index (e.g., 0) will be deleted, returning its content. 65 | - `table`: It will specify the table to either set or get the item from, with options being `["favorite", "clips"]`. This parameter is optional and has a default value of `clips`. 66 | 67 | ### Run Action 68 | 69 | All `ActionData` parameters are supported, e.g. `args`. 70 | 71 | | Parameter | Type | 72 | | --------- | ---- | 73 | | runAction | Text | 74 | | text | Text | 75 | 76 | - `runAction`: Long press the action on the JSBox action page to copy the URL Schema and fill it in here. 77 | - `text`: Optional parameter to pass content to the action. When running JSBox in a shortcut, the script cannot access clipboard content, so you need to pass it through this parameter (use the shortcut's `Get Clipboard` action). 78 | -------------------------------------------------------------------------------- /scripts/setting/general/clip.js: -------------------------------------------------------------------------------- 1 | const { SettingSwitch, SettingScript, SettingNumber, SettingPush, SettingChild } = require("../../libs/easy-jsbox") 2 | 3 | module.exports = new SettingChild({ 4 | icon: ["doc.on.clipboard.fill", "#FFCC66"], 5 | title: "CLIPS" 6 | }).with({ 7 | children: [ 8 | { 9 | items: [ 10 | new SettingSwitch({ 11 | icon: ["link", "#FF6633"], 12 | title: "UNIVERSAL_CLIPBOARD", 13 | key: "clipboard.universal", 14 | value: true 15 | }), 16 | new SettingScript({ 17 | icon: ["cursorarrow.rays", "#FF6633"], 18 | title: "Tips" 19 | }).with({ 20 | script: "$ui.alert({title:$l10n('UNIVERSAL_CLIPBOARD'),message:$l10n('UNIVERSAL_CLIPBOARD_TIPS')})" 21 | }) 22 | ] 23 | }, 24 | { 25 | items: [ 26 | new SettingPush({ 27 | icon: ["trash", "red"], 28 | title: "RECYCLE_BIN", 29 | key: "clipboard.recycleBin" 30 | }).with({ view: "this.method.recycleBin", navButtons: "this.method.recycleBinNavButtons" }) 31 | ] 32 | }, 33 | { 34 | items: [ 35 | new SettingNumber({ 36 | icon: ["text.alignleft", "#FFCC66"], 37 | title: "MAX_ITEM_LENGTH", 38 | key: "clipboard.maxItemLength", 39 | value: 100 40 | }), 41 | new SettingSwitch({ 42 | icon: ["square.and.arrow.down.on.square", "#FF6633"], 43 | title: "AUTO_SAVE", 44 | key: "clipboard.autoSave", 45 | value: true 46 | }) 47 | ] 48 | }, 49 | { 50 | items: [ 51 | new SettingScript({ 52 | icon: "square.and.arrow.up", 53 | title: "EXPORT" 54 | }).with({ script: "this.method.exportClipboard" }), 55 | new SettingScript({ 56 | icon: ["square.and.arrow.down", "#FFCC33"], 57 | title: "IMPORT" 58 | }).with({ script: "this.method.importClipboard" }) 59 | ] 60 | }, 61 | { 62 | items: [ 63 | new SettingScript({ 64 | icon: ["arrow.2.circlepath", "red"], 65 | title: "REBUILD_DATABASE" 66 | }).with({ script: "this.method.rebuildDatabase" }), 67 | new SettingScript({ 68 | icon: ["trash", "red"], 69 | title: "DELETE_ALL_DATA" 70 | }).with({ script: "this.method.deleteAllData" }) 71 | ] 72 | } 73 | ] 74 | }) 75 | -------------------------------------------------------------------------------- /scripts/ui/components/action-scripts.js: -------------------------------------------------------------------------------- 1 | const { Sheet } = require("../../libs/easy-jsbox") 2 | 3 | /** 4 | * @typedef {import("../../app-main").AppKernel} AppKernel 5 | */ 6 | 7 | class ActionScripts { 8 | /** 9 | * @param {AppKernel} kernel 10 | */ 11 | constructor(kernel) { 12 | this.kernel = kernel 13 | this.listId = "action-category-list" 14 | } 15 | 16 | getNavButtons() { 17 | return [ 18 | { 19 | symbol: "plus", 20 | tapped: () => this.kernel.actions.addActionCategory() 21 | } 22 | ] 23 | } 24 | 25 | getActionCategories() { 26 | return this.kernel.actions.getActionCategories() 27 | } 28 | 29 | getListView() { 30 | return { 31 | type: "list", 32 | props: { 33 | id: this.listId, 34 | reorder: true, 35 | data: this.getActionCategories(), 36 | actions: [ 37 | { 38 | title: " " + $l10n("DELETE") + " ", 39 | color: $color("red"), 40 | handler: async (sender, indexPath) => { 41 | const result = await this.kernel.actions.deleteActionCategory(sender.object(indexPath)) 42 | if (result) { 43 | sender.delete(indexPath) 44 | } 45 | } 46 | } 47 | ] 48 | }, 49 | events: { 50 | didSelect: async (sender, indexPath, data) => { 51 | try { 52 | const result = await this.kernel.actions.renameActionCategory(data) 53 | if (result) { 54 | sender.data = this.getActionCategories() 55 | } 56 | } catch (error) { 57 | this.kernel.logger.error(`Failed to rename action category: ${error}`) 58 | } 59 | }, 60 | swipeEnabled: (sender, indexPath) => { 61 | return sender.data.length > 1 // 禁止删除最后一个分类 62 | }, 63 | reorderFinished: data => { 64 | this.kernel.actions.saveActionCategoryOrder(data) 65 | } 66 | }, 67 | layout: $layout.fill 68 | } 69 | } 70 | 71 | static async sheet(kernel) { 72 | const sheet = new Sheet() 73 | const actionScripts = new ActionScripts(kernel) 74 | sheet.setView(actionScripts.getListView()).addNavBar({ 75 | title: $l10n("EDIT_CATEGORY"), 76 | popButton: { title: $l10n("DONE") }, 77 | rightButtons: actionScripts.getNavButtons() 78 | }) 79 | 80 | sheet.init().present() 81 | } 82 | } 83 | 84 | module.exports = ActionScripts 85 | -------------------------------------------------------------------------------- /scripts/action/clipboard/Tokenize/main.js: -------------------------------------------------------------------------------- 1 | class MyAction extends Action { 2 | getView() { 3 | const color = { 4 | background: { 5 | normal: $color("#E7F2FF", "#E7F2FF"), 6 | highlight: $color("##074FF", "#BBDAFF") 7 | }, 8 | text: { 9 | normal: $color("##074FF", "##074FF"), 10 | highlight: $color("#FFFFFF", "#ADADAD") 11 | } 12 | } 13 | const fontSize = 16 14 | const edges = 10 15 | return { 16 | type: "matrix", 17 | layout: $layout.fill, 18 | props: { 19 | spacing: edges, 20 | data: this.results.map(item => ({ label: { text: item } })), 21 | template: { 22 | views: [{ 23 | type: "label", 24 | props: { 25 | id: "label", 26 | align: $align.center, 27 | cornerRadius: edges, 28 | bgcolor: color.background.normal, 29 | font: $font(fontSize), 30 | textColor: color.text.normal 31 | }, 32 | layout: $layout.fill 33 | }] 34 | } 35 | }, 36 | events: { 37 | highlighted: () => { }, 38 | itemSize: (sender, indexPath) => { 39 | const width = fontSize * this.results[indexPath.item].length + 1 40 | if (this.maxtrixItemHeight === undefined) 41 | this.maxtrixItemHeight = fontSize + edges * 2 42 | return $size(width + edges * 2, this.maxtrixItemHeight) 43 | }, 44 | didSelect: (sender, indexPath) => { 45 | const index = this.selected.indexOf(indexPath.item) 46 | const label = sender.cell(indexPath).get("label") 47 | if (index === -1) { 48 | this.selected.push(indexPath.item) 49 | label.bgcolor = color.background.highlight 50 | label.textColor = color.text.highlight 51 | } else { 52 | this.selected.splice(index, 1) 53 | label.bgcolor = color.background.normal 54 | label.textColor = color.text.normal 55 | } 56 | } 57 | } 58 | } 59 | } 60 | /** 61 | * 系统会调用 do() 方法 62 | */ 63 | do() { 64 | this.selected = [] 65 | this.results = [] 66 | $text.tokenize({ 67 | text: this.selectedText ?? this.text, 68 | handler: results => { 69 | this.results = results 70 | this.pageSheet({ 71 | view: this.getView(), 72 | done: () => { 73 | const result = [] 74 | this.selected.sort().forEach(i => { 75 | result.push(this.results[i]) 76 | }) 77 | if (result.length > 0) { 78 | const text = result.join("") 79 | $clipboard.text = text 80 | $ui.alert({ 81 | title: "完成", 82 | message: `已复制内容:${text}` 83 | }) 84 | } 85 | } 86 | }) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scripts/action/clipboard/B23Clean/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../../action").Action} Action 3 | */ 4 | class MyAction extends Action { 5 | l10n() { 6 | return { 7 | "zh-Hans": { 8 | "b23clean.converting": "正在转换...", 9 | "b23clean.noUrl": "未检测到链接", 10 | "b23clean.noBiliUrl": "未检测到 bilibili 链接", 11 | "b23clean.success": "已转换为 BV 视频链接", 12 | "b23clean.noChange": "无变化", 13 | "b23clean.multipleLinks": "多条链接仅在编辑模式下可用。" 14 | }, 15 | en: { 16 | "b23clean.converting": "Converting...", 17 | "b23clean.noUrl": "No link detected", 18 | "b23clean.noBiliUrl": "bilibili link not detected", 19 | "b23clean.success": "Converted to BV video link", 20 | "b23clean.noChange": "No change", 21 | "b23clean.multipleLinks": "Multiple links are only available in edit mode." 22 | } 23 | } 24 | } 25 | 26 | async cleanUrl(b23url) { 27 | if (b23url.indexOf("bilibili.com") === -1 && b23url.indexOf("b23.tv") === -1) { 28 | throw new Error($l10n("b23clean.noBiliUrl")) 29 | } 30 | 31 | let url = b23url 32 | if (b23url.indexOf("b23.tv") >= 0) { 33 | const resp = await $http.get(b23url) 34 | url = resp.response.url 35 | } 36 | 37 | const queryStart = url.indexOf("?") 38 | if (queryStart > -1) { 39 | url = url.substring(0, queryStart - 1) 40 | } 41 | 42 | return url 43 | } 44 | 45 | /** 46 | * 系统会调用 do() 方法 47 | */ 48 | async do() { 49 | $ui.toast($l10n("b23clean.converting"), 1000) 50 | 51 | try { 52 | const b23url = this.getUrls() 53 | if (b23url.length === 0) { 54 | throw new Error($l10n("b23clean.noUrl")) 55 | } 56 | 57 | if (b23url.length === 1) { 58 | let url = await this.cleanUrl(b23url[0]) 59 | $ui.clearToast() 60 | $ui.alert({ 61 | title: $l10n("b23clean.success"), 62 | message: url, 63 | actions: [ 64 | { title: $l10n("OK") }, 65 | { 66 | title: $l10n("COPY"), 67 | handler: () => { 68 | $clipboard.text = url 69 | $ui.success($l10n("COPIED")) 70 | } 71 | } 72 | ] 73 | }) 74 | } else { 75 | if (this.env !== ActionEnv.editor) { 76 | $ui.toast($l10n("b23clean.multipleLinks")) 77 | return 78 | } 79 | let flag = false 80 | for (let i = 0; i < b23url.length; i++) { 81 | try { 82 | const url = b23url[i].trim() 83 | const replacedUrl = await this.cleanUrl(url) 84 | flag = true 85 | 86 | if (url !== replacedUrl) { 87 | const newText = this.text.replace(url, replacedUrl) 88 | this.setContent(newText) 89 | } 90 | } catch {} 91 | } 92 | if (!flag) { 93 | throw new Error($l10n("b23clean.noBiliUrl")) 94 | } else { 95 | $ui.toast($l10n("b23clean.noChange")) 96 | } 97 | } 98 | } catch (error) { 99 | $ui.clearToast() 100 | $delay(0.5, () => $ui.error(error)) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /scripts/setting/general/keyboard.js: -------------------------------------------------------------------------------- 1 | const { 2 | SettingSwitch, 3 | SettingStepper, 4 | SettingScript, 5 | SettingTab, 6 | SettingNumber, 7 | SettingPush, 8 | SettingChild, 9 | SettingImage 10 | } = require("../../libs/easy-jsbox") 11 | 12 | module.exports = new SettingChild({ 13 | icon: ["keyboard", "#a2a5a6"], 14 | title: "KEYBOARD" 15 | }).with({ 16 | children: [ 17 | { 18 | items: [ 19 | new SettingPush({ 20 | icon: "rectangle.3.offgrid.fill", 21 | title: "PREVIEW", 22 | key: "keyboard.previewAndHeight", 23 | value: 267 24 | }).with({ view: "this.method.previewKeyboard" }) 25 | ] 26 | }, 27 | { 28 | items: [ 29 | new SettingTab({ 30 | icon: ["rectangle.topthird.inset.filled", "#A569BD"], 31 | title: "DISPLAY_MODE", 32 | key: "keyboard.displayMode", 33 | value: 0 34 | }).with({ items: ["LIST", "MATRIX"] }), 35 | new SettingNumber({ 36 | icon: ["textformat.size"], 37 | title: "FONT_SIZE", 38 | key: "keyboard.fontSize", 39 | value: 16 40 | }), 41 | new SettingSwitch({ 42 | icon: ["checkerboard.rectangle", "#1899c4"], 43 | title: "USE_BLUR", 44 | key: "keyboard.blur", 45 | value: false 46 | }), 47 | new SettingImage({ 48 | icon: ["photo", "#FFCC66"], 49 | title: "BACKGROUND_IMAGE", 50 | key: "keyboard.background.image" 51 | }) 52 | ] 53 | }, 54 | { 55 | items: [ 56 | new SettingScript({ 57 | icon: ["pin", "#FFCC33"], 58 | title: "PIN_ACTION" 59 | }).with({ script: "this.method.keyboardPinAction" }), 60 | new SettingSwitch({ 61 | icon: ["bolt.circle", "#FF6633"], 62 | title: "keyboard.excludePin", 63 | key: "keyboard.excludePin", 64 | value: false 65 | }) 66 | ] 67 | }, 68 | { 69 | items: [ 70 | new SettingSwitch({ 71 | icon: ["globe", "#1899c4"], 72 | title: "SWITCH_AFTER_INSERT", 73 | key: "keyboard.switchAfterInsert", 74 | value: false 75 | }), 76 | new SettingSwitch({ 77 | icon: ["cursor.rays", "#FF8C00"], 78 | title: "TAPTIC_ENGINE", 79 | key: "keyboard.tapticEngine", 80 | value: true 81 | }), 82 | new SettingStepper({ 83 | icon: ["cursor.rays", "#FF8C00"], 84 | title: "TAPTIC_ENGINE_LEVEL", 85 | key: "keyboard.tapticEngineLevel", 86 | value: 1 87 | }).with({ min: 0, max: 2 }), 88 | new SettingScript({ 89 | icon: "paperplane", 90 | title: "QUICK_START_SCRIPTS" 91 | }).with({ script: "this.method.setKeyboardQuickStart" }), 92 | new SettingSwitch({ 93 | icon: ["paperplane", "#1899c4"], 94 | title: "RUN_DIRECTLY", 95 | key: "keyboard.runDirectly", 96 | value: false 97 | }) 98 | ] 99 | }, 100 | { 101 | items: [ 102 | new SettingNumber({ 103 | icon: ["rays", "#FFCC33"], 104 | title: "DELETE_DELAY", 105 | key: "keyboard.deleteDelay", 106 | value: 0.05 107 | }) 108 | ] 109 | } 110 | ] 111 | }) 112 | -------------------------------------------------------------------------------- /scripts/dao/webdav-sync-action.js: -------------------------------------------------------------------------------- 1 | const { FileStorage } = require("../libs/easy-jsbox") 2 | const WebDavSync = require("./webdav-sync") 3 | 4 | /** 5 | * @typedef {import("../app-main").AppKernel} AppKernel 6 | */ 7 | 8 | class WebDavSyncAction extends WebDavSync { 9 | localSyncDataPath = "/user_action/sync.json" 10 | webdavSyncDataPath = "/sync.json" 11 | 12 | tempPath = "/temp" 13 | webdavActionsPath = "/actions.zip" 14 | localActionsPath = "/user_action" 15 | 16 | constructor({ host, user, password, basepath, kernel } = {}) { 17 | basepath = FileStorage.join(basepath, "user_action") 18 | super({ host, user, password, basepath, kernel }) 19 | } 20 | 21 | async init() { 22 | await super.init() 23 | this.sync() 24 | } 25 | 26 | isNew() { 27 | return this.kernel.actions.isNew 28 | } 29 | 30 | async pull() { 31 | const tempPath = FileStorage.join(this.tempPath, "user_action") 32 | const rawTempPath = this.kernel.fileStorage.filePath(tempPath) 33 | const resp = await this.webdav.get(this.webdavActionsPath) 34 | const success = await $archiver.unzip({ file: resp.rawData, dest: rawTempPath }) 35 | if (!success) { 36 | throw new Error($l10n("UNZIP_FAILED")) 37 | } 38 | await this.downloadSyncData() 39 | 40 | this.kernel.fileStorage.move(tempPath, this.localActionsPath) 41 | this.kernel.logger.info(`action webdav sync: pulled`) 42 | } 43 | async push() { 44 | const actionsZip = FileStorage.join(this.tempPath, "actions.zip") 45 | const success = $archiver.zip({ 46 | directory: this.kernel.fileStorage.filePath(this.localActionsPath), 47 | dest: this.kernel.fileStorage.filePath(actionsZip) 48 | }) 49 | if (!success) { 50 | throw new Error($l10n("ZIP_FAILED")) 51 | } 52 | await $wait(0.5) 53 | await this.webdav.put(this.webdavActionsPath, this.kernel.fileStorage.readSync(actionsZip)) 54 | await this.uploadSyncData() 55 | 56 | this.kernel.fileStorage.delete(actionsZip) 57 | this.kernel.logger.info(`action webdav sync: pushed`) 58 | } 59 | 60 | notify(option) { 61 | $app.notify({ 62 | name: "actionSyncStatus", 63 | object: option 64 | }) 65 | } 66 | 67 | async #sync() { 68 | let isPull = false 69 | try { 70 | const syncStep = await this.nextSyncStep() 71 | this.kernel.logger.info(`action nextSyncStep: ${WebDavSync.stepName[syncStep]}`) 72 | if (syncStep === WebDavSync.step.needPush || syncStep === WebDavSync.step.init) { 73 | await this.push() 74 | } else if (syncStep === WebDavSync.step.needPull) { 75 | await this.pull() 76 | isPull = true 77 | } else if (syncStep === WebDavSync.step.conflict) { 78 | const resp = await this.conflict($l10n("ACTIONS")) 79 | if (resp === WebDavSyncAction.conflictKeep.webdav) { 80 | isPull = true 81 | } else { 82 | return 83 | } 84 | } else { 85 | this.notify({ status: WebDavSync.status.nochange }) 86 | return 87 | } 88 | this.notify({ 89 | status: WebDavSync.status.success, 90 | updateList: isPull 91 | }) 92 | } catch (error) { 93 | this.notify({ 94 | status: WebDavSync.status.fail, 95 | error 96 | }) 97 | throw error 98 | } 99 | } 100 | 101 | sync() { 102 | this.notify({ status: WebDavSync.status.syncing, animate: true }) 103 | if (this.syncTimer) this.syncTimer.cancel() 104 | this.syncTimer = $delay(0.5, () => { 105 | this.#sync(true) 106 | }) 107 | } 108 | 109 | needUpload() { 110 | this.notify({ status: WebDavSync.status.syncing }) 111 | if (this.uploadTimer) this.uploadTimer.cancel() 112 | this.uploadTimer = $delay(0.5, () => { 113 | this.updateLocalTimestamp() 114 | this.#sync() 115 | }) 116 | } 117 | } 118 | 119 | module.exports = WebDavSyncAction 120 | -------------------------------------------------------------------------------- /scripts/action/README.md: -------------------------------------------------------------------------------- 1 | # Action 2 | 3 | 所有 Action 保存在 `storage/user_action` 目录下,按照文件夹分类 4 | 5 | `Action` 结构如下: 6 | 7 | - `ActionName` 8 | - `config.json` 配置文件 9 | - `main.js` 入口文件 10 | - `README.md` 说明文件 11 | 12 | ## `config.json` 配置项 13 | 14 | - `icon` 图标 可以是 [JSBox 内置图标](https://github.com/cyanzhong/xTeko/tree/master/extension-icons)、SF Symbols图标、base64图片数据和来自 url 的图片 15 | - `color` 颜色 16 | - `name` 名称 17 | - `description` 描述信息 18 | 19 | ## `main.js` 入口文件 20 | 21 | 创建名为 `MyAction` 的类并继承 `Action` 类 22 | 23 | ```js 24 | /** 25 | * @typedef {import("scripts/action/action.js").Action} Action 26 | */ 27 | /** 28 | * 必须为 MyAction 29 | */ 30 | class MyAction extends Action { 31 | /** 32 | * 系统会调用 do() 方法 33 | * 在编辑模式如果提供了返回值,则可弹窗预览返回值 34 | */ 35 | do() { 36 | console.log(this.text) 37 | } 38 | } 39 | ``` 40 | 41 | ## 父类 `Action` 的属性 42 | 43 | - `this.env` 44 | 当前运行环境,参见 [ActionEnv](#ActionEnv) 45 | - `this.config` 46 | 当前 Action 配置文件内容 47 | - `this.editor` 48 | 参见 [ActionData](#ActionData) 49 | - `this.section` 50 | 首页剪切板分类 51 | - `this.uuid` 52 | 首页剪切板项目 uuid 53 | - `this.text` 54 | 当处于键盘中运行时为输入框内文本,处于编辑器时为编辑器内文本,其他情况为剪切板内文本。(仅可获取光标行) 55 | - `this.selectedText` 56 | 当前选中的文本 57 | - `this.selectedRange` 58 | 在编辑器中,当前选中的文本范围 `{location: Number, length: Number}` 59 | - `this.textBeforeInput` 60 | 键盘中输入光标之前的文本 61 | - `this.textAfterInput` 62 | 键盘中输入光标之后的文本 63 | 64 | 更多参见 [ActionData](#ActionData) 65 | 66 | ## 父类的方法 67 | 68 | ```js 69 | /** 70 | * 编辑动作状态下提供预览数据 71 | * @returns {ActionData} 72 | */ 73 | preview(): ActionData 74 | 75 | /** 76 | * 重写该方法返回 l10n 对象可注入 l10n 77 | * l10n() { 78 | return { 79 | "zh-Hans": { 80 | "clipboard.clean.success": "剪切板已清空" 81 | }, 82 | en: { 83 | "clipboard.clean.success": "Clipboard is cleaned" 84 | } 85 | } 86 | } 87 | */ 88 | l10n() 89 | 90 | /** 91 | * page sheet 92 | * @param {*} args 93 | * { 94 | view: args.view, // 视图对象 95 | title: args.title ?? "", // 中间标题 96 | done: args.done, // 点击左上角按钮后的回调函数 97 | doneText: args.doneText ?? $l10n("DONE") // 左上角文本 98 | rightButtons: [{ title:string, symbol:string, tapped:function }] // 右上角按钮 99 | } 100 | * @returns {Sheet} 101 | */ 102 | pageSheet(args): Sheet 103 | showTextContent(text, title = ""): Sheet 104 | showMarkdownContent(markdown, title = ""): Sheet 105 | 106 | /** 107 | * 获取所有剪切板数据 108 | * @returns {object} 109 | */ 110 | getAllClips(): { favorite, clips } 111 | 112 | /** 113 | * 更新当前文本,当用户侧滑返回时才会触发保存操作 114 | */ 115 | setContent(text): void 116 | 117 | /** 118 | * 获取指定的 Action 类 119 | * @param {string} category 120 | * @param {string} name config.name 121 | * @param {ActionData} data new ActionData({ args: any }) 122 | * @returns 123 | */ 124 | getAction(category, name, data): any 125 | 126 | /** 127 | * 运行指定的 Action 并返回该 Action do() 方法的返回值 128 | */ 129 | async runAction(category, name, data): any 130 | 131 | /** 132 | * 从 `this.text` 中匹配所有 url 133 | */ 134 | getUrls(): [] 135 | 136 | /** 137 | * 运行其他 JSBox 脚本 138 | * @param {string} name 139 | */ 140 | addinRun(name): void 141 | ``` 142 | 143 | ## ActionEnv 144 | 145 | ```js 146 | class ActionEnv { 147 | static build = -1 // 动作编辑器 148 | static today = 0 149 | static editor = 1 150 | static clipboard = 2 151 | static action = 3 // 主动作页面 152 | static keyboard = 4 153 | static recursion = 5 154 | static widget = 6 155 | static siri = 7 156 | } 157 | ``` 158 | 159 | ## ActionData 160 | 161 | ```js 162 | class ActionData { 163 | env 164 | args // 其他动作传递的参数 165 | text // 自动获取文本,优先获取选中的文本 166 | originalContent // 原始文本 167 | section // 首页剪切板分类 168 | uuid // 首页剪切板项目 uuid 169 | selectedRange // 文本选中的范围 170 | selectedText // 选中的文本 171 | textBeforeInput // 键盘中输入光标之前的文本 172 | textAfterInput // 键盘中输入光标之后的文本 173 | editor // 编辑器 174 | } 175 | ``` 176 | 177 | 其中 `editor` 如下: 178 | 179 | ```js 180 | const editor = { 181 | originalContent, 182 | setContent: text => {} 183 | } 184 | ``` 185 | -------------------------------------------------------------------------------- /scripts/app-lite.js: -------------------------------------------------------------------------------- 1 | const { AppKernelBase } = require("./app") 2 | 3 | /** 4 | * @typedef {AppKernel} AppKernel 5 | */ 6 | class AppKernel extends AppKernelBase { 7 | constructor() { 8 | super() 9 | this.setting.setReadonly() 10 | } 11 | 12 | addOpenInJsboxButton() { 13 | this.useJsboxNav() 14 | this.setNavButtons([ 15 | { 16 | image: $image("assets/icon.png"), 17 | handler: () => this.openInJsbox() 18 | } 19 | ]) 20 | } 21 | } 22 | 23 | class Shortcuts { 24 | /** @type {AppKernel} */ 25 | kernel 26 | 27 | constructor(kernel) { 28 | this.kernel = kernel 29 | 30 | // to display action running result 31 | $ui.render({ 32 | views: [ 33 | { 34 | type: "label", 35 | props: { 36 | id: "shortcuts-root-label", 37 | lines: 0, 38 | text: "CAIO" 39 | }, 40 | layout: $layout.center 41 | } 42 | ] 43 | }) 44 | if (!this.kernel.runActionFlag) { 45 | this.checkQuery() 46 | } 47 | } 48 | 49 | setClips(content) { 50 | if (content.trim() === "") { 51 | throw new Error("cannot set empty content") 52 | } 53 | this.kernel.clips.addItem(content) 54 | } 55 | getClips(getIdx) { 56 | if (typeof getIdx !== "number") { 57 | throw new Error("`get` must be a number index") 58 | } 59 | const clip = this.kernel.clips.getByIndex(getIdx) 60 | return clip.text 61 | } 62 | deleteClips(deleteIdx) { 63 | if (typeof deleteIdx !== "number") { 64 | throw new Error("`delete` must be a number index") 65 | } 66 | const clip = this.kernel.clips.getByIndex(deleteIdx) 67 | const text = clip.text 68 | this.kernel.clips.delete(clip.uuid) 69 | return text 70 | } 71 | 72 | checkQuery() { 73 | const table = $context.query["table"]?.trim() ?? "" 74 | if (table !== "") { 75 | this.kernel.clips.rememberTabIndex = false // 不记住标签页 76 | const tabItemsMap = this.kernel.clips.tabItemsMap 77 | this.kernel.clips.tabIndex = tabItemsMap[table] ?? tabItemsMap["clips"] 78 | } 79 | 80 | try { 81 | if ($context.query["set"] !== undefined) { 82 | this.setClips($context.query["set"]) 83 | $intents.finish() 84 | } else if ($context.query["get"] !== undefined) { 85 | this.getClips($context.query["get"]) 86 | $intents.finish(clip.text) 87 | } else if ($context.query["delete"] !== undefined) { 88 | this.deleteClips($context.query["delete"]) 89 | $intents.finish(text) 90 | } else { 91 | $intents.finish("`get`, `set` or `delete` is required") 92 | } 93 | } catch (error) { 94 | $intents.finish(error.message) 95 | } finally { 96 | $intents.finish() // 防止卡住 97 | } 98 | } 99 | } 100 | 101 | class AppUI { 102 | static kernel = new AppKernel() 103 | 104 | static renderKeyboardUI() { 105 | this.kernel.addOpenInJsboxButton() 106 | 107 | const Keyboard = require("./ui/keyboard") 108 | const keyboard = new Keyboard(this.kernel) 109 | 110 | this.kernel.KeyboardRenderWithViewFunc(() => keyboard.getView(), keyboard.keyboardHeight) 111 | } 112 | 113 | static renderTodayUI() { 114 | this.kernel.addOpenInJsboxButton() 115 | 116 | const Today = require("./ui/today") 117 | const today = new Today(this.kernel) 118 | 119 | this.kernel.UIRender(today.getView()) 120 | } 121 | 122 | static shortcuts() { 123 | new Shortcuts(this.kernel) 124 | } 125 | } 126 | 127 | module.exports = { 128 | run: () => { 129 | //AppUI.renderKeyboardUI();return 130 | //AppUI.renderTodayUI();return 131 | 132 | if ($app.env === $env.today || $app.env === $env.notification) { 133 | AppUI.renderTodayUI() 134 | } else if ($app.env === $env.keyboard) { 135 | AppUI.renderKeyboardUI() 136 | } else if ($app.env === $env.siri) { 137 | AppUI.shortcuts() 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | const { UIKit, Kernel, Logger, FileStorage, Setting } = require("./libs/easy-jsbox") 2 | const SettingStructure = require("./setting/setting") 3 | const { Storage } = require("./dao/storage") 4 | const Clips = require("./ui/clips/clips") 5 | const Actions = require("./ui/action/actions") 6 | const { ActionEnv } = require("./action/action") 7 | 8 | /** 9 | * @typedef {AppKernelBase} AppKernelBase 10 | */ 11 | class AppKernelBase extends Kernel { 12 | static fileStorage = new FileStorage({ 13 | basePath: UIKit.isTaio ? FileStorage.join($file.rootPath, "caio") : "shared://caio" 14 | }) 15 | 16 | #storage 17 | #clips 18 | #actions 19 | 20 | #addins = {} 21 | 22 | runActionFlag = false 23 | 24 | constructor() { 25 | super() 26 | // $file.list($file.rootPath) 27 | $app.listen({ exit: () => $objc_clean() }) 28 | // FileStorage 29 | this.fileStorage = AppKernelBase.fileStorage 30 | // Logger 31 | this.logger = new Logger() 32 | this.logger.printToFile([Logger.level.warn, Logger.level.error]) 33 | this.logger.setWriter(this.fileStorage, FileStorage.join("logs", this.logFile)) 34 | // Setting 35 | this.setting = new Setting({ 36 | logger: this.logger, 37 | fileStorage: this.fileStorage, 38 | structure: SettingStructure 39 | }) 40 | 41 | this.runAction($context.query) 42 | } 43 | 44 | getQueries(query) { 45 | const queries = {} 46 | if (query.indexOf("?") !== -1) { 47 | query = query.split("?")[1] 48 | } 49 | const pairs = query.split("&") 50 | pairs.forEach(pair => { 51 | const indexOfEquals = pair.indexOf("=") 52 | if (indexOfEquals !== -1) { 53 | const key = pair.slice(0, indexOfEquals) 54 | const value = pair.slice(indexOfEquals + 1) 55 | queries[key] = value 56 | } else { 57 | const key = pair 58 | queries[key] = "" 59 | } 60 | }) 61 | return queries 62 | } 63 | 64 | runAction(query) { 65 | if (!query.runAction) return 66 | this.runActionFlag = true 67 | 68 | if (query.runAction?.startsWith("jsbox://")) { 69 | query.runAction = this.getQueries(query.runAction).runAction 70 | } 71 | const data = JSON.parse($text.base64Decode(query.runAction)) 72 | const env = $app.env === $env.siri ? ActionEnv.siri : ActionEnv.widget 73 | 74 | $delay(0.1, async () => { 75 | try { 76 | const action = this.actions.getAction(data.category, data.dir, { env, ...query }) 77 | let result = await action.do() 78 | if (env === ActionEnv.siri) { 79 | $intents.finish(result) 80 | if (typeof result === "object") { 81 | result = JSON.stringify(result, null, 2) 82 | } 83 | $("shortcuts-root-label").text = result ?? "CAIO" 84 | } 85 | } catch (error) { 86 | if (env === ActionEnv.siri) { 87 | $intents.finish(error) 88 | } 89 | this.logger.error(error) 90 | } 91 | }) 92 | } 93 | 94 | get logFile() { 95 | return "caio.log" 96 | } 97 | 98 | get isWebdavEnabled() { 99 | return this.setting.get("webdav.status") 100 | } 101 | 102 | /** 103 | * @type {Storage} 104 | */ 105 | get storage() { 106 | if (!this.#storage) { 107 | this.#storage = new Storage(this) 108 | } 109 | return this.#storage 110 | } 111 | 112 | /** 113 | * @type {Clips} 114 | */ 115 | get clips() { 116 | if (!this.#clips) { 117 | this.#clips = new Clips(this) 118 | } 119 | return this.#clips 120 | } 121 | 122 | /** 123 | * @type {Actions} 124 | */ 125 | get actions() { 126 | if (!this.#actions) { 127 | this.#actions = new Actions(this) 128 | } 129 | return this.#actions 130 | } 131 | 132 | getAddin(name) { 133 | if (this.#addins[name]) { 134 | return this.#addins[name] 135 | } 136 | const list = $addin.list 137 | for (const s of list) { 138 | if (s.name === name || s.displayName === name) { 139 | this.#addins[name] = s 140 | return this.#addins[name] 141 | } 142 | } 143 | this.logger.warn(`Addin ${name} not found`) 144 | return null 145 | } 146 | } 147 | 148 | module.exports = { 149 | AppKernelBase 150 | } 151 | -------------------------------------------------------------------------------- /scripts/ui/components/selectActions.js: -------------------------------------------------------------------------------- 1 | const { Sheet } = require("../../libs/easy-jsbox") 2 | 3 | /** 4 | * @typedef {import("../../app-main").AppKernel} AppKernel 5 | */ 6 | 7 | class SelectActions { 8 | cacheKey 9 | 10 | /** 11 | * @param {AppKernel} kernel 12 | */ 13 | constructor(kernel) { 14 | this.kernel = kernel 15 | } 16 | 17 | setKernel(kernel) { 18 | this.kernel = kernel 19 | return this 20 | } 21 | 22 | getRawActions() { 23 | const actions = $cache.get(this.cacheKey) 24 | if (!Array.isArray(actions)) { 25 | return [] 26 | } 27 | return actions 28 | } 29 | 30 | getActions(defaultAll = false) { 31 | const actions = this.getRawActions() 32 | const existsAction = actions.filter(action => this.kernel.actions.exists(action.category, action.dir)) 33 | if (existsAction.length === 0 && defaultAll) { 34 | return Object.values(this.kernel.actions.allActions) 35 | } 36 | return existsAction 37 | } 38 | 39 | addAction(action) { 40 | const actions = this.getActions() 41 | actions.push(action) 42 | $cache.set(this.cacheKey, actions) 43 | } 44 | 45 | setActions(actions = []) { 46 | $cache.set(this.cacheKey, actions) 47 | } 48 | 49 | updateAction(from, to) { 50 | this.setActions( 51 | // Using getRawActions to ensure not losing actions 52 | // this.kernel.actions.exists will filter out actions that do not exist if using getActions 53 | this.getRawActions().map(action => { 54 | if (action.category === from.category && action.dir === from.dir) { 55 | this.kernel.logger.info( 56 | `Updating action from ${from.category}/${from.dir} to ${to.category}/${to.dir}` 57 | ) 58 | return to 59 | } 60 | return action 61 | }) 62 | ) 63 | } 64 | 65 | add() { 66 | const selectionId = "keyboard.add.sheet" 67 | const getSelectionData = () => { 68 | const selected = this.getActions().map(a => a.name) 69 | const data = this.kernel.actions.actionList 70 | for (let i = 0; i < data.length; i++) { 71 | data[i].items = data[i].items.filter(action => { 72 | return selected.indexOf(action.name.text) === -1 73 | }) 74 | } 75 | data.map(category => { 76 | category.rows = category.items 77 | return category 78 | }) 79 | return data 80 | } 81 | const view = this.kernel.actions.views.getActionListView( 82 | (_, action) => { 83 | this.addAction(action) 84 | $(this.listId).data = this.getListData() 85 | $(selectionId).data = getSelectionData() 86 | }, 87 | { 88 | id: selectionId, 89 | bgcolor: $color("primarySurface"), 90 | stickyHeader: false, 91 | data: getSelectionData() 92 | }, 93 | {}, 94 | $layout.fill 95 | ) 96 | const sheet = new Sheet() 97 | sheet 98 | .setView(view) 99 | .addNavBar({ title: $l10n("ADD") }) 100 | .init() 101 | .present() 102 | } 103 | 104 | getNavButtons() { 105 | return [ 106 | { 107 | symbol: "plus", 108 | tapped: () => this.add() 109 | } 110 | ] 111 | } 112 | 113 | getListData(actions = this.getActions()) { 114 | return actions.map(action => { 115 | return this.kernel.actions.views.actionToData(action) 116 | }) 117 | } 118 | 119 | getListView() { 120 | return this.kernel.actions.views.getActionListView( 121 | undefined, 122 | { 123 | id: this.listId, 124 | bgcolor: $color("primarySurface"), 125 | stickyHeader: false, 126 | reorder: true, 127 | data: this.getListData(), 128 | actions: [ 129 | { 130 | title: "delete", 131 | handler: (sender, indexPath) => { 132 | const data = sender.data 133 | this.setActions(data.map(data => data.info.info)) 134 | } 135 | } 136 | ] 137 | }, 138 | { 139 | reorderFinished: data => { 140 | this.setActions(data.map(data => data.info.info)) 141 | } 142 | }, 143 | $layout.fill 144 | ) 145 | } 146 | 147 | async sheet() { 148 | const sheet = new Sheet() 149 | sheet.setView(this.getListView()).addNavBar({ 150 | title: $l10n("PIN_ACTION"), 151 | popButton: { title: $l10n("DONE") }, 152 | rightButtons: this.getNavButtons() 153 | }) 154 | 155 | sheet.init().present() 156 | } 157 | } 158 | 159 | module.exports = { 160 | SelectActions 161 | } 162 | -------------------------------------------------------------------------------- /strings/zh-Hans.strings: -------------------------------------------------------------------------------- 1 | "AUTHOR" = "作者"; 2 | "ALERT_INFO" = "提示"; 3 | "NONE" = "什么都没有"; 4 | "DONE" = "完成"; 5 | "CLOSE" = "关闭"; 6 | "FAILED_TO_LOAD_VIEW" = "加载视图失败"; 7 | "VIEW_NOT_PROVIDED" = "未提供该视图"; 8 | "UNCATEGORIZED" = "未分类"; 9 | "SHARE" = "分享"; 10 | 11 | "CLICK_TO_OPEN_JSBOX" = "点击标题打开主应用。"; 12 | 13 | "CLIPS" = "剪切板"; 14 | "CLIPBOARD" = "剪切板"; 15 | "UNIVERSAL_CLIPBOARD" = "通用剪贴板"; 16 | "UNIVERSAL_CLIPBOARD_TIPS" = "用剪贴板允许您在 iPhone 上复制某些内容,然后使用 iCloud 将其粘贴到 Mac 上(反之亦然)。"; 17 | "CLIPS_STRUCTURE_ERROR" = "剪切板数据结构异常"; 18 | "CLIPBOARD_NO_CHANGE" = "剪切板无变化"; 19 | "RECYCLE_BIN" = "回收站"; 20 | "ADD" = "添加"; 21 | "TAG" = "标签"; 22 | "ADD_TAG" = "添加标签"; 23 | "EDIT" = "编辑"; 24 | "SEARCH" = "搜索"; 25 | "SEARCH_HISTORY" = "搜索历史"; 26 | "SEARCH_RESULT" = "搜索结果"; 27 | "NO_SEARCH_RESULT" = "搜索无结果"; 28 | "FAVORITE" = "收藏"; 29 | "COPY" = "复制"; 30 | "COPIED" = "已复制"; 31 | "SORT" = "排序"; 32 | "ACTIONS" = "动作"; 33 | "MORE_ACTIONS" = "更多动作"; 34 | "PREVIEW" = "预览"; 35 | "MAX_ITEM_LENGTH" = "行数限制"; 36 | "TEXT_MAX_LENGTH" = "显示字符长度"; 37 | "AUTO_SAVE" = "自动保存"; 38 | "AUTO_SYNC" = "自动同步"; 39 | "SYNC_NOW" = "立即同步"; 40 | "UNZIP_FAILED" = "解压文件失败"; 41 | "REBUILD" = "重建"; 42 | "REBUILD_DATABASE" = "重建数据库"; 43 | "REBUILD_DATABASE_ALERT" = "重建数据库将会丢失顺序信息,是否确认重建?"; 44 | "DELETE_ALL_DATA" = "删除所有数据"; 45 | "DELETE_ALL_DATA_ALERT" = "确定要删除所有数据吗?"; 46 | "DELETE_DATA" = "删除数据"; 47 | "DELETE_TABLE" = "删除 `${table}` 的所有数据?"; 48 | "SELECT_ALL" = "全选"; 49 | "DESELECT_ALL" = "取消全选"; 50 | 51 | "EDITOR" = "编辑器"; 52 | "CREATE_NEW" = "新建"; 53 | "CREATE_NEW_ACTION" = "新建动作"; 54 | "CREATE_NEW_TYPE" = "新建分类"; 55 | "TYPE_ALREADY_EXISTS" = "该类别已经存在"; 56 | "EDIT_DETAILS" = "编辑信息"; 57 | "EDIT_SCRIPT" = "编辑脚本"; 58 | "INFORMATION" = "信息"; 59 | "NAME" = "名称"; 60 | "ICON" = "图标"; 61 | "CATEGORY" = "分类"; 62 | "EDIT_CATEGORY" = "编辑分类"; 63 | "delete.category" = "删除分类 ${category}"; 64 | "delete.category.keep.actions" = "是否保留该分类中的动作?"; 65 | "DESCRIPTION" = "描述"; 66 | "CODE" = "代码"; 67 | "TEXT_INSETS" = "文本下边距"; 68 | "SHOW_LINE_NUMBER" = "显示行号"; 69 | "LIGHT_MODE_THEME" = "浅色模式主题"; 70 | "DARK_MODE_THEME" = "深色模式主题"; 71 | 72 | "SAVE" = "保存"; 73 | "SAVE_SUCCESS" = "保存成功"; 74 | "SAVE_ERROR" = "保存失败"; 75 | "DELETE" = "删除"; 76 | "CONFIRM" = "确认"; 77 | "DELETE_CONFIRM_MSG" = "确认要删除吗?"; 78 | "DELETE_SUCCESS" = "删除成功"; 79 | "DELETE_ERROR" = "删除失败"; 80 | 81 | "IMPORT_EXAMPLE_ACTIONS" = "导入示例动作"; 82 | "REBUILD_ACTION_DATABASE" = "重建动作库"; 83 | "REBUILD_ACTION_DATABASE_ALERT_TITLE" = "您确认要重建?"; 84 | "REBUILD_ACTION_DATABASE_ALERT_MESSAGE" = "重建会同时删除保存在 WebDAV 中的数据!(如果开启的话)"; 85 | "EXPORT" = "导出"; 86 | "IMPORT" = "导入"; 87 | "FILE_TYPE_ERROR" = "文件类型不符"; 88 | "OVERWRITE_ALERT" = "该操作将会覆盖当前数据,是否继续?"; 89 | "UNABLE_CREATE_ACTION" = "无法创建动作"; 90 | "ACTION_NAME_ALREADY_EXISTS" = "动作 `${name}` 已存在"; 91 | "IMPORT_FROM_FILE" = "从文件导入"; 92 | "DEIT_CATEGORY" = "编辑分类"; 93 | 94 | "KEYBOARD" = "键盘"; 95 | "KEYBOARD_HEIGHT" = "键盘高度"; 96 | "USE_BLUR" = "使用模糊效果"; 97 | "BACKGROUND_IMAGE" = "背景图片"; 98 | "DELETE_DELAY" = "删除延时"; 99 | "SWITCH_AFTER_INSERT" = "输入后切换"; 100 | "JSBOX_TOOLBAR" = "JSBox 工具栏"; 101 | "QUICK_START_SCRIPTS" = "快速启动脚本"; 102 | "SEND" = "发送"; 103 | "OPEN_IN_JSBOX" = "在 JSBox 中打开"; 104 | "SWITCH_KEYBOARD" = "切换键盘"; 105 | "TAPTIC_ENGINE" = "触感反馈"; 106 | "TAPTIC_ENGINE_LEVEL" = "触感反馈强度"; 107 | "TAPTIC_ENGINE_FOR_DELETE" = "删除按钮触感反馈"; 108 | "SPACE" = "空格"; 109 | "ALL_SCRIPTS" = "所有脚本"; 110 | "SELECT_SCRIPTS" = "选择脚本"; 111 | "FONT_SIZE" = "字体大小"; 112 | "LIST" = "列表"; 113 | "MATRIX" = "网格"; 114 | "PIN_ACTION" = "置顶动作"; 115 | "keyboard.excludePin" = "列表中排除已置顶"; 116 | "RUN_DIRECTLY" = "直接运行脚本"; 117 | 118 | "CHECK_UPDATE" = "检查更新"; 119 | "UPDATE" = "更新"; 120 | 121 | "WIDGET" = "小组件"; 122 | "RECENT" = "最近内容"; 123 | "CLICK_ACTION" = "点击事件"; 124 | 125 | "TODAY_WIDGET" = "通知中心小组件"; 126 | "PREV_PAGE" = "上一页"; 127 | "NEXT_PAGE" = "下一页"; 128 | 129 | "DISPLAY_MODE" = "显示模式"; 130 | "CLASSIC" = "经典"; 131 | "MODERN" = "现代"; 132 | 133 | "FILE_MANAGEMENT" = "文件管理"; 134 | 135 | "compatibility.rebuildUserAction.alert.title" = "我们需要重建部分动作!"; 136 | "compatibility.rebuildUserAction.alert.message" = "如果您点击 好,以下动作将会被重建:"; 137 | "compatibility.rebuildUserAction.alert.message2" = "只有动作逻辑会被更改,名称和图标将维持现状。"; 138 | 139 | "EXPERIMENTAL" = "实验功能"; 140 | "SYNC_ACTIONS" = "动作同步"; 141 | "SYNCING" = "正在同步..."; 142 | "LAST_SYNC_AT" = "最后同步:"; 143 | "MODIFIED" = "最近修改:"; 144 | "WEBDAV_ERROR_CLOSED" = "WebDAV 同步出错,暂时关闭。"; 145 | 146 | "HOST" = "Host"; 147 | "USER" = "User"; 148 | "PASSWORD" = "Password"; 149 | "BASEPATH" = "Base Path"; 150 | "DATA_CONFLICT" = "数据同步发生冲突"; 151 | "DATA_CONFLICT_MESSAGE" = "请选择想要保留的数据"; 152 | "WEBDAV_DATA" = "WebDAV 数据"; 153 | "LOCAL_DATA" = "本地数据"; 154 | "ADD_TO_TAIO" = "添加到 Taio"; 155 | "SELECT_TAIO_APP" = "请在分享菜单中选择 Taio App"; 156 | 157 | "ACTION_SAFETY_WARNING" = "动作安全警告"; 158 | "ACTION_PERMISSION_REQUEST" = "动作权限申请"; 159 | 160 | "ACTION_RESET_NAME_WARNING" = "动作 `${name}` 正在尝试将名称改为 `${to_name}`,这可能导致其获取 `${to_name}` 的所有权限"; 161 | "ACTION_NETWORK_PERMISSION_MESSAGE" = "是否允许动作 `${name}` 获取网络权限?"; 162 | 163 | "Return" = "返回"; 164 | "Go" = "前往"; 165 | "Google" = "谷歌"; 166 | "Join" = "加入"; 167 | "Next" = "下一步"; 168 | "Route" = "路线"; 169 | "Search" = "搜索"; 170 | "Send" = "发送"; 171 | "Yahoo" = "雅虎"; 172 | "Done" = "完成"; 173 | "Emergency Call" = "紧急呼叫"; 174 | "Continue" = "继续"; 175 | "Joining" = "正在加入"; 176 | "Route Continue" = "继续路线"; -------------------------------------------------------------------------------- /scripts/ui/components/keyboard-scripts.js: -------------------------------------------------------------------------------- 1 | const { Sheet } = require("../../libs/easy-jsbox") 2 | const { SelectActions } = require("./selectActions") 3 | 4 | /** 5 | * @typedef {import("../../app-main").AppKernel} AppKernel 6 | */ 7 | 8 | class KeyboardAddins { 9 | constructor() { 10 | this.listId = "keyboard-script-list" 11 | } 12 | 13 | static getAddins() { 14 | const addins = $cache.get("keyboard.addins") 15 | if (!addins) { 16 | return [] 17 | } else if ($cache.get("keyboard.addins.all")) { 18 | const current = $addin.current.name 19 | return $addin.list 20 | ?.filter(addin => { 21 | return current !== addin.displayName 22 | }) 23 | .map(i => i.displayName) 24 | } 25 | try { 26 | return JSON.parse(addins) 27 | } catch (error) { 28 | return [] 29 | } 30 | } 31 | 32 | static setAddins(list = []) { 33 | list.map((item, i) => { 34 | if (item === null) { 35 | list.splice(i, 1) 36 | } 37 | }) 38 | try { 39 | $cache.set("keyboard.addins", JSON.stringify(list)) 40 | } catch (error) { 41 | $cache.set("keyboard.addins", undefined) 42 | } 43 | } 44 | 45 | static setAllAddins(useAll) { 46 | $cache.set("keyboard.addins.all", useAll) 47 | } 48 | 49 | getUnsetAddins() { 50 | const current = $addin.current.name 51 | const addins = KeyboardAddins.getAddins() 52 | return $addin.list 53 | ?.filter(addin => { 54 | return addins.indexOf(addin.displayName) === -1 && current !== addin.displayName 55 | }) 56 | .map(i => i.displayName) 57 | } 58 | 59 | add() { 60 | const view = { 61 | type: "list", 62 | props: { 63 | data: this.getUnsetAddins() 64 | }, 65 | events: { 66 | didSelect: (sender, indexPath, data) => { 67 | const addins = KeyboardAddins.getAddins() 68 | addins.unshift(data) 69 | KeyboardAddins.setAddins(addins) 70 | $(this.listId).insert({ 71 | indexPath: $indexPath(0, 0), 72 | value: data 73 | }) 74 | sender.delete(indexPath) 75 | } 76 | }, 77 | layout: $layout.fill 78 | } 79 | const sheet = new Sheet() 80 | sheet 81 | .setView(view) 82 | .addNavBar({ title: $l10n("ADD") }) 83 | .init() 84 | .present() 85 | } 86 | 87 | getNavButtons() { 88 | return [ 89 | { 90 | symbol: "plus", 91 | tapped: () => this.add() 92 | } 93 | ] 94 | } 95 | 96 | getListView() { 97 | return { 98 | type: "list", 99 | props: { 100 | id: this.listId, 101 | reorder: true, 102 | data: KeyboardAddins.getAddins(), 103 | actions: [ 104 | { 105 | title: "delete", 106 | handler: (sender, indexPath) => { 107 | KeyboardAddins.setAddins(sender.data) 108 | } 109 | } 110 | ] 111 | }, 112 | events: { 113 | reorderFinished: data => { 114 | KeyboardAddins.setAddins(data) 115 | } 116 | }, 117 | layout: $layout.fill 118 | } 119 | } 120 | 121 | async sheet() { 122 | const selected = await $ui.menu({ 123 | items: [$l10n("ALL_SCRIPTS"), $l10n("SELECT_SCRIPTS")] 124 | }) 125 | if (selected.index === 0) { 126 | KeyboardAddins.setAllAddins(true) 127 | } else { 128 | KeyboardAddins.setAllAddins(false) 129 | const sheet = new Sheet() 130 | sheet.setView(this.getListView()).addNavBar({ 131 | title: $l10n("QUICK_START_SCRIPTS"), 132 | popButton: { title: $l10n("DONE") }, 133 | rightButtons: this.getNavButtons() 134 | }) 135 | 136 | sheet.init().present() 137 | } 138 | } 139 | } 140 | 141 | class KeyboardPinActions extends SelectActions { 142 | static shared = new KeyboardPinActions() 143 | cacheKey = "keyboard.pinAction" 144 | 145 | /** 146 | * @param {AppKernel} kernel 147 | */ 148 | constructor(kernel) { 149 | super(kernel) 150 | this.listId = "keyboard-pin-action-list" 151 | } 152 | 153 | async sheet() { 154 | const sheet = new Sheet() 155 | sheet.setView(this.getListView()).addNavBar({ 156 | title: $l10n("PIN_ACTION"), 157 | popButton: { title: $l10n("DONE") }, 158 | rightButtons: this.getNavButtons() 159 | }) 160 | 161 | sheet.init().present() 162 | } 163 | } 164 | 165 | module.exports = { 166 | KeyboardAddins, 167 | KeyboardPinActions 168 | } 169 | -------------------------------------------------------------------------------- /scripts/app-main.js: -------------------------------------------------------------------------------- 1 | const { UIKit, ViewController, TabBarController, FileManager } = require("./libs/easy-jsbox") 2 | const { AppKernelBase } = require("./app") 3 | 4 | const compatibility = require("./compatibility") 5 | const settingMethods = require("./setting/setting-methods") 6 | 7 | /** 8 | * @typedef {AppKernel} AppKernel 9 | */ 10 | class AppKernel extends AppKernelBase { 11 | constructor() { 12 | super() 13 | this.query = $context.query 14 | 15 | settingMethods(this) 16 | 17 | this.fileManager = new FileManager() 18 | } 19 | } 20 | 21 | class AppUI { 22 | static kernel = new AppKernel() 23 | 24 | static renderMainUI() { 25 | const buttons = { 26 | clips: { icon: "doc.on.clipboard.fill", title: $l10n("CLIPS") }, 27 | actions: { icon: "command", title: $l10n("ACTIONS") }, 28 | setting: { icon: "gear", title: $l10n("SETTING") } 29 | } 30 | 31 | // this.kernel.useJsboxNav() 32 | // this.kernel.setting.useJsboxNav() 33 | // this.kernel.fileManager.setViewController(new ViewController()) 34 | // this.kernel.tabBarController = new TabBarController() 35 | // const clipsdNavigationView = this.kernel.clips.getNavigationView() 36 | // this.kernel.tabBarController 37 | // .setPages({ 38 | // clips: clipsdNavigationView.getPage(), 39 | // actions: this.kernel.actions.getPage(), 40 | // setting: this.kernel.setting.getPage() 41 | // }) 42 | // .setCells({ 43 | // clips: buttons.clips, 44 | // actions: buttons.actions, 45 | // setting: buttons.setting 46 | // }) 47 | 48 | // $define({ 49 | // type: "ViewController: UIViewController", 50 | // events: { 51 | // viewDidLoad: () => { 52 | // console.log("viewDidLoad") 53 | // self.$super().$viewDidLoad() 54 | 55 | // //const view = this.kernel.tabBarController.generateView().definition 56 | // const view = this.kernel.clips.getListView() 57 | // self.$view().jsValue().add(view) 58 | 59 | // const navigationItem = self.$navigationItem() 60 | // navigationItem.$setTitle("CAIO") 61 | // navigationItem.$setLargeTitleDisplayMode(0) 62 | 63 | // const leftButton = $objc("UIBarButtonItem").$alloc() 64 | // leftButton.$initWithTitle_style_target_action("Close", 0, self, "backButtonPressed") 65 | 66 | // navigationItem.$setLeftBarButtonItem(leftButton) 67 | // }, 68 | // backButtonPressed: () => { 69 | // self.$dismissViewControllerAnimated_completion(true, null) 70 | // } 71 | // } 72 | // }) 73 | 74 | // const render = sender => { 75 | // const myVC = $objc("ViewController").$alloc().$init() 76 | // const navigator = $objc("UINavigationController").$alloc().$initWithRootViewController(myVC) 77 | // navigator.$setModalPresentationStyle(0) 78 | // this.kernel.navigator = navigator 79 | 80 | // const navigationBar = navigator.$navigationBar() 81 | // navigationBar.$setPrefersLargeTitles(true) 82 | // const image = $objc("UIImage").$imageWithColor($color("clear").ocValue()) 83 | // navigationBar.$setBackgroundImage_forBarPosition_barMetrics(image, 0, 0) 84 | 85 | // $ui.vc.ocValue().invoke("presentViewController:animated:completion:", navigator, true, null) 86 | // } 87 | // render() 88 | // return 89 | 90 | if (UIKit.isTaio || this.kernel.setting.get("mainUIDisplayMode") === 0) { 91 | this.kernel.useJsboxNav() 92 | this.kernel.setting.useJsboxNav() 93 | this.kernel.setNavButtons([ 94 | { 95 | symbol: buttons.setting.icon, 96 | title: buttons.setting.title, 97 | handler: () => { 98 | UIKit.push({ 99 | title: buttons.setting.title, 100 | views: [this.kernel.setting.getListView()] 101 | }) 102 | } 103 | }, 104 | { 105 | symbol: buttons.actions.icon, 106 | title: buttons.actions.title, 107 | handler: () => { 108 | this.kernel.actions.present() 109 | } 110 | } 111 | ]) 112 | 113 | this.kernel.UIRender(this.kernel.clips.getNavigationView().getPage()) 114 | } else { 115 | this.kernel.fileManager.setViewController(new ViewController()) 116 | 117 | this.kernel.tabBarController = new TabBarController() 118 | 119 | const clipsdNavigationView = this.kernel.clips.getNavigationView() 120 | 121 | this.kernel.tabBarController 122 | .setPages({ 123 | clips: clipsdNavigationView.getPage(), 124 | actions: this.kernel.actions.getPage(), 125 | setting: this.kernel.setting.getPage() 126 | }) 127 | .setCells({ 128 | clips: buttons.clips, 129 | actions: buttons.actions, 130 | setting: buttons.setting 131 | }) 132 | 133 | this.kernel.UIRender(this.kernel.tabBarController.generateView().definition) 134 | } 135 | } 136 | } 137 | 138 | module.exports = { 139 | run: () => { 140 | // 兼容性操作 141 | compatibility(AppUI.kernel) 142 | 143 | AppUI.renderMainUI() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /scripts/ui/clips/search.js: -------------------------------------------------------------------------------- 1 | const { UIKit, SearchBar } = require("../../libs/easy-jsbox") 2 | 3 | /** 4 | * @typedef {import("../../app-main").AppKernel} AppKernel 5 | */ 6 | 7 | class ClipsSearch { 8 | listId = $text.uuid 9 | 10 | /** 11 | * @type {AppKernel} 12 | */ 13 | kernel 14 | callback = () => {} 15 | onBegin = () => {} 16 | onDismiss = () => {} 17 | 18 | /** 19 | * @param {AppKernel} kernel 20 | */ 21 | constructor(kernel) { 22 | this.kernel = kernel 23 | 24 | this.searchBar = new SearchBar() 25 | this.searchBarId = this.searchBar.id + "-input" 26 | } 27 | 28 | get searchHistoryView() { 29 | return { 30 | hide: () => ($(this.listId + "-history").hidden = true), 31 | show: () => ($(this.listId + "-history").hidden = false) 32 | } 33 | } 34 | 35 | get searchHistory() { 36 | return [ 37 | { 38 | title: $l10n("SEARCH_HISTORY"), 39 | rows: $cache.get("caio.search.history")?.reverse() ?? [] 40 | } 41 | ] 42 | } 43 | 44 | getAccessoryView() { 45 | return UIKit.blurBox({ height: 50 }, [ 46 | { 47 | type: "button", 48 | props: { 49 | bgcolor: $color("clear"), 50 | tintColor: $color("primaryText"), 51 | symbol: "xmark.circle" 52 | }, 53 | layout: (make, view) => { 54 | make.right.inset(0) 55 | make.height.equalTo(view.super) 56 | make.width.equalTo(view.super.height) 57 | }, 58 | events: { 59 | tapped: () => this.dismiss() 60 | } 61 | }, 62 | { 63 | type: "button", 64 | props: { 65 | bgcolor: $color("clear"), 66 | tintColor: $color("primaryText"), 67 | symbol: "keyboard.chevron.compact.down" 68 | }, 69 | layout: (make, view) => { 70 | make.right.equalTo(view.prev.left) 71 | make.height.equalTo(view.super) 72 | make.width.equalTo(view.super.height) 73 | }, 74 | events: { 75 | tapped: () => $(this.searchBarId).blur() 76 | } 77 | } 78 | ]) 79 | } 80 | 81 | getSearchHistoryView() { 82 | return { 83 | type: "list", 84 | props: { 85 | id: this.listId + "-history", 86 | hidden: true, 87 | stickyHeader: true, 88 | data: this.searchHistory, 89 | separatorInset: $insets(0, 13, 0, 0), 90 | actions: [ 91 | { 92 | title: $l10n("DELETE"), 93 | handler: (sender, indexPath) => { 94 | const data = sender.data 95 | this.updateSearchHistory(data[0].rows.reverse()) 96 | } 97 | } 98 | ] 99 | }, 100 | events: { 101 | didSelect: (sender, indexPath, data) => { 102 | this.searchAction(data) 103 | $(this.searchBarId).text = data 104 | } 105 | }, 106 | layout: $layout.fill 107 | } 108 | } 109 | 110 | setCallback(callback) { 111 | this.callback = callback 112 | } 113 | 114 | setOnBegin(callback) { 115 | this.onBegin = callback 116 | } 117 | 118 | setOnDismiss(callback) { 119 | this.onDismiss = callback 120 | } 121 | 122 | begin() { 123 | this.searchHistoryView.show() 124 | this.onBegin() 125 | } 126 | 127 | dismiss() { 128 | this.searchBar.cancel() 129 | } 130 | 131 | async searchAction(text) { 132 | try { 133 | if (text !== "") { 134 | let searchReturn, 135 | isTagKeyword = text.startsWith("#") 136 | if (isTagKeyword) { 137 | searchReturn = this.kernel.storage.searchByTag(text.substring(1)) 138 | } else { 139 | searchReturn = await this.kernel.storage.search(text) 140 | } 141 | const { result, keyword } = searchReturn 142 | if (result && result.length > 0) { 143 | $(this.searchBarId).blur() 144 | this.callback({ keyword, result, isTagKeyword }) 145 | } else { 146 | $ui.toast($l10n("NO_SEARCH_RESULT")) 147 | } 148 | // history 149 | this.pushSearchHistory(text) 150 | } 151 | } catch (error) { 152 | throw error 153 | } 154 | } 155 | 156 | pushSearchHistory(text) { 157 | let history = $cache.get("caio.search.history") ?? [] 158 | if (history.indexOf(text) === -1) { 159 | history.push(text) 160 | if (history.length > 20) { 161 | history = history.slice(-20) 162 | } 163 | $cache.set("caio.search.history", history) 164 | $(this.listId + "-history").data = this.searchHistory 165 | } 166 | } 167 | 168 | updateSearchHistory(data = []) { 169 | $cache.set("caio.search.history", data) 170 | } 171 | 172 | getSearchBarView() { 173 | // 初始化搜索功能 174 | this.searchBar.controller.setEvent("onReturn", text => { 175 | if (text !== "") { 176 | this.searchAction(text) 177 | } else { 178 | this.searchHistoryView.show() 179 | } 180 | }) 181 | this.searchBar.controller.setEvent("onChange", text => { 182 | if (text === "") this.searchHistoryView.show() 183 | }) 184 | this.searchBar.controller.setEvent("onBeginEditing", text => { 185 | if (text === "") this.begin() 186 | }) 187 | this.searchBar.controller.setEvent("onCancel", () => { 188 | this.searchHistoryView.hide() 189 | this.onDismiss() 190 | }) 191 | 192 | this.searchBar.setAccessoryView(this.getAccessoryView()) 193 | 194 | return this.searchBar 195 | } 196 | } 197 | 198 | module.exports = ClipsSearch 199 | -------------------------------------------------------------------------------- /scripts/dao/webdav-sync.js: -------------------------------------------------------------------------------- 1 | const { WebDAV } = require("../libs/easy-jsbox") 2 | 3 | /** 4 | * @typedef {import("../app-main").AppKernel} AppKernel 5 | */ 6 | 7 | class WebDavSync { 8 | static step = { 9 | init: -1, 10 | stay: 0, 11 | needPush: 2, 12 | needPull: 3, 13 | conflict: 4 14 | } 15 | static stepName = (() => { 16 | const map = {} 17 | Object.keys(WebDavSync.step).map(key => { 18 | const value = WebDavSync.step[key] 19 | map[value] = String(key) 20 | }) 21 | return map 22 | })() 23 | static status = { 24 | syncing: 0, 25 | success: 1, 26 | nochange: 2, 27 | fail: 3 28 | } 29 | static conflictKeep = { 30 | local: 0, 31 | webdav: 1, 32 | cancel: 2 33 | } 34 | static initLocalTimestamp = 0 35 | 36 | /** 37 | * @type {WebDAV} 38 | */ 39 | webdav 40 | /** 41 | * @type {AppKernel} 42 | */ 43 | kernel 44 | 45 | localSyncDataPath 46 | webdavSyncDataPath 47 | 48 | constructor({ host, user, password, basepath, kernel } = {}) { 49 | this.kernel = kernel 50 | this.webdav = new WebDAV({ host, user, password, basepath }) 51 | this.webdav.namespace = "JSBox.CAIO" 52 | } 53 | 54 | get mustLocalSyncDataPath() { 55 | if (!this.localSyncDataPath) { 56 | throw new Error("localSyncDataPath not set") 57 | } 58 | return this.localSyncDataPath 59 | } 60 | get mustWebdavSyncDataPath() { 61 | if (!this.webdavSyncDataPath) { 62 | throw new Error("webdavSyncDataPath not set") 63 | } 64 | return this.webdavSyncDataPath 65 | } 66 | 67 | get localSyncData() { 68 | if (!this.kernel.fileStorage.exists(this.mustLocalSyncDataPath)) { 69 | this.localSyncData = { timestamp: WebDavSync.initLocalTimestamp } 70 | } 71 | return this.kernel.fileStorage.readSync(this.mustLocalSyncDataPath) 72 | } 73 | set localSyncData(data) { 74 | this.kernel.fileStorage.writeSync( 75 | this.mustLocalSyncDataPath, 76 | $data({ 77 | string: JSON.stringify(data) 78 | }) 79 | ) 80 | } 81 | get localTimestamp() { 82 | return Number(JSON.parse(this.localSyncData.string).timestamp) 83 | } 84 | set localTimestamp(timestamp) { 85 | this.localSyncData = Object.assign(this.localSyncData, { timestamp }) 86 | } 87 | 88 | /** 89 | * 90 | * @param {string} path 91 | */ 92 | async init(path = this.webdav.basepath) { 93 | const webdav = new WebDAV({ 94 | host: this.webdav.host, 95 | user: this.webdav.user, 96 | password: this.webdav.password, 97 | basepath: path 98 | }) 99 | let exists = await webdav.exists("/") 100 | if (!exists) { 101 | try { 102 | await webdav.mkdir("/") 103 | } catch (error) { 104 | if (error.code === 409) { 105 | // 递归创建目录 106 | await this.init(path.substring(0, path.lastIndexOf("/"))) 107 | await this.init(path) 108 | return 109 | } 110 | throw error 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * 要同步的数据是否是刚刚初始化的(无内容)状态 117 | * @returns {boolean} 118 | */ 119 | isNew() { 120 | return false 121 | } 122 | 123 | async webdavSyncData() { 124 | this.webdav.clearNSURLCache() 125 | let resp = await this.webdav.get(this.mustWebdavSyncDataPath) 126 | let syncData = resp.data 127 | if (typeof syncData === "string") { 128 | syncData = JSON.parse(syncData) 129 | } 130 | return syncData 131 | } 132 | async webdavTimestamp() { 133 | try { 134 | let syncData = await this.webdavSyncData() 135 | return Number(syncData.timestamp) 136 | } catch (error) { 137 | if (error.code === 404) { 138 | return WebDavSync.step.init 139 | } 140 | throw error 141 | } 142 | } 143 | 144 | updateLocalTimestamp() { 145 | if (this.localTimestamp === WebDavSync.initLocalTimestamp) { 146 | return 147 | } 148 | this.localTimestamp = Date.now() 149 | } 150 | 151 | async uploadSyncData() { 152 | if (this.localTimestamp === WebDavSync.initLocalTimestamp) { 153 | this.localTimestamp = Date.now() 154 | } 155 | await this.webdav.put(this.mustWebdavSyncDataPath, this.localSyncData) 156 | } 157 | async downloadSyncData() { 158 | this.localSyncData = await this.webdavSyncData() 159 | } 160 | 161 | async nextSyncStep() { 162 | const localTimestamp = this.localTimestamp 163 | const webdavTimestamp = await this.webdavTimestamp() 164 | if (webdavTimestamp === WebDavSync.step.init) { 165 | return WebDavSync.step.init 166 | } 167 | 168 | if (webdavTimestamp === WebDavSync.initLocalTimestamp) { 169 | // 重置 webdav 数据 170 | this.localTimestamp = Date.now() 171 | await this.push() 172 | return WebDavSync.step.stay 173 | } 174 | 175 | if (localTimestamp > webdavTimestamp) { 176 | return WebDavSync.step.needPush 177 | } 178 | if (localTimestamp === webdavTimestamp) { 179 | return WebDavSync.step.stay 180 | } 181 | if (localTimestamp < webdavTimestamp) { 182 | // WebDAV 有数据,本地 sync 时间戳为 0,发生数据冲突 183 | if (!this.isNew() && localTimestamp === WebDavSync.initLocalTimestamp) { 184 | return WebDavSync.step.conflict 185 | } 186 | return WebDavSync.step.needPull 187 | } 188 | } 189 | 190 | async conflict(name = "") { 191 | const actions = [] 192 | actions[WebDavSync.conflictKeep.local] = $l10n("LOCAL_DATA") 193 | actions[WebDavSync.conflictKeep.webdav] = $l10n("WEBDAV_DATA") 194 | actions[WebDavSync.conflictKeep.cancel] = $l10n("CANCEL") 195 | const resp = await $ui.alert({ 196 | title: $l10n("DATA_CONFLICT"), 197 | message: $l10n("DATA_CONFLICT_MESSAGE") + ` (${name})`, 198 | actions 199 | }) 200 | if (resp.index !== WebDavSync.conflictKeep.cancel) { 201 | if (resp.index === WebDavSync.conflictKeep.local) { 202 | this.kernel.logger.info(`conflict resolve: keep local database`) 203 | await this.push() 204 | } else { 205 | this.kernel.logger.info(`conflict resolve: keep WebDAV database`) 206 | await this.pull() 207 | } 208 | } 209 | return resp.index 210 | } 211 | } 212 | 213 | module.exports = WebDavSync 214 | -------------------------------------------------------------------------------- /scripts/ui/components/editor.js: -------------------------------------------------------------------------------- 1 | const { UIKit, NavigationBar, NavigationView, Sheet } = require("../../libs/easy-jsbox") 2 | const { ActionEnv } = require("../../action/action") 3 | 4 | /** 5 | * @typedef {import("../../app-main").AppKernel} AppKernel 6 | */ 7 | 8 | class Editor { 9 | #text 10 | originalContent // 原始数据 11 | 12 | /** 13 | * @param {AppKernel} kernel 14 | */ 15 | constructor(kernel) { 16 | this.kernel = kernel 17 | this.id = "editor" 18 | } 19 | 20 | /** 21 | * 编辑器内容 22 | * @param {string} text 23 | */ 24 | set text(text = "") { 25 | if (this.originalContent === undefined) { 26 | // 原始内容 27 | this.originalContent = text 28 | } 29 | this.#text = text 30 | } 31 | 32 | get text() { 33 | return this.#text ?? "" 34 | } 35 | 36 | getActionButton() { 37 | return { 38 | symbol: "bolt.circle", 39 | tapped: (sender, senderMaybe) => { 40 | // senderMaybe 处理 Sheet addNavBar 中的按钮 41 | if (senderMaybe) sender = senderMaybe 42 | const actionData = { 43 | env: ActionEnv.editor, 44 | editor: { 45 | originalContent: this.originalContent, 46 | setContent: text => this.setContent(text) 47 | }, 48 | text: () => this.text, 49 | selectedText: () => { 50 | const range = $(this.id).selectedRange 51 | this.text.slice(range.location, range.location + range.length) 52 | }, 53 | selectedRange: () => $(this.id).selectedRange 54 | } 55 | const popover = $ui.popover({ 56 | sourceView: sender, 57 | directions: $popoverDirection.up, 58 | size: $size(200, 300), 59 | views: [ 60 | this.kernel.actions.views.getActionListView(action => { 61 | //popover.dismiss() 62 | $delay(0.5, () => action(actionData)) 63 | }) 64 | ] 65 | }) 66 | } 67 | } 68 | } 69 | 70 | setContent(text) { 71 | this.text = text 72 | $(this.id).text = text 73 | } 74 | 75 | getView(type = "text") { 76 | return { 77 | type: type, 78 | layout: $layout.fill, 79 | props: { 80 | id: this.id, 81 | lineNumbers: this.kernel.setting.get("editor.code.lineNumbers"), // 放在此处动态获取设置的更改 82 | theme: this.kernel.setting.get($device.isDarkMode ? "editor.code.darkTheme" : "editor.code.lightTheme"), 83 | text: this.text, 84 | insets: $insets(15, 15, type === "text" ? this.kernel.setting.get("editor.text.insets") : 15, 15) 85 | }, 86 | events: { 87 | ready: sender => { 88 | if (this.text === "") 89 | // 自动弹出键盘 90 | $delay(0.5, () => sender.focus()) 91 | }, 92 | didChange: sender => { 93 | this.text = sender.text 94 | } 95 | } 96 | } 97 | } 98 | 99 | pageSheet(text = "", callback, title, navButtons = [], type = "text") { 100 | this.text = text 101 | navButtons.unshift(this.getActionButton()) 102 | const sheet = new Sheet() 103 | sheet 104 | .setView(this.getView(type)) 105 | .addNavBar({ 106 | title, 107 | popButton: { 108 | title: $l10n("DONE"), 109 | tapped: () => callback(this.text) 110 | }, 111 | rightButtons: navButtons 112 | }) 113 | .init() 114 | 115 | sheet.navigationView.navigationBar.contentViewHeightOffset = 0 116 | sheet.present() 117 | } 118 | 119 | // getViewController(type, callback) { 120 | // $define({ 121 | // type: "EditorViewController: UIViewController", 122 | // events: { 123 | // viewDidLoad: () => { 124 | // self.$super().$viewDidLoad() 125 | 126 | // self.$view().jsValue().add(this.getView(type)) 127 | 128 | // const navigationItem = self.$navigationItem() 129 | // navigationItem.$setLargeTitleDisplayMode(2) 130 | // }, 131 | // "viewDidDisappear:": animated => { 132 | // callback() 133 | // } 134 | // } 135 | // }) 136 | // return $objc("EditorViewController").$new() 137 | // } 138 | 139 | /** 140 | * 141 | * @param {*} text 142 | * @param {*} callback 143 | * @param {Array} navButtons 可通过 Editor.text 属性访问内容,如 editor.text 144 | * @param {*} type 145 | */ 146 | uikitPush(text = "", callback, navButtons = [], type = "text") { 147 | this.text = text 148 | navButtons.unshift(this.getActionButton()) 149 | 150 | // this.kernel.navigator.$pushViewController_animated( 151 | // this.getViewController(type, () => callback(this.text)), 152 | // true 153 | // ) 154 | // return 155 | 156 | UIKit.push({ 157 | title: "", 158 | navButtons: navButtons.map(button => { 159 | button.handler = button.tapped 160 | button.tapped = undefined 161 | return button 162 | }), 163 | views: [this.getView(type)], 164 | // dealloc: () => callback(this.text), 165 | disappeared: () => callback(this.text) 166 | }) 167 | } 168 | 169 | /** 170 | * 171 | * @param {*} text 172 | * @param {*} callback 173 | * @param {Array} navButtons 可通过 Editor.text 属性访问内容,如 editor.text 174 | * @param {*} type 175 | */ 176 | getNavigationView(text = "", callback, navButtons = [], type = "text") { 177 | this.text = text 178 | navButtons.unshift(this.getActionButton()) 179 | 180 | const navigationView = new NavigationView() 181 | navigationView.navigationBar.contentViewHeightOffset = 0 182 | navigationView.navigationBar.setLargeTitleDisplayMode(NavigationBar.largeTitleDisplayModeNever) 183 | navigationView.navigationBarItems.setRightButtons(navButtons) 184 | navigationView.setView(this.getView(type)).navigationBarTitle("") 185 | navigationView.setEvent("onPop", () => callback(this.text)) 186 | 187 | return navigationView 188 | } 189 | } 190 | 191 | module.exports = Editor 192 | -------------------------------------------------------------------------------- /strings/en.strings: -------------------------------------------------------------------------------- 1 | "AUTHOR" = "Author"; 2 | "ALERT_INFO" = "Alert"; 3 | "NONE" = "Nothing"; 4 | "DONE" = "Done"; 5 | "CLOSE" = "Close"; 6 | "FAILED_TO_LOAD_VIEW" = "Faild to load view"; 7 | "VIEW_NOT_PROVIDED" = "The view is not provided"; 8 | "UNCATEGORIZED" = "Uncategorized"; 9 | "SHARE" = "Share"; 10 | 11 | "CLICK_TO_OPEN_JSBOX" = "Click the title to open the main app."; 12 | 13 | "CLIPS" = "Clips"; 14 | "CLIPBOARD" = "Clipboard"; 15 | "UNIVERSAL_CLIPBOARD" = "Universal Clipboard"; 16 | "UNIVERSAL_CLIPBOARD_TIPS" = "Universal Clipboard allows you to copy something on your iPhone, and paste it on your Mac–or vice-versa–using iCloud."; 17 | "CLIPS_STRUCTURE_ERROR" = "Clips data structure is abnormal"; 18 | "CLIPBOARD_NO_CHANGE" = "Clipboard no change"; 19 | "RECYCLE_BIN" = "Recycle Bin"; 20 | "ADD" = "Add"; 21 | "TAG" = "Tag"; 22 | "ADD_TAG" = "Add Tag"; 23 | "EDIT" = "Edit"; 24 | "SEARCH" = "Search"; 25 | "SEARCH_HISTORY" = "Search History"; 26 | "SEARCH_RESULT" = "Search Result"; 27 | "NO_SEARCH_RESULT" = "No item found."; 28 | "FAVORITE" = "Favorite"; 29 | "COPY" = "Copy"; 30 | "COPIED" = "Copied"; 31 | "SORT" = "Sort"; 32 | "ACTIONS" = "Actions"; 33 | "MORE_ACTIONS" = "More Actions"; 34 | "PREVIEW" = "Preview"; 35 | "MAX_ITEM_LENGTH" = "Line Limit"; 36 | "TEXT_MAX_LENGTH" = "Display Character Length"; 37 | "AUTO_SAVE" = "Auto Save"; 38 | "AUTO_SYNC" = "Auto Sync"; 39 | "SYNC_NOW" = "Sync Now"; 40 | "UNZIP_FAILED" = "Unzip file failed"; 41 | "REBUILD" = "Rebuild"; 42 | "REBUILD_DATABASE" = "Rebuild Database"; 43 | "REBUILD_DATABASE_ALERT" = "Rebuilding the database will lose the order information, do you want to confirm the rebuild?"; 44 | "DELETE_ALL_DATA" = "Delete All Data"; 45 | "DELETE_ALL_DATA_ALERT" = "Are you sure you want to delete all your data?"; 46 | "DELETE_DATA" = "Delete Data"; 47 | "DELETE_TABLE" = "Delete all data from `${table}`?"; 48 | "SELECT_ALL" = "Select All"; 49 | "DESELECT_ALL" = "Deselect All"; 50 | 51 | "EDITOR" = "Editor"; 52 | "CREATE_NEW" = "Create New"; 53 | "CREATE_NEW_ACTION" = "New Action"; 54 | "CREATE_NEW_TYPE" = "New Category"; 55 | "TYPE_ALREADY_EXISTS" = "This category already exists"; 56 | "EDIT_DETAILS" = "Edit Details"; 57 | "EDIT_SCRIPT" = "Edit Script"; 58 | "INFORMATION" = "Information"; 59 | "NAME" = "Name"; 60 | "ICON" = "Icon"; 61 | "CATEGORY" = "Category"; 62 | "EDIT_CATEGORY" = "Edit Category"; 63 | "delete.category" = "Delete Category ${category}"; 64 | "delete.category.keep.actions" = "Do you want to keep the actions in this category?"; 65 | "DESCRIPTION" = "Description"; 66 | "CODE" = "Code"; 67 | "TEXT_INSETS" = "Text bottom margin"; 68 | "SHOW_LINE_NUMBER" = "Show line number"; 69 | "LIGHT_MODE_THEME" = "Light Mode Theme"; 70 | "DARK_MODE_THEME" = "Dark Mode Theme"; 71 | 72 | "SAVE" = "Save"; 73 | "SAVE_SUCCESS" = "Save success"; 74 | "SAVE_ERROR" = "Save failed"; 75 | "DELETE" = "Delete"; 76 | "CONFIRM" = "Confirm"; 77 | "DELETE_CONFIRM_MSG" = "Are you sure you want to delete?"; 78 | "DELETE_SUCCESS" = "Delete success"; 79 | "DELETE_ERROR" = "Delete failed"; 80 | 81 | "IMPORT_EXAMPLE_ACTIONS" = "Import example actions"; 82 | "REBUILD_ACTION_DATABASE" = "Rebuild Action Database"; 83 | "REBUILD_ACTION_DATABASE_ALERT_TITLE" = "Are you sure you want to rebuild?"; 84 | "REBUILD_ACTION_DATABASE_ALERT_MESSAGE" = "Rebuild also deletes the data saved in WebDAV Drive! (If enabled)"; 85 | "EXPORT" = "Export"; 86 | "IMPORT" = "Import"; 87 | "FILE_TYPE_ERROR" = "File type does not match"; 88 | "OVERWRITE_ALERT" = "This operation will overwrite the current data. Do you want to continue?"; 89 | "UNABLE_CREATE_ACTION" = "Unable to create action"; 90 | "ACTION_NAME_ALREADY_EXISTS" = "Action `${name}` already exists"; 91 | "IMPORT_FROM_FILE" = "Import from Files"; 92 | "DEIT_CATEGORY" = "Edit Category"; 93 | 94 | "KEYBOARD" = "Keyboard"; 95 | "KEYBOARD_HEIGHT" = "Keyboard Height"; 96 | "USE_BLUR" = "Use Blur"; 97 | "BACKGROUND_IMAGE" = "Background Image"; 98 | "DELETE_DELAY" = "Delete Delay"; 99 | "SWITCH_AFTER_INSERT" = "Switch After Insert"; 100 | "JSBOX_TOOLBAR" = "JSBox Toolbar"; 101 | "QUICK_START_SCRIPTS" = "Quick Start Scripts"; 102 | "SEND" = "Send"; 103 | "OPEN_IN_JSBOX" = "Open in JSBox"; 104 | "SWITCH_KEYBOARD" = "Switch Keyboard"; 105 | "TAPTIC_ENGINE" = "Taptic Engine"; 106 | "TAPTIC_ENGINE_LEVEL" = "Taptic Engine Level"; 107 | "TAPTIC_ENGINE_FOR_DELETE" = "Taptic Engine For Delete"; 108 | "SPACE" = "Space"; 109 | "ALL_SCRIPTS" = "All Scripts"; 110 | "SELECT_SCRIPTS" = "Select Scripts"; 111 | "FONT_SIZE" = "Font Size"; 112 | "LIST" = "List"; 113 | "MATRIX" = "Matrix"; 114 | "PIN_ACTION" = "Pin Action"; 115 | "PIN_ACTION" = "Pin Action"; 116 | "keyboard.excludePin" = "Exclude Pinned from List"; 117 | "RUN_DIRECTLY" = "Run Scripts Directly"; 118 | 119 | "CHECK_UPDATE" = "Check Update"; 120 | "UPDATE" = "Update"; 121 | 122 | "WIDGET" = "Widget"; 123 | "RECENT" = "Recent"; 124 | "CLICK_ACTION" = "Click Action"; 125 | 126 | "TODAY_WIDGET" = "Today Widget"; 127 | "PREV_PAGE" = "Prev"; 128 | "NEXT_PAGE" = "Next"; 129 | 130 | "DISPLAY_MODE" = "Display Mode"; 131 | "CLASSIC" = "Classic"; 132 | "MODERN" = "Modern"; 133 | 134 | "FILE_MANAGEMENT" = "File Management"; 135 | 136 | "compatibility.rebuildUserAction.alert.title" = "We need to rebuild some of the action!"; 137 | "compatibility.rebuildUserAction.alert.message" = "If you tap the OK button, the following actions will be rebuilt:"; 138 | "compatibility.rebuildUserAction.alert.message2" = "Only the action logic will be changed, and the name and icon will remain as it is."; 139 | 140 | "EXPERIMENTAL" = "Experimental"; 141 | "SYNC_ACTIONS" = "Sync Actions"; 142 | "SYNCING" = "Syncing..."; 143 | "LAST_SYNC_AT" = "Last sync at: "; 144 | "MODIFIED" = "Modified: "; 145 | "WEBDAV_ERROR_CLOSED" = "WebDAV sync has an error and is temporarily closed."; 146 | 147 | "HOST" = "Host"; 148 | "USER" = "User"; 149 | "PASSWORD" = "Password"; 150 | "BASEPATH" = "Base Path"; 151 | "DATA_CONFLICT" = "A data synchronization conflict occurred"; 152 | "DATA_CONFLICT_MESSAGE" = "Select the data you want to keep"; 153 | "WEBDAV_DATA" = "WebDAV data"; 154 | "LOCAL_DATA" = "Local data"; 155 | "ADD_TO_TAIO" = "Add to Taio"; 156 | "SELECT_TAIO_APP" = "Please select Taio App from the Share menu."; 157 | 158 | "ACTION_SAFETY_WARNING" = "Action safety warning"; 159 | "ACTION_PERMISSION_REQUEST" = "Action permission request"; 160 | 161 | "ACTION_RESET_NAME_WARNING" = "Action `${name}` is attempting to change its name to `${to_name}`, which may result in it gaining all permissions associated with `${to_name}`."; 162 | "ACTION_NETWORK_PERMISSION_MESSAGE" = "Would you like to grant Action `${name}` network permissions?"; 163 | 164 | "Return" = "Return"; 165 | "Go" = "Go"; 166 | "Google" = "Google"; 167 | "Join" = "Join"; 168 | "Next" = "Next"; 169 | "Route" = "Route"; 170 | "Search" = "Search"; 171 | "Send" = "Send"; 172 | "Yahoo" = "Yahoo"; 173 | "Done" = "Done"; 174 | "Emergency Call" = "Emergency Call"; 175 | "Continue" = "Continue"; 176 | "Joining" = "Joining"; 177 | "Route Continue" = "Route Continue"; -------------------------------------------------------------------------------- /scripts/dao/webdav-sync-clip.js: -------------------------------------------------------------------------------- 1 | const { FileStorage } = require("../libs/easy-jsbox") 2 | const WebDavSync = require("./webdav-sync") 3 | 4 | /** 5 | * @typedef {import("../app-main").AppKernel} AppKernel 6 | */ 7 | 8 | class WebDavSyncClip extends WebDavSync { 9 | localSyncDataPath = "/sync.json" 10 | webdavSyncDataPath = "/sync.json" 11 | 12 | webdavDbPath = "/CAIO.db" 13 | webdavImagePath = "/image" 14 | get localDb() { 15 | return $data({ path: this.kernel.fileStorage.filePath(this.kernel.storage.localDb) }) 16 | } 17 | set localDb(data) { 18 | this.kernel.fileStorage.writeSync(this.kernel.storage.localDb, data) 19 | } 20 | get localImagePath() { 21 | return this.kernel.fileStorage.filePath("/image") 22 | } 23 | 24 | async init() { 25 | await super.init() 26 | await this.initImagePath() 27 | this.sync() 28 | } 29 | 30 | async initImagePath() { 31 | let exists = await this.webdav.exists(this.webdavImagePath) 32 | if (!exists) { 33 | await this.webdav.mkdir(this.webdavImagePath) 34 | const initDir = ["/image/original", "/image/preview"] 35 | initDir.map(async item => { 36 | let exists = await this.webdav.exists(item) 37 | if (!exists) { 38 | await this.webdav.mkdir(item) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | isNew() { 45 | return this.kernel.storage.isEmpty() 46 | } 47 | 48 | async webdavImages() { 49 | const resp = await this.webdav.ls(this.webdavImagePath, "infinity") 50 | const rootElement = resp.rootElement 51 | const baseUrl = rootElement.firstChild({ xPath: "//D:response/D:href" }).string 52 | const original = [], 53 | preview = [] 54 | rootElement.children().map(item => { 55 | /** 56 | * @type {string} 57 | */ 58 | const href = item.firstChild({ tag: "href" })?.string?.replaceAll(baseUrl, "") 59 | if (href.endsWith("/")) return 60 | if (href.startsWith("original")) { 61 | original.push(href.substring(9)) 62 | } else if (href.startsWith("preview")) { 63 | preview.push(href.substring(8)) 64 | } 65 | }) 66 | return { original, preview } 67 | } 68 | 69 | async syncImages() { 70 | const webdavImages = await this.webdavImages() 71 | const dbImages = this.kernel.storage.allImageFromDb(false) 72 | const localImages = this.kernel.storage.localImagesFromFile() 73 | // 删除本地多余图片 74 | Object.keys(localImages).map(key => { 75 | localImages[key].forEach(async image => { 76 | if (!dbImages[key].includes(image)) { 77 | const localPath = FileStorage.join(this.kernel.storage.imagePath[key], image) 78 | this.kernel.fileStorage.delete(localPath) 79 | this.kernel.logger.info(`Local image deleted: ${localPath}`) 80 | } 81 | }) 82 | }) 83 | // 从 webdav 下载本地缺失图片或上传 webdav 缺失图片 84 | Object.keys(dbImages).map(key => { 85 | dbImages[key].forEach(async image => { 86 | const webdavPath = FileStorage.join(this.webdavImagePath, key, image) 87 | const localPath = FileStorage.join(this.kernel.storage.imagePath[key], image) 88 | if (webdavImages[key].includes(image) && !this.kernel.fileStorage.exists(localPath)) { 89 | const resp = await this.webdav.get(webdavPath) 90 | this.kernel.fileStorage.writeSync(localPath, resp.rawData) 91 | this.kernel.logger.info(`WebDAV image downloaded: ${localPath}`) 92 | } else if (!webdavImages[key].includes(image)) { 93 | const file = this.kernel.fileStorage.readSync(localPath) 94 | await this.webdav.put(webdavPath, file) 95 | this.kernel.logger.info(`WebDAV image uploaded: ${webdavPath}`) 96 | } 97 | }) 98 | }) 99 | // 遍历 webdavImages 删除 webdav 中本地没有的图片 100 | Object.keys(webdavImages).map(key => { 101 | webdavImages[key].forEach(async image => { 102 | if (!dbImages[key].includes(image)) { 103 | const webdavPath = FileStorage.join(this.webdavImagePath, key, image) 104 | await this.webdav.delete(webdavPath) 105 | this.kernel.logger.info(`WebDAV image deleted: ${webdavPath}`) 106 | } 107 | }) 108 | }) 109 | } 110 | 111 | async pull() { 112 | this.webdav.clearNSURLCache() 113 | let resp = await this.webdav.get(this.webdavDbPath) 114 | this.localDb = resp.rawData 115 | await this.downloadSyncData() 116 | 117 | this.kernel.storage.init() 118 | await this.syncImages() 119 | this.kernel.logger.info(`clip webdav sync: pulled`) 120 | } 121 | async push() { 122 | await this.webdav.put(this.webdavDbPath, this.localDb) 123 | await this.uploadSyncData() 124 | await this.syncImages() 125 | this.kernel.logger.info(`clip webdav sync: pushed`) 126 | } 127 | 128 | notify(option) { 129 | $app.notify({ 130 | name: "clipSyncStatus", 131 | object: option 132 | }) 133 | } 134 | 135 | async #sync() { 136 | let isPull = false 137 | try { 138 | const syncStep = await this.nextSyncStep() 139 | this.kernel.logger.info(`clip nextSyncStep: ${WebDavSync.stepName[syncStep]}`) 140 | if (syncStep === WebDavSync.step.needPush || syncStep === WebDavSync.step.init) { 141 | await this.push() 142 | } else if (syncStep === WebDavSync.step.needPull) { 143 | await this.pull() 144 | isPull = true 145 | } else if (syncStep === WebDavSync.step.conflict) { 146 | const resp = await this.conflict($l10n("CLIPS")) 147 | if (resp === WebDavSyncClip.conflictKeep.webdav) { 148 | isPull = true 149 | } else { 150 | return 151 | } 152 | } else { 153 | this.notify({ status: WebDavSync.status.nochange }) 154 | return 155 | } 156 | this.notify({ 157 | status: WebDavSync.status.success, 158 | updateList: isPull 159 | }) 160 | } catch (error) { 161 | this.notify({ 162 | status: WebDavSync.status.fail, 163 | error 164 | }) 165 | this.kernel.logger.error("clip sync error") 166 | this.kernel.logger.error(error) 167 | throw error 168 | } 169 | } 170 | 171 | sync() { 172 | this.notify({ status: WebDavSync.status.syncing, animate: true }) 173 | if (this.syncTimer) this.syncTimer.cancel() 174 | this.syncTimer = $delay(0.5, () => { 175 | this.#sync() 176 | }) 177 | } 178 | 179 | needUpload() { 180 | this.notify({ status: WebDavSync.status.syncing }) 181 | if (this.uploadTimer) this.uploadTimer.cancel() 182 | this.uploadTimer = $delay(0.5, () => { 183 | this.updateLocalTimestamp() 184 | this.#sync() 185 | }) 186 | } 187 | } 188 | 189 | module.exports = WebDavSyncClip 190 | -------------------------------------------------------------------------------- /scripts/widget/Actions.js: -------------------------------------------------------------------------------- 1 | const { TodayPinActions } = require("../ui/components/today-actions") 2 | 3 | /** 4 | * @typedef {import("../app-main").AppKernel} AppKernel 5 | */ 6 | class ActionsWidget { 7 | actions = [] 8 | 9 | /** 10 | * @param {AppKernel} kernel 11 | */ 12 | constructor(kernel = {}) { 13 | this.kernel = kernel 14 | 15 | this.actions = TodayPinActions.shared.setKernel(this.kernel).getActions(true) 16 | } 17 | 18 | get maxLength() { 19 | // require this.ctx, this.render() 20 | switch (this.ctx.family) { 21 | case 0: 22 | return 1 23 | case 1: 24 | return 4 25 | case 2: 26 | return 8 27 | } 28 | } 29 | 30 | get data() { 31 | // require this.maxLength 32 | return this.actions.slice(0, this.maxLength) 33 | } 34 | 35 | getIcon(action, size) { 36 | const view = { 37 | type: "image", 38 | props: { 39 | color: $color("#ffffff"), 40 | frame: { 41 | width: size, 42 | height: size 43 | }, 44 | resizable: true 45 | } 46 | } 47 | if (action.icon.startsWith("icon_")) { 48 | view.props.image = $icon( 49 | action.icon?.slice(5, action.icon.indexOf(".")), 50 | $color("#ffffff"), 51 | $size(size, size) 52 | ) 53 | .ocValue() 54 | .$image() 55 | .jsValue() 56 | } else if (action.icon.indexOf("/") > -1) { 57 | view.props.image = $image(action.icon) 58 | } else { 59 | view.props.symbol = { 60 | glyph: action.icon, 61 | size: size 62 | } 63 | } 64 | 65 | return view 66 | } 67 | 68 | view2x2() { 69 | const action = this.data[0] 70 | return { 71 | type: "vstack", 72 | props: { 73 | background: $color("primarySurface"), 74 | alignment: $widget.horizontalAlignment.leading, 75 | spacing: 0, 76 | padding: 20, 77 | widgetURL: this.kernel.actions.getActionURLScheme(action) 78 | }, 79 | views: [ 80 | { 81 | type: "hstack", 82 | props: { 83 | frame: { 84 | maxWidth: Infinity, 85 | alignment: $widget.alignment.leading 86 | } 87 | }, 88 | views: [ 89 | { 90 | type: "hstack", 91 | modifiers: [ 92 | { 93 | background: this.kernel.actions.views.getColor(action.color), 94 | frame: { 95 | width: 50, 96 | height: 50 97 | } 98 | }, 99 | { 100 | cornerRadius: { 101 | value: 15, 102 | style: 1 103 | } 104 | } 105 | ], 106 | views: [this.getIcon(action, 50 * 0.6)] 107 | } 108 | ] 109 | }, 110 | { type: "spacer" }, 111 | { 112 | // 底部 113 | type: "vstack", 114 | props: { 115 | frame: { 116 | maxWidth: Infinity, 117 | alignment: $widget.alignment.trailing 118 | }, 119 | spacing: 0 120 | }, 121 | views: [ 122 | { 123 | type: "text", 124 | props: { 125 | text: action.name, 126 | font: $font(18), 127 | bold: true, 128 | color: $color("primaryText") 129 | } 130 | } 131 | ] 132 | } 133 | ] 134 | } 135 | } 136 | 137 | view2x4() { 138 | const actions = this.data 139 | const length = actions.length 140 | const views = [] 141 | 142 | const height = this.ctx.displaySize.height 143 | const width = this.ctx.displaySize.width 144 | const padding = 15 145 | const innerPadding = 10 146 | const itemHeight = (height - padding * (this.maxLength / 2 + 1)) / (this.maxLength / 2) - innerPadding * 2 147 | const itemWidth = (width - padding * 3) / 2 - innerPadding * 2 148 | const r_outer = 15 149 | const r_inner = r_outer * ((itemHeight - innerPadding) / itemHeight) 150 | for (let i = 0; i < length; i += 2) { 151 | const row = [] 152 | for (let j = 0; j < 2; j++) { 153 | const action = actions[i + j] 154 | row.push({ 155 | type: "hstack", 156 | props: { spacing: 0 }, 157 | modifiers: [ 158 | { 159 | link: this.kernel.actions.getActionURLScheme(action), 160 | background: $color({ 161 | light: $rgb(245, 245, 245), 162 | dark: $rgba(80, 80, 80, 0.3), 163 | black: $rgba(70, 70, 70, 0.3) 164 | }), 165 | padding: innerPadding, 166 | frame: { 167 | maxWidth: itemWidth, 168 | maxHeight: itemHeight, 169 | alignment: $widget.alignment.leading 170 | } 171 | }, 172 | { 173 | cornerRadius: { 174 | value: r_outer, 175 | style: 1 176 | } 177 | } 178 | ], 179 | views: [ 180 | { 181 | type: "hstack", 182 | modifiers: [ 183 | { 184 | background: this.kernel.actions.views.getColor(action.color), 185 | frame: { 186 | width: itemHeight, 187 | height: itemHeight 188 | } 189 | }, 190 | { 191 | cornerRadius: { 192 | value: r_inner, 193 | style: 1 194 | } 195 | } 196 | ], 197 | views: [this.getIcon(action, itemHeight * 0.6)] 198 | }, 199 | { 200 | type: "spacer", 201 | props: { frame: { maxWidth: innerPadding * 1.5 } } 202 | }, 203 | { 204 | type: "text", 205 | props: { 206 | text: action.name, 207 | font: $font(15), 208 | bold: true, 209 | color: $color("primaryText") 210 | } 211 | } 212 | ] 213 | }) 214 | } 215 | views.push({ 216 | type: "hstack", 217 | props: { 218 | spacing: padding, 219 | padding: 0 220 | }, 221 | views: row 222 | }) 223 | } 224 | return { 225 | type: "vstack", 226 | props: { 227 | background: $color("primarySurface"), 228 | spacing: padding, 229 | padding: padding 230 | }, 231 | views: views 232 | } 233 | } 234 | 235 | view4x4() { 236 | return this.view2x4() 237 | } 238 | 239 | render() { 240 | $widget.setTimeline({ 241 | render: ctx => { 242 | this.ctx = ctx 243 | let view 244 | switch (this.ctx.family) { 245 | case 0: 246 | view = this.view2x2() 247 | break 248 | case 1: 249 | view = this.view2x4() 250 | break 251 | case 2: 252 | view = this.view4x4() 253 | break 254 | default: 255 | view = this.view2x2() 256 | } 257 | return view 258 | } 259 | }) 260 | } 261 | } 262 | 263 | module.exports = { Widget: ActionsWidget } 264 | -------------------------------------------------------------------------------- /scripts/ui/action/editor.js: -------------------------------------------------------------------------------- 1 | const { Setting, Sheet } = require("../../libs/easy-jsbox") 2 | const Editor = require("../components/editor") 3 | const { ActionEnv } = require("../../action/action") 4 | 5 | /** 6 | * @typedef {import("./actions").Actions} Actions 7 | */ 8 | 9 | class ActionEditor { 10 | editingActionInfo 11 | 12 | /** 13 | * 14 | * @param {Actions} data 15 | * @param {object} info 16 | */ 17 | constructor(data, info) { 18 | this.data = data 19 | this.info = info 20 | this.raw = JSON.parse(JSON.stringify(info)) 21 | 22 | this.actionCategories = this.data.getActionCategories() 23 | 24 | this.initEditingActionInfo() 25 | this.initSettingInstance() 26 | } 27 | 28 | initEditingActionInfo() { 29 | this.isNew = !Boolean(this.info) 30 | if (this.isNew) { 31 | this.editingActionInfo = { 32 | category: this.actionCategories[0], 33 | name: "MyAction", 34 | color: "#CC00CC", 35 | icon: "icon_062.png", // 默认星星图标 36 | readme: "" 37 | } 38 | } else { 39 | this.editingActionInfo = this.info 40 | this.editingActionInfo.readme = this.data.getActionReadme(this.info.category, this.info.dir) 41 | } 42 | } 43 | 44 | initSettingInstance() { 45 | this.settingInstance = new Setting({ 46 | structure: [], 47 | set: (key, value) => { 48 | this.editingActionInfo[key] = value 49 | return true 50 | }, 51 | get: (key, _default = null) => { 52 | if (Object.prototype.hasOwnProperty.call(this.editingActionInfo, key)) 53 | return this.editingActionInfo[key] 54 | else return _default 55 | } 56 | }) 57 | } 58 | 59 | informationView() { 60 | const nameInput = this.settingInstance 61 | .loader({ 62 | setting: this.settingInstance, 63 | type: "input", 64 | key: "name", 65 | icon: ["pencil.circle", "#FF3366"], 66 | title: $l10n("NAME") 67 | }) 68 | .create() 69 | const createColor = this.settingInstance 70 | .loader({ 71 | setting: this.settingInstance, 72 | type: "color", 73 | key: "color", 74 | icon: ["pencil.tip.crop.circle", "#0066CC"], 75 | title: $l10n("COLOR") 76 | }) 77 | .create() 78 | const iconInput = this.settingInstance 79 | .loader({ 80 | setting: this.settingInstance, 81 | type: "icon", 82 | key: "icon", 83 | icon: ["star.circle", "#FF9933"], 84 | title: $l10n("ICON"), 85 | bgcolor: this.data.views.getColor(this.editingActionInfo.color) 86 | }) 87 | .create() 88 | const categoryMenu = this.settingInstance 89 | .loader({ 90 | setting: this.settingInstance, 91 | type: "menu", 92 | key: "category", 93 | icon: ["tag.circle", "#33CC33"], 94 | title: $l10n("CATEGORY"), 95 | items: this.actionCategories, 96 | values: this.actionCategories, 97 | pullDown: true 98 | }) 99 | .create() 100 | 101 | let result = [nameInput, createColor, iconInput, categoryMenu] 102 | return result 103 | } 104 | 105 | actionInfoView() { 106 | const readme = { 107 | type: "view", 108 | views: [ 109 | { 110 | type: "text", 111 | props: { 112 | id: "action-text", 113 | textColor: $color("#000000", "secondaryText"), 114 | bgcolor: $color("systemBackground"), 115 | text: this.editingActionInfo.readme, 116 | insets: $insets(10, 10, 10, 10) 117 | }, 118 | layout: $layout.fill, 119 | events: { 120 | tapped: sender => { 121 | $("actionInfoPageSheetList").scrollToOffset($point(0, 220)) 122 | $delay(0.2, () => sender.focus()) 123 | }, 124 | didChange: sender => { 125 | this.editingActionInfo.readme = sender.text 126 | } 127 | } 128 | } 129 | ], 130 | layout: $layout.fill 131 | } 132 | const data = [ 133 | { title: $l10n("INFORMATION"), rows: this.informationView() }, 134 | { title: $l10n("DESCRIPTION"), rows: [readme] } 135 | ] 136 | 137 | return { 138 | type: "list", 139 | props: { 140 | id: "actionInfoPageSheetList", 141 | bgcolor: $color("insetGroupedBackground"), 142 | style: 2, 143 | separatorInset: $insets(0, 50, 0, 10), // 分割线边距 144 | data: data 145 | }, 146 | layout: $layout.fill, 147 | events: { 148 | rowHeight: (sender, indexPath) => (indexPath.section === 1 ? 150 : 50) 149 | } 150 | } 151 | } 152 | 153 | editActionInfoPageSheet(done) { 154 | const sheet = new Sheet() 155 | const sheetDone = async () => { 156 | if (this.isNew) { 157 | this.editingActionInfo.dir = this.data.initActionDirByName(this.editingActionInfo.name) 158 | if (this.data.exists(this.editingActionInfo.category, this.editingActionInfo.dir)) { 159 | const resp = await $ui.alert({ 160 | title: $l10n("UNABLE_CREATE_ACTION"), 161 | message: $l10n("ACTION_NAME_ALREADY_EXISTS").replaceAll("${name}", this.editingActionInfo.name) 162 | }) 163 | if (resp.index === 1) return 164 | } 165 | } 166 | sheet.dismiss() 167 | this.data.saveActionInfo(this.raw, this.editingActionInfo) 168 | await $wait(0.3) // 等待 sheet 关闭 169 | if (done) done(this.editingActionInfo) 170 | } 171 | sheet 172 | .setView(this.actionInfoView()) 173 | .addNavBar({ 174 | title: "", 175 | popButton: { title: $l10n("CANCEL") }, 176 | rightButtons: [{ title: $l10n("DONE"), tapped: () => sheetDone() }] 177 | }) 178 | .init() 179 | .present() 180 | } 181 | 182 | editorNavButtons(editor) { 183 | return [ 184 | { 185 | symbol: "book.circle", 186 | tapped: () => { 187 | let content = $file.read("scripts/action/README.md")?.string 188 | if (!content) { 189 | try { 190 | content = __ACTION_README__.content 191 | } catch {} 192 | } 193 | const sheet = new Sheet() 194 | sheet 195 | .setView({ 196 | type: "markdown", 197 | props: { content: content }, 198 | layout: (make, view) => { 199 | make.size.equalTo(view.super) 200 | } 201 | }) 202 | .addNavBar({ title: "Document", popButton: { symbol: "x.circle" } }) 203 | .init() 204 | .present() 205 | } 206 | }, 207 | { 208 | symbol: "play.circle", 209 | tapped: async () => { 210 | try { 211 | this.data.saveMainJs(this.info, editor.text) 212 | let actionRest = await this.data.getActionHandler( 213 | this.info.category, 214 | this.info.dir 215 | )({ env: ActionEnv.build }) 216 | if (actionRest !== undefined) { 217 | if (typeof actionRest === "object") { 218 | actionRest = JSON.stringify(actionRest, null, 2) 219 | } else { 220 | actionRest = String(actionRest) 221 | } 222 | const sheet = new Sheet() 223 | sheet 224 | .setView({ 225 | type: "code", 226 | props: { 227 | lineNumbers: true, 228 | editable: false, 229 | text: actionRest 230 | }, 231 | layout: $layout.fill 232 | }) 233 | .addNavBar({ 234 | title: "", 235 | popButton: { title: $l10n("DONE") } 236 | }) 237 | .init() 238 | .present() 239 | } 240 | } catch (error) { 241 | this.data.kernel.logger.error(error) 242 | } 243 | } 244 | } 245 | ] 246 | } 247 | 248 | editActionMainJs(text = "") { 249 | const editor = new Editor(this.data.kernel) 250 | editor.pageSheet( 251 | text, 252 | content => { 253 | this.data.saveMainJs(this.info, content) 254 | }, 255 | this.info.name, 256 | this.editorNavButtons(editor), 257 | "code" 258 | ) 259 | } 260 | } 261 | 262 | module.exports = ActionEditor 263 | -------------------------------------------------------------------------------- /scripts/action/secure.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../app-main").AppKernel} AppKernel 3 | * @typedef {SecureFunction} SecureFunction 4 | * @typedef {SecureScript} SecureScript 5 | */ 6 | 7 | class SecureFunctionBase { 8 | /** 9 | * @type {AppKernel} 10 | */ 11 | #kernel 12 | #config 13 | action 14 | 15 | notAllowed = `"The parameter or method is not allowed in Action."` 16 | 17 | constructor(kernel, action) { 18 | this.#kernel = kernel 19 | this.#config = JSON.parse(JSON.stringify(action.config)) 20 | if (!this.#config?.name || this.#config?.name === "undefined") { 21 | throw new Error("Cannot get Action name.") 22 | } 23 | this.action = action 24 | } 25 | 26 | get name() { 27 | return this.#config.name 28 | } 29 | set name(name) { 30 | const message = $l10n("ACTION_RESET_NAME_WARNING") 31 | .replaceAll("${name}", this.name) 32 | .replaceAll("${to_name}", name) 33 | $ui.alert({ 34 | title: $l10n("ACTION_SAFETY_WARNING"), 35 | message 36 | }) 37 | throw new Error(message) 38 | } 39 | 40 | info(parameter) { 41 | this.#kernel.logger.info(parameter) 42 | } 43 | error(parameter) { 44 | this.#kernel.logger.error(parameter) 45 | } 46 | } 47 | 48 | class SecureFile extends SecureFunctionBase { 49 | rootPath = this.notAllowed 50 | extensions = this.notAllowed 51 | 52 | read() { 53 | throw new Error(this.notAllowed) 54 | } 55 | async download() { 56 | throw new Error(this.notAllowed) 57 | } 58 | write() { 59 | throw new Error(this.notAllowed) 60 | } 61 | delete() { 62 | throw new Error(this.notAllowed) 63 | } 64 | list() { 65 | throw new Error(this.notAllowed) 66 | } 67 | copy() { 68 | throw new Error(this.notAllowed) 69 | } 70 | move() { 71 | throw new Error(this.notAllowed) 72 | } 73 | mkdir() { 74 | throw new Error(this.notAllowed) 75 | } 76 | exists() { 77 | throw new Error(this.notAllowed) 78 | } 79 | isDirectory() { 80 | throw new Error(this.notAllowed) 81 | } 82 | merge() { 83 | throw new Error(this.notAllowed) 84 | } 85 | absolutePath() { 86 | throw new Error(this.notAllowed) 87 | } 88 | } 89 | 90 | class SecureHttp extends SecureFunctionBase { 91 | #permissionCacheKey = "ActionHttpPermission" 92 | 93 | get #permissions() { 94 | const permissions = $cache.get(this.#permissionCacheKey) 95 | if (!permissions) { 96 | this.#permissions = {} 97 | return {} 98 | } 99 | return permissions 100 | } 101 | set #permissions(permissions = {}) { 102 | return $cache.set(this.#permissionCacheKey, permissions) 103 | } 104 | 105 | async #requestPermission() { 106 | const res = await $ui.alert({ 107 | title: $l10n("ACTION_PERMISSION_REQUEST"), 108 | message: $l10n("ACTION_NETWORK_PERMISSION_MESSAGE").replaceAll("${name}", this.name), 109 | actions: [ 110 | { 111 | title: $l10n("OK"), 112 | style: $alertActionType.destructive 113 | }, 114 | { title: $l10n("CANCEL") } 115 | ] 116 | }) 117 | 118 | if (res.index === 0) { 119 | const permissions = this.#permissions 120 | permissions[this.name] = true 121 | this.#permissions = permissions 122 | return true 123 | } 124 | return false 125 | } 126 | async #checkPermission() { 127 | const permissions = this.#permissions 128 | if (typeof permissions[this.name] === "boolean") { 129 | return permissions[this.name] 130 | } 131 | if (await this.#requestPermission()) { 132 | return true 133 | } 134 | return false 135 | } 136 | 137 | async request(request = {}) { 138 | try { 139 | if (!(await this.#checkPermission())) { 140 | throw new Error("No network permission.") 141 | } 142 | this.info(`sending request [${request.method}]: ${request.url}`) 143 | const resp = await $http.request(Object.assign({ timeout: 3 }, request)) 144 | 145 | if (typeof request?.handler === "function") { 146 | request?.handler(resp) 147 | } 148 | 149 | if (resp.error) { 150 | throw resp.error 151 | } else if (resp?.response?.statusCode >= 400) { 152 | let errMsg = resp.data 153 | if (typeof errMsg === "object") { 154 | errMsg = JSON.stringify(errMsg) 155 | } 156 | throw new Error("Http error: [" + resp.response.statusCode + "] " + errMsg) 157 | } 158 | 159 | return resp 160 | } catch (error) { 161 | if (error.code) { 162 | error = new Error("Network error: [" + error.code + "] " + error.localizedDescription) 163 | } 164 | this.error(`Action request error: ${this.name}`) 165 | this.error(error) 166 | throw error 167 | } 168 | } 169 | async get(parameter) { 170 | if (typeof parameter === "string") { 171 | parameter = { url: parameter } 172 | } 173 | parameter.method = "GET" 174 | return await this.request(parameter) 175 | } 176 | async post(parameter) { 177 | if (typeof parameter === "string") { 178 | parameter = { url: parameter } 179 | } 180 | parameter.method = "POST" 181 | return await this.request(parameter) 182 | } 183 | async download() { 184 | throw new Error(this.notAllowed) 185 | } 186 | async upload() { 187 | throw new Error(this.notAllowed) 188 | } 189 | async startServer() { 190 | throw new Error(this.notAllowed) 191 | } 192 | async stopServer() { 193 | throw new Error(this.notAllowed) 194 | } 195 | async shorten(param) { 196 | return await $http.shorten(param) 197 | } 198 | async lengthen(param) { 199 | return await $http.lengthen(param) 200 | } 201 | } 202 | 203 | class SecureCache extends SecureFunctionBase { 204 | #cacheKey = "ActionCache" 205 | 206 | #get() { 207 | const cache = $cache.get(this.#cacheKey) ?? {} 208 | if (!cache[this.name]) { 209 | cache[this.name] = {} 210 | } 211 | return cache 212 | } 213 | 214 | get(key) { 215 | return this.#get()[this.name][key] 216 | } 217 | async getAsync({ key, handler } = {}) { 218 | handler(this.get(key)) 219 | } 220 | 221 | set(key, value) { 222 | const cache = this.#get() 223 | cache[this.name][key] = value 224 | return $cache.set(this.#cacheKey, cache) 225 | } 226 | async setAsync({ key, value, handler } = {}) { 227 | handler(this.set(key, value)) 228 | } 229 | 230 | remove(key) { 231 | const cache = this.#get() 232 | delete cache[this.name][key] 233 | $cache.set(this.#cacheKey, cache) 234 | } 235 | async removeAsync({ key, handler } = {}) { 236 | this.remove(key) 237 | handler() 238 | } 239 | 240 | clear() { 241 | const cache = this.#get() 242 | delete cache[this.name] 243 | $cache.set(this.#cacheKey, cache) 244 | } 245 | async clearAsync({ handler } = {}) { 246 | this.clear() 247 | handler() 248 | } 249 | } 250 | 251 | class SecureFunction extends SecureFunctionBase { 252 | #sheet 253 | 254 | constructor(...args) { 255 | super(...args) 256 | this.file = new SecureFile(...args) 257 | this.http = new SecureHttp(...args) 258 | this.cache = new SecureCache(...args) 259 | } 260 | 261 | get controller() { 262 | return this.#sheet.sheetVC.jsValue() 263 | } 264 | 265 | render(view) { 266 | this.#sheet = this.action.pageSheet({ view }) 267 | } 268 | 269 | addin() { 270 | throw new Error(this.notAllowed) 271 | } 272 | 273 | sheetDismiss() { 274 | this.#sheet.dismiss() 275 | } 276 | } 277 | 278 | class SecureScript { 279 | /** 280 | * @typedef {string} 281 | */ 282 | script 283 | 284 | sfPrefix = "this" 285 | sf 286 | 287 | /** 288 | * @param {string} script 289 | */ 290 | constructor(script, sfPrefix = "this") { 291 | this.sfPrefix = sfPrefix 292 | this.sf = `${this.sfPrefix}.secureFunction` 293 | this.script = script 294 | } 295 | 296 | /** 297 | * Replaces text in a string, using a regular expression or search string. 298 | * @param {string | RegExp} searchValue A string or regular expression to search for. 299 | * @param {string} replaceValue A string containing the text to replace. When the searchValue is a RegExp, all matches are replaced if the g flag is set (or only those matches at the beginning, if the y flag is also present). Otherwise, only the first match of searchValue is replaced. 300 | */ 301 | #replace(searchValue, replaceValue) { 302 | this.script = this.script.replaceAll(searchValue, replaceValue) 303 | } 304 | 305 | replaceFunction() { 306 | this.#replace(/\$ui\.render/gi, `${this.sf}.render`) 307 | this.#replace(/\$ui\.controller/gi, `${this.sf}.controller`) 308 | 309 | this.#replace(/\$app\.close/gi, `${this.sf}.sheetDismiss`) 310 | this.#replace(/\$keyboard\.dismiss/gi, `${this.sf}.sheetDismiss`) 311 | 312 | this.#replace(/\$addin\.*[a-zA-Z0-9\[\]'"`]+/gi, `${this.sf}.addin()`) 313 | this.#replace("eval", `${this.sf}.addin()`) 314 | } 315 | 316 | replaceFile() { 317 | this.#replace("$file", `${this.sf}.file`) 318 | } 319 | replaceHttp() { 320 | this.#replace("$http", `${this.sf}.http`) 321 | } 322 | replaceCache() { 323 | this.#replace("$cache", `${this.sf}.cache`) 324 | } 325 | 326 | secure() { 327 | this.replaceFunction() 328 | this.replaceFile() 329 | this.replaceHttp() 330 | this.replaceCache() 331 | return this.script 332 | } 333 | } 334 | 335 | module.exports = { 336 | SecureFunction, 337 | SecureScript 338 | } 339 | -------------------------------------------------------------------------------- /scripts/widget/list-widget.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("../dao/storage")} Storage 3 | * @typedef {import("../libs/easy-jsbox").Setting} Setting 4 | */ 5 | class ListWidget { 6 | /** 7 | * @param {Setting} setting 8 | * @param {Storage} storage 9 | */ 10 | constructor({ setting, storage, source, label } = {}) { 11 | this.setting = setting 12 | this.storage = storage 13 | this.baseUrlScheme = `jsbox://run?name=${$addin.current.name}` 14 | this.urlScheme = { 15 | clips: this.baseUrlScheme, 16 | add: `${this.baseUrlScheme}&add=1`, 17 | actions: `${this.baseUrlScheme}&actions=1`, 18 | copy: uuid => `${this.baseUrlScheme}©=${uuid}` 19 | } 20 | 21 | this.viewStyle = { 22 | topItemSize: 32, // 2x2加号和计数大小 23 | tipTextColor: "orange" // 2x2加号和计数大小 24 | } 25 | this.padding = 15 26 | this.label = label 27 | 28 | this.rawData = this.storage.sort(this.storage.all(source)) 29 | this.rawDataLength = this.rawData.length 30 | } 31 | 32 | get maxLength() { 33 | // require this.ctx, this.render() 34 | switch (this.ctx.family) { 35 | case 0: 36 | return 1 37 | case 1: 38 | return 5 39 | case 2: 40 | return 10 41 | } 42 | } 43 | 44 | get data() { 45 | // require this.maxLength 46 | return this.rawData.slice(0, this.maxLength) 47 | } 48 | 49 | view2x2() { 50 | return { 51 | type: "vstack", 52 | props: { 53 | background: $color("primarySurface"), 54 | alignment: $widget.horizontalAlignment.leading, 55 | spacing: 0, 56 | padding: this.padding, 57 | widgetURL: (() => { 58 | switch (this.setting.get("widget.2x2.widgetURL")) { 59 | case 0: 60 | return this.urlScheme.add 61 | case 1: 62 | return this.urlScheme.actions 63 | case 2: 64 | return this.urlScheme.clips 65 | } 66 | })() 67 | }, 68 | views: [ 69 | { 70 | // 顶部 71 | type: "vgrid", 72 | props: { 73 | columns: [ 74 | { 75 | flexible: { 76 | minimum: 10, 77 | maximum: this.viewStyle.topItemSize 78 | }, 79 | alignment: $widget.alignment.leading 80 | }, 81 | { 82 | flexible: { 83 | minimum: 10, 84 | maximum: Infinity 85 | }, 86 | alignment: $widget.alignment.trailing 87 | } 88 | ] 89 | }, 90 | views: [ 91 | { 92 | type: "image", 93 | props: { 94 | offset: $point(-2, 0), // 图标圆边与文字对齐 95 | symbol: { 96 | glyph: "plus.circle.fill", 97 | size: this.viewStyle.topItemSize 98 | }, 99 | color: $color("systemLink") 100 | } 101 | }, 102 | { 103 | type: "text", 104 | props: { 105 | font: $font("bold", this.viewStyle.topItemSize), 106 | text: String(this.rawDataLength) 107 | } 108 | } 109 | ] 110 | }, 111 | { type: "spacer" }, 112 | { 113 | // 底部 114 | type: "vstack", 115 | props: { 116 | alignment: $widget.horizontalAlignment.leading, 117 | spacing: 0 118 | }, 119 | views: [ 120 | { 121 | type: "text", 122 | props: { 123 | color: $color(this.viewStyle.tipTextColor), 124 | text: $l10n("RECENT"), 125 | font: $font("bold", 16) 126 | } 127 | }, 128 | { 129 | type: "text", 130 | props: { 131 | text: this.data[0] ? this.data[0].text : "", 132 | font: $font(12) 133 | } 134 | } 135 | ] 136 | } 137 | ] 138 | } 139 | } 140 | 141 | view2x4() { 142 | return { 143 | type: "hstack", 144 | props: { 145 | background: $color("primarySurface"), 146 | spacing: 0, 147 | padding: this.padding, 148 | widgetURL: this.urlScheme.clips 149 | }, 150 | views: [ 151 | { 152 | // 左侧 153 | type: "vstack", 154 | props: { 155 | alignment: $widget.horizontalAlignment.leading 156 | }, 157 | views: [ 158 | { 159 | type: "image", 160 | props: { 161 | offset: $point(-2, 0), // 图标圆边与文字对齐 162 | symbol: { 163 | glyph: "plus.circle.fill", 164 | size: this.viewStyle.topItemSize 165 | }, 166 | link: this.urlScheme.add, 167 | color: $color("systemLink") 168 | } 169 | }, 170 | { type: "spacer" }, 171 | { 172 | type: "text", 173 | props: { 174 | font: $font("bold", this.viewStyle.topItemSize), 175 | text: String(this.rawDataLength) 176 | } 177 | }, 178 | { 179 | // 提示文字 180 | type: "text", 181 | props: { 182 | color: $color(this.viewStyle.tipTextColor), 183 | text: this.label, 184 | font: $font("bold", 16) 185 | } 186 | } 187 | ] 188 | }, 189 | { 190 | type: "spacer", 191 | props: { frame: { maxWidth: this.data.length > 0 ? 25 : Infinity } } 192 | }, 193 | { 194 | // 右侧 195 | type: "vstack", 196 | props: { 197 | spacing: 0, 198 | frame: { 199 | maxHeight: Infinity, 200 | maxWidth: Infinity, 201 | alignment: $widget.alignment.topLeading 202 | } 203 | }, 204 | views: (() => { 205 | const result = [] 206 | const height = (this.ctx.displaySize.height - this.padding) / this.maxLength 207 | this.data.map((item, i) => { 208 | if (i !== 0 && i !== this.maxLength) { 209 | result.push({ type: "divider" }) 210 | } 211 | result.push({ 212 | type: "text", 213 | props: { 214 | text: item.text, 215 | lineLimit: 1, 216 | font: $font(14), 217 | link: `${this.urlScheme.copy(item.uuid)}`, 218 | frame: { 219 | maxHeight: height, 220 | maxWidth: Infinity, 221 | alignment: $widget.alignment.leading 222 | } 223 | } 224 | }) 225 | }) 226 | return result 227 | })() 228 | } 229 | ] 230 | } 231 | } 232 | 233 | view4x4() { 234 | return this.view2x4() 235 | } 236 | 237 | render() { 238 | const nowDate = Date.now() 239 | const expireDate = new Date(nowDate + 1000 * 60 * 10) // 每十分钟切换 240 | $widget.setTimeline({ 241 | entries: [ 242 | { 243 | date: nowDate, 244 | info: {} 245 | } 246 | ], 247 | policy: { 248 | afterDate: expireDate 249 | }, 250 | render: ctx => { 251 | this.ctx = ctx 252 | let view 253 | switch (this.ctx.family) { 254 | case 0: 255 | view = this.view2x2() 256 | break 257 | case 1: 258 | view = this.view2x4() 259 | break 260 | case 2: 261 | view = this.view4x4() 262 | break 263 | default: 264 | view = this.view2x2() 265 | } 266 | return view 267 | } 268 | }) 269 | } 270 | } 271 | 272 | module.exports = ListWidget 273 | -------------------------------------------------------------------------------- /scripts/compatibility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("./app-main").AppKernel} AppKernel 3 | */ 4 | 5 | const isTaio = $app.info.bundleID.includes("taio") 6 | 7 | class Compatibility { 8 | files = [] 9 | databases = [] 10 | actions = {} 11 | 12 | /** 13 | * @param {AppKernel} kernel 14 | */ 15 | constructor(kernel) { 16 | this.kernel = kernel 17 | } 18 | 19 | deleteFiles(files) { 20 | files.forEach(file => { 21 | if (!this.files.includes(file)) { 22 | this.files.push(file) 23 | } 24 | }) 25 | } 26 | 27 | #deleteFiles() { 28 | this.files.forEach(file => { 29 | if ($file.exists(file)) { 30 | this.kernel.logger.info(`delete file: ${file}`) 31 | $file.delete(file) 32 | } 33 | }) 34 | } 35 | 36 | rebuildDatabase(oldTab, newTab) { 37 | this.databases.push([oldTab, newTab]) 38 | } 39 | 40 | #rebuildDatabase() { 41 | const action = (oldTab, newTab) => { 42 | const result = this.kernel.storage.sqlite.query( 43 | `SELECT count(*), name FROM sqlite_master WHERE type = "table" AND name = "${oldTab}"` 44 | ) 45 | if (result.error !== null) { 46 | throw new Error( 47 | `Code [${result.error.code}] ${result.error.domain} ${result.error.localizedDescription}` 48 | ) 49 | } 50 | result.result.next() 51 | const count = result.result.get(0) 52 | result.result.close() 53 | 54 | if (count > 0) { 55 | this.kernel.logger.info(`copy data from old table: ${oldTab}`) 56 | this.kernel.storage.sqlite.update(`INSERT INTO ${newTab} SELECT * FROM ${oldTab}`) 57 | this.kernel.logger.info(`drop table: ${oldTab}`) 58 | this.kernel.storage.sqlite.update(`DROP TABLE ${oldTab}`) 59 | } 60 | } 61 | this.databases.forEach(db => { 62 | action(db[0], db[1]) 63 | }) 64 | } 65 | 66 | rebuildUserActions(actions = {}) { 67 | for (let category of Object.keys(actions)) { 68 | actions[category].forEach(action => { 69 | if (!this.actions[category]) { 70 | this.actions[category] = [] 71 | } 72 | if (!this.actions[category].includes(action)) { 73 | this.actions[category].push(action) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | async #rebuildUserActions() { 80 | if (Object.keys(this.actions).length === 0) return 81 | const actionPath = `scripts/action` 82 | const userActionPath = `${this.kernel.fileStorage.basePath}/user_action` 83 | 84 | const changeList = [] 85 | for (let category of Object.keys(this.actions)) { 86 | this.actions[category].forEach(action => { 87 | let config 88 | const configPath = `${actionPath}/${category}/${action}/config.json` 89 | if ($file.exists(configPath)) { 90 | config = JSON.parse($file.read(`${actionPath}/${category}/${action}/config.json`).string) 91 | } else { 92 | config = __INFO__ 93 | } 94 | changeList.push(config.name) 95 | }) 96 | } 97 | const alertResult = await $ui.alert({ 98 | title: $l10n("compatibility.rebuildUserAction.alert.title"), 99 | message: 100 | $l10n("compatibility.rebuildUserAction.alert.message") + 101 | "\n" + 102 | JSON.stringify(changeList, null, 2) + 103 | "\n" + 104 | $l10n("compatibility.rebuildUserAction.alert.message2"), 105 | actions: [{ title: $l10n("OK") }, { title: $l10n("CANCEL") }] 106 | }) 107 | if (alertResult.index === 1) { 108 | return 109 | } 110 | 111 | // 重建用户动作 112 | for (let category of Object.keys(this.actions)) { 113 | this.actions[category].forEach(action => { 114 | if ($file.exists(`${userActionPath}/${category}/${action}`)) { 115 | this.kernel.logger.info(`rebuild user action: ${category}/${action}`) 116 | $file.copy({ 117 | src: `${actionPath}/${category}/${action}/main.js`, 118 | dst: `${userActionPath}/${category}/${action}/main.js` 119 | }) 120 | } 121 | }) 122 | } 123 | this.kernel.actions.needUpload() 124 | } 125 | 126 | async do() { 127 | this.#deleteFiles() 128 | this.#rebuildDatabase() 129 | await this.#rebuildUserActions() 130 | } 131 | } 132 | 133 | class VersionActions { 134 | version = 15 135 | userVersion = $cache.get("compatibility.version") ?? 0 136 | 137 | /** 138 | * @param {AppKernel} kernel 139 | */ 140 | constructor(kernel) { 141 | this.kernel = kernel 142 | this.compatibility = new Compatibility(this.kernel) 143 | } 144 | 145 | do() { 146 | // this.userVersion === 0 视为新用户 147 | if (this.userVersion > 0 && this.userVersion < this.version) { 148 | this.kernel.logger.info(`compatibility: userVersion [${this.userVersion}] lower than [${this.version}]`) 149 | for (let i = this.userVersion + 1; i <= this.version; i++) { 150 | this.call(i) 151 | } 152 | this.compatibility.do().catch(e => this.kernel.logger.error(e)) 153 | this.kernel.logger.info(`compatibility: userVersion [${this.userVersion}] updated to [${this.version}]`) 154 | } 155 | 156 | // 修改版本 157 | $cache.set("compatibility.version", this.version) 158 | } 159 | 160 | call(version) { 161 | if (typeof this[`ver${version}`] === "function") { 162 | this[`ver${version}`]() 163 | } else { 164 | throw new ReferenceError(`Version ${version} undefined`) 165 | } 166 | } 167 | 168 | ver1() { 169 | this.compatibility.deleteFiles([ 170 | "scripts/action/clipboard/ClearClipboard", 171 | "scripts/ui/clipboard.js", 172 | "scripts/ui/clipboard-data.js", 173 | "scripts/ui/clipboard-search.js" 174 | ]) 175 | 176 | this.compatibility.rebuildDatabase("clipboard", "clips") 177 | 178 | this.compatibility.rebuildUserActions({ 179 | uncategorized: ["ExportAllContent", "DisplayClipboard"], 180 | clipboard: ["B23Clean"] 181 | }) 182 | 183 | // 键盘高度保存到 setting 184 | if ($cache.get("caio.keyboard.height")) { 185 | this.kernel.setting.set("keyboard.previewAndHeight", $cache.get("caio.keyboard.height")) 186 | $cache.remove("caio.keyboard.height") 187 | } 188 | } 189 | 190 | ver2() { 191 | this.compatibility.deleteFiles([ 192 | "scripts/storage.js", 193 | "scripts/ui/clips-data.js", 194 | "scripts/ui/components/action-manager-data.js" 195 | ]) 196 | 197 | this.compatibility.rebuildDatabase("pin", "favorite") 198 | 199 | this.compatibility.rebuildUserActions({ 200 | uncategorized: ["ExportAllContent"] 201 | }) 202 | } 203 | 204 | ver3() { 205 | this.compatibility.rebuildUserActions({ 206 | clipboard: ["SendToWin"] 207 | }) 208 | } 209 | 210 | ver4() { 211 | const actionSyncDataPath = "/storage/user_action/data.json" 212 | if ($file.exists(actionSyncDataPath)) { 213 | const date = JSON.parse($file.read(actionSyncDataPath).string).date 214 | $file.write({ 215 | data: $data({ string: JSON.stringify({ timestamp: date }) }), 216 | path: "/storage/user_action/sync.json" 217 | }) 218 | $file.delete(actionSyncDataPath) 219 | } 220 | } 221 | 222 | ver5() { 223 | this.compatibility.rebuildUserActions({ 224 | uncategorized: ["DisplayClipboard"] 225 | }) 226 | } 227 | 228 | ver6() { 229 | this.compatibility.rebuildUserActions({ 230 | clipboard: ["GetFromWin"] 231 | }) 232 | } 233 | 234 | ver7() { 235 | this.compatibility.rebuildUserActions({ 236 | uncategorized: ["Replace"] 237 | }) 238 | } 239 | 240 | ver8() {} 241 | ver9() { 242 | this.compatibility.rebuildUserActions({ 243 | clipboard: ["SendToWin"] 244 | }) 245 | } 246 | 247 | ver10() { 248 | const basePath = this.kernel.fileStorage.basePath 249 | $file.mkdir(basePath) 250 | if ($file.exists("storage")) { 251 | $file.move({ 252 | src: "storage", 253 | dst: basePath 254 | }) 255 | } 256 | 257 | this.compatibility.deleteFiles(["storage"]) 258 | } 259 | 260 | ver11() { 261 | const sqls = [ 262 | `create table temp as select uuid, text, prev, next from clips where 1=1;`, 263 | `drop table clips;`, 264 | `alter table temp rename to clips;`, 265 | `create table temp as select uuid, text, prev, next from favorite where 1=1;`, 266 | `drop table favorite;`, 267 | `alter table temp rename to favorite;` 268 | ] 269 | this.kernel.storage.beginTransaction() 270 | try { 271 | sqls.forEach(sql => this.kernel.storage.sqlite.update(sql)) 272 | this.kernel.storage.commit() 273 | } catch (error) { 274 | this.kernel.storage.rollback() 275 | this.kernel.logger.error(error) 276 | throw error 277 | } 278 | } 279 | 280 | ver12() { 281 | if (isTaio) { 282 | this.compatibility.deleteFiles(["shared://caio", "storage"]) 283 | } 284 | } 285 | 286 | ver13() { 287 | this.compatibility.deleteFiles([ 288 | "setting.json", 289 | "dist/CAIO-en.json", 290 | "dist/CAIO-zh-Hans.json", 291 | "dist/CAIO.js", 292 | "assets/icon" 293 | ]) 294 | } 295 | 296 | ver14() { 297 | $cache.remove(this.kernel.actions.allActionsCacheKey) 298 | } 299 | 300 | ver15() { 301 | this.compatibility.rebuildUserActions({ 302 | clipboard: ["SendToWin", "Tokenize"] 303 | }) 304 | } 305 | } 306 | 307 | /** 308 | * @param {AppKernel} kernel 309 | */ 310 | async function compatibility(kernel) { 311 | if (!kernel) return 312 | 313 | try { 314 | const versionActions = new VersionActions(kernel) 315 | versionActions.do() 316 | } catch (error) { 317 | kernel.logger.error(error) 318 | throw error 319 | } 320 | } 321 | 322 | module.exports = compatibility 323 | -------------------------------------------------------------------------------- /scripts/ui/clips/views.js: -------------------------------------------------------------------------------- 1 | const { UIKit, ViewController } = require("../../libs/easy-jsbox") 2 | const Editor = require("../components/editor") 3 | 4 | /** 5 | * @typedef {ClipsViews} ClipsViews 6 | * @typedef {import("../../dao/storage").Clip} Clip 7 | * @typedef {import("../../app-main").AppKernel} AppKernel 8 | */ 9 | 10 | class ClipsViews { 11 | listId = $text.uuid 12 | 13 | editingToolBarId = this.listId + "-edit-mode-tool-bar" 14 | 15 | // 剪贴板个性化设置 16 | #singleLine = false 17 | #singleLineContentHeight = 0 18 | scrollToOffsetDirectionX = false 19 | tabLeftMargin = 20 // tab 左边距 20 | horizontalMargin = 20 // 列表边距 21 | verticalMargin = 14 // 列表边距 22 | containerMargin = 0 // list 单边边距。如果 list 未贴合屏幕左右边缘,则需要此值辅助计算文字高度 23 | fontSize = 16 // 字体大小 24 | copiedIndicatorSize = 6 // 已复制指示器(小绿点)大小 25 | imageContentHeight = 50 26 | tagHeight = this.verticalMargin + 5 27 | tagColor = $color("lightGray") 28 | 29 | tabHeight = 44 30 | editModeToolBarHeight = 44 31 | 32 | #textHeightCache = {} 33 | 34 | viewController 35 | 36 | /** 37 | * @param {AppKernel} kernel 38 | */ 39 | constructor(kernel) { 40 | this.kernel = kernel 41 | 42 | this.viewController = new ViewController() 43 | } 44 | 45 | get singleLineContentHeight() { 46 | if (this.#singleLineContentHeight === 0) { 47 | this.#singleLineContentHeight = this.getTextHeight($font(this.fontSize)) 48 | } 49 | return this.#singleLineContentHeight 50 | } 51 | 52 | setSingleLine() { 53 | this.#singleLine = true 54 | // 图片高度与文字一致 55 | this.imageContentHeight = this.singleLineContentHeight 56 | this.#singleLineContentHeight = 0 57 | } 58 | 59 | getTextHeight(font, text = "a") { 60 | return $text.sizeThatFits({ 61 | text, 62 | font, 63 | width: UIKit.windowSize.width - (this.horizontalMargin + this.containerMargin) * 2 64 | }).height 65 | } 66 | 67 | getContentHeight(text) { 68 | if (!this.#textHeightCache[text]) { 69 | this.#textHeightCache[text] = this.#singleLine 70 | ? this.singleLineContentHeight 71 | : Math.min(this.getTextHeight($font(this.fontSize), text), this.singleLineContentHeight * 2) 72 | } 73 | return this.#textHeightCache[text] 74 | } 75 | 76 | edit(text, callback) { 77 | const editor = new Editor(this.kernel) 78 | const navButtons = [ 79 | { 80 | symbol: "square.and.arrow.up", 81 | tapped: () => { 82 | if (editor.text) { 83 | $share.sheet(editor.text) 84 | } else { 85 | $ui.warning($l10n("NONE")) 86 | } 87 | } 88 | } 89 | ] 90 | 91 | if (this.kernel.isUseJsboxNav) { 92 | editor.uikitPush(text, text => callback(text), navButtons) 93 | } else { 94 | const navigationView = editor.getNavigationView(text, text => callback(text), navButtons) 95 | this.viewController.push(navigationView) 96 | } 97 | } 98 | 99 | tabView(tabItems, tabIndex, events) { 100 | return { 101 | type: "tab", 102 | props: { 103 | id: this.listId + "-tab", 104 | items: tabItems, 105 | index: tabIndex, 106 | dynamicWidth: true 107 | }, 108 | events, 109 | layout: (make, view) => { 110 | make.centerY.equalTo(view.super) 111 | if (view.prev) { 112 | make.left.equalTo(view.prev.right).offset(this.tabLeftMargin) 113 | } else { 114 | make.left.inset(this.tabLeftMargin) 115 | } 116 | } 117 | } 118 | } 119 | 120 | /** 121 | * @param {Clip} clip 122 | * @param {boolean} indicator 123 | * @returns 124 | */ 125 | lineData(clip, indicator = false) { 126 | const image = { hidden: true } 127 | const content = { text: "" } 128 | const tag = { hidden: !clip?.hasTag } 129 | 130 | if (clip.image) { 131 | //image.src = clip.imagePath.preview 132 | image.data = clip.imagePreview 133 | image.hidden = false 134 | } else { 135 | if (clip.textStyledText) { 136 | content.styledText = clip.textStyledText 137 | } else { 138 | content.text = clip.text 139 | } 140 | if (clip.tagStyledText) { 141 | tag.styledText = clip.tagStyledText 142 | } else { 143 | tag.text = clip.tag 144 | } 145 | } 146 | 147 | return { 148 | copied: { hidden: !indicator }, 149 | image, 150 | tag, 151 | content 152 | } 153 | } 154 | 155 | listTemplate() { 156 | return { 157 | props: { bgcolor: $color("clear") }, 158 | views: [ 159 | { 160 | type: "view", 161 | views: [ 162 | { 163 | type: "view", 164 | props: { 165 | id: "copied", 166 | circular: this.copiedIndicatorSize, 167 | hidden: true, 168 | bgcolor: $color("green") 169 | }, 170 | layout: (make, view) => { 171 | make.centerY.equalTo(view.super) 172 | make.size.equalTo(this.copiedIndicatorSize) 173 | // 放在前面小缝隙的中间 `this.copyedIndicatorSize / 2` 指大小的一半 174 | make.left 175 | .equalTo(view.super) 176 | .inset(this.horizontalMargin / 2 - this.copiedIndicatorSize / 2) 177 | } 178 | }, 179 | { 180 | type: "label", 181 | props: { 182 | id: "content", 183 | lines: this.#singleLine ? 1 : 2, 184 | font: $font(this.fontSize) 185 | }, 186 | layout: (make, view) => { 187 | make.left.right.equalTo(view.super).inset(this.horizontalMargin) 188 | make.top.equalTo(this.verticalMargin) 189 | } 190 | }, 191 | { 192 | type: "label", 193 | props: { 194 | id: "tag", 195 | lines: 1, 196 | color: this.tagColor, 197 | autoFontSize: true, 198 | align: $align.leading 199 | }, 200 | layout: (make, view) => { 201 | make.bottom.equalTo(view.super) 202 | make.left.right.equalTo(view.prev) 203 | make.height.equalTo(this.tagHeight) 204 | } 205 | } 206 | ], 207 | layout: $layout.fill 208 | }, 209 | { 210 | type: "image", 211 | props: { 212 | id: "image", 213 | hidden: true 214 | }, 215 | layout: $layout.fill 216 | } 217 | ] 218 | } 219 | } 220 | 221 | getEmptyBackground(hidden = false) { 222 | return { 223 | type: "label", 224 | props: { 225 | color: $color("secondaryText"), 226 | hidden, 227 | text: $l10n("NONE"), 228 | align: $align.center 229 | }, 230 | events: { 231 | ready: sender => { 232 | sender.layout((make, view) => { 233 | make.top.equalTo(this.tabHeight) 234 | make.left.right.bottom.equalTo(view.super) 235 | }) 236 | } 237 | } 238 | } 239 | } 240 | 241 | getListEditModeToolBarView({ selectButtonEvents, deleteButtonEvents } = {}) { 242 | const blurBox = UIKit.blurBox({ id: this.editingToolBarId }, [ 243 | UIKit.separatorLine(), 244 | { 245 | type: "view", 246 | views: [ 247 | { 248 | type: "button", 249 | props: { 250 | id: this.editingToolBarId + "-select-button", 251 | title: $l10n("SELECT_ALL"), 252 | titleColor: $color("tint"), 253 | bgcolor: $color("clear") 254 | }, 255 | layout: (make, view) => { 256 | make.left.inset(this.horizontalMargin) 257 | make.centerY.equalTo(view.super) 258 | }, 259 | events: selectButtonEvents 260 | }, 261 | { 262 | type: "button", 263 | props: { 264 | id: this.editingToolBarId + "-delete-button", 265 | symbol: "trash", 266 | hidden: true, 267 | tintColor: $color("red"), 268 | bgcolor: $color("clear") 269 | }, 270 | layout: (make, view) => { 271 | make.height.equalTo(view.super) 272 | make.width.equalTo(this.horizontalMargin * 2) 273 | make.right.inset(this.horizontalMargin / 2) 274 | make.centerY.equalTo(view.super) 275 | }, 276 | events: deleteButtonEvents 277 | } 278 | ], 279 | layout: (make, view) => { 280 | make.left.right.top.equalTo(view.super) 281 | make.bottom.equalTo(view.super.safeAreaBottom) 282 | } 283 | } 284 | ]) 285 | return blurBox 286 | } 287 | 288 | getListView(id = this.listId, data = [], events) { 289 | const listView = { 290 | // 剪切板列表 291 | type: "list", 292 | props: { 293 | id, 294 | associateWithNavigationBar: false, 295 | bgcolor: $color("clear"), 296 | separatorInset: $insets(0, this.horizontalMargin, 0, 0), 297 | data, 298 | allowsMultipleSelectionDuringEditing: true, 299 | template: this.listTemplate(), 300 | backgroundView: $ui.create(this.getEmptyBackground()) 301 | }, 302 | events, 303 | layout: (make, view) => { 304 | if (view.prev) { 305 | make.top.equalTo(view.prev.bottom) 306 | } else { 307 | make.top.equalTo(view.super) 308 | } 309 | make.left.right.bottom.equalTo(view.super) 310 | } 311 | } 312 | 313 | return listView 314 | } 315 | } 316 | 317 | module.exports = ClipsViews 318 | -------------------------------------------------------------------------------- /scripts/action/action.js: -------------------------------------------------------------------------------- 1 | const { L10n, Sheet } = require("../libs/easy-jsbox") 2 | const { SecureFunction, SecureScript } = require("./secure") 3 | const AES = require("../libs/aes") 4 | 5 | /** 6 | * @typedef {import("../app-main").AppKernel} AppKernel 7 | * @typedef {Action} Action 8 | * @typedef {ActionEnv} ActionEnv 9 | * @typedef {ActionData} ActionData 10 | */ 11 | 12 | class ActionEnv { 13 | static build = -1 14 | static today = 0 15 | static editor = 1 16 | static clipboard = 2 17 | static action = 3 // 主动作页面 18 | static keyboard = 4 19 | static recursion = 5 20 | static widget = 6 21 | static siri = 7 22 | static mock = 8 // 虚拟 Action,用于快速访问方法 23 | } 24 | class ActionData { 25 | #text 26 | #originalContent 27 | #textBeforeInput 28 | #selectedText 29 | #selectedRange 30 | #textAfterInput 31 | 32 | env 33 | args // 其他动作传递的参数 34 | section // 首页剪切板分类 35 | uuid // 首页剪切板项目 uuid 36 | editor 37 | 38 | constructor(data = {}) { 39 | if (data.env === ActionEnv.build) { 40 | const _data = this.preview() 41 | if (_data) { 42 | data = _data 43 | data.env = ActionEnv.build // 忽略 preview 返回的 env 44 | } 45 | } 46 | 47 | this.init(data) 48 | } 49 | 50 | init({ 51 | env, 52 | args = null, 53 | text = null, 54 | section = null, 55 | uuid = null, 56 | selectedRange = null, 57 | textBeforeInput = null, 58 | selectedText = null, 59 | textAfterInput = null, 60 | editor = null 61 | } = {}) { 62 | this.env = env 63 | this.args = args 64 | this.section = section 65 | this.uuid = uuid 66 | this.#text = text 67 | this.#originalContent = text 68 | this.#textBeforeInput = textBeforeInput 69 | this.#selectedText = selectedText 70 | this.#selectedRange = selectedRange 71 | this.#textAfterInput = textAfterInput 72 | this.editor = editor 73 | } 74 | 75 | get text() { 76 | if (typeof this.#text === "function") { 77 | return this.#text() 78 | } 79 | return this.#text ?? $clipboard.text 80 | } 81 | 82 | get originalContent() { 83 | return this.#originalContent 84 | } 85 | 86 | get textBeforeInput() { 87 | if (typeof this.#textBeforeInput === "function") { 88 | return this.#textBeforeInput() 89 | } 90 | return this.#textBeforeInput 91 | } 92 | 93 | get selectedText() { 94 | if (typeof this.#selectedText === "function") { 95 | return this.#selectedText() 96 | } 97 | return this.#selectedText 98 | } 99 | 100 | get selectedRange() { 101 | if (typeof this.#selectedRange === "function") { 102 | return this.#selectedRange() 103 | } 104 | return this.#selectedRange 105 | } 106 | 107 | get textAfterInput() { 108 | if (typeof this.#textAfterInput === "function") { 109 | return this.#textAfterInput() 110 | } 111 | return this.#textAfterInput 112 | } 113 | 114 | preview() { 115 | return null 116 | } 117 | } 118 | 119 | class Action extends ActionData { 120 | /** 121 | * @type {AppKernel} 122 | */ 123 | #kernel 124 | secureFunction 125 | 126 | /** 127 | * 128 | * @param {AppKernel} kernel 129 | * @param {object} config 130 | * @param {ActionData} data 131 | */ 132 | constructor(kernel, config, data) { 133 | super(data) 134 | this.#kernel = kernel 135 | this.config = config 136 | this.secureFunction = new SecureFunction(this.#kernel, this) 137 | 138 | const l10n = this.l10n() 139 | Object.keys(l10n).forEach(language => { 140 | L10n.add(language, l10n[language]) 141 | }) 142 | } 143 | 144 | /** 145 | * 编辑动作状态下提供预览数据 146 | * @returns {ActionData} 147 | */ 148 | preview() { 149 | return { env: ActionEnv.build } 150 | } 151 | 152 | l10n() { 153 | return {} 154 | } 155 | 156 | /** 157 | * page sheet 158 | * @param {*} args 159 | * { 160 | view: 视图对象 161 | title: 中间标题 162 | done: 点击左上角按钮后的回调函数 163 | doneText: 左上角文本 164 | rightButtons: 右上角按钮 165 | } 166 | * @returns {Sheet} 167 | */ 168 | pageSheet({ view, title = "", done, doneText = $l10n("DONE"), rightButtons = [] }) { 169 | const sheet = new Sheet() 170 | sheet.setView(view).addNavBar({ 171 | title: title, 172 | popButton: { 173 | title: doneText, 174 | tapped: () => { 175 | if (done) done() 176 | } 177 | }, 178 | rightButtons 179 | }) 180 | sheet.init().present() 181 | return sheet 182 | } 183 | 184 | /** 185 | * 186 | * @param {Object} items 187 | * item: { 188 | * key: string, 189 | * value: string, 190 | * placeholder: string 191 | * } 192 | * @returns 193 | */ 194 | input(items) { 195 | const suffix = $text.uuid 196 | const views = [] 197 | for (let item of items) { 198 | views.push( 199 | { 200 | type: "label", 201 | props: { 202 | color: $color("secondaryText"), 203 | text: item.placeholder 204 | }, 205 | layout: (make, view) => { 206 | make.centerY.equalTo(view.super).offset(5) 207 | make.left.inset(15) 208 | } 209 | }, 210 | { 211 | type: "view", 212 | props: { bgcolor: $color("clear") }, 213 | views: [ 214 | { 215 | type: "input", 216 | props: { 217 | id: item.key + suffix, 218 | text: item.value, 219 | placeholder: item.placeholder 220 | }, 221 | layout: $layout.fill 222 | } 223 | ], 224 | layout: (make, view) => { 225 | make.centerY.height.equalTo(view.super) 226 | make.left.right.inset(15) 227 | } 228 | } 229 | ) 230 | } 231 | 232 | return new Promise((resolve, reject) => { 233 | this.pageSheet({ 234 | view: { 235 | type: "list", 236 | props: { 237 | separatorHidden: true, 238 | data: [{ rows: views }] 239 | } 240 | }, 241 | done: () => { 242 | const result = {} 243 | for (let item of items) { 244 | result[item.key] = $(item.key + suffix).text 245 | } 246 | resolve(result) 247 | } 248 | }) 249 | }) 250 | } 251 | 252 | showTextContent(text, title = "") { 253 | return this.pageSheet({ 254 | view: { 255 | type: "text", 256 | props: { text }, 257 | layout: $layout.fill 258 | }, 259 | title, 260 | rightButtons: [ 261 | { 262 | title: $l10n("COPY"), 263 | tapped: () => ($clipboard.text = text) 264 | } 265 | ] 266 | }) 267 | } 268 | 269 | showMarkdownContent(markdown, title = "") { 270 | return this.pageSheet({ 271 | view: { 272 | type: "markdown", 273 | props: { content: markdown }, 274 | layout: $layout.fill 275 | }, 276 | title 277 | }) 278 | } 279 | 280 | quickLookImage(image) { 281 | Sheet.quickLookImage(image) 282 | } 283 | 284 | /** 285 | * 获取所有剪切板数据 286 | * @returns {object} 287 | */ 288 | getAllClips() { 289 | return { 290 | favorite: this.#kernel.storage.all("favorite").map(item => item.text), 291 | clips: this.#kernel.storage.all("clips").map(item => item.text) 292 | } 293 | } 294 | 295 | async clearAllClips() { 296 | const res = await $ui.alert({ 297 | title: $l10n("DELETE_DATA"), 298 | message: $l10n("DELETE_TABLE").replaceAll("${table}", $l10n("CLIPS")), 299 | actions: [{ title: $l10n("DELETE"), style: $alertActionType.destructive }, { title: $l10n("CANCEL") }] 300 | }) 301 | if (res.index === 0) { 302 | // 确认删除 303 | try { 304 | this.#kernel.storage.deleteTable("clips") 305 | return true 306 | } catch (error) { 307 | this.#kernel.logger.error(error) 308 | throw error 309 | } 310 | } else { 311 | return false 312 | } 313 | } 314 | 315 | setContent(text) { 316 | if (this.env === ActionEnv.editor) { 317 | this.editor.setContent(text) 318 | } else if (this.env === ActionEnv.clipboard) { 319 | this.#kernel.storage.updateText(this.section, this.uuid, text) 320 | this.#kernel.clips.updateList(true) 321 | } 322 | } 323 | 324 | replaceKeyboardText(search, replacement) { 325 | if (this.env !== ActionEnv.keyboard || !this.text) { 326 | return 327 | } 328 | if (this.selectedText) { 329 | $keyboard.insert(replacement) 330 | return 331 | } 332 | 333 | const replaced = this.text.replace(search, replacement) 334 | const textAfterInput = this.textAfterInput 335 | if (textAfterInput && textAfterInput.length > 0) { 336 | $keyboard.moveCursor(textAfterInput.length) 337 | } 338 | while ($keyboard.hasText) { 339 | $keyboard.delete() 340 | } 341 | $keyboard.insert(replaced) 342 | } 343 | 344 | /** 345 | * 获取动作对象 346 | * @param {string} category 347 | * @param {string} name 348 | * @param {ActionData|Object} data 349 | * @returns 350 | */ 351 | getAction(category, name, data) { 352 | const dir = this.#kernel.actions.getActionDir(category, name) 353 | return this.#kernel.actions.getAction(category, dir, data) 354 | } 355 | 356 | async runAction(category, name, data = {}) { 357 | const action = this.getAction(category, name, { env: ActionEnv.recursion, ...data }) 358 | return await action.do() 359 | } 360 | 361 | async request(url, method, body, header) { 362 | return this.secureFunction.http.request({ url, method, body, header, timeout: 3 }) 363 | } 364 | 365 | getUrls() { 366 | const text = this.selectedText ?? this.text ?? "" 367 | 368 | const httpRegex = /https?:\/\/[\w-]+(\.[\w-]+)*([\p{Script=Han}\w.,@?^=%&:/~+#()\-]*[\w@?^=%&/~+#()\-])?/giu 369 | // 正则表达式用于匹配iOS URL Scheme(假设scheme后面是://),包括中文字符和括号 370 | const iosSchemeRegex = /\b\w+:\/\/[\w-]+(\.[\w-]+)*([\p{Script=Han}\w.,@?^=%&:/~+#()\-]*[\w@?^=%&/~+#()\-])?/giu 371 | 372 | // 使用正则表达式查找匹配项 373 | const httpUrls = text.match(httpRegex) || [] 374 | const iosUrls = text.match(iosSchemeRegex) || [] 375 | 376 | // 合并两个数组并去重 377 | const allUrls = [...new Set([...httpUrls, ...iosUrls])] 378 | 379 | return allUrls 380 | } 381 | 382 | aes(key, iv) { 383 | return new AES(key, iv) 384 | } 385 | 386 | addinRun(name, runDirectly = false) { 387 | if (runDirectly) { 388 | $addin.run(name) 389 | return 390 | } 391 | const script = this.#kernel.getAddin(name) 392 | if (script.name === script.diskName) { 393 | this.runJSBoxScript(name) 394 | } else { 395 | $addin.run(name) 396 | } 397 | } 398 | 399 | runJSBoxScript(name) { 400 | const script = this.#kernel.getAddin(name) // 此方法不会重新搜索 401 | const actionKey = "_" + $text.uuid.replace(/-/g, "") 402 | const ss = new SecureScript(script.data.string, actionKey) 403 | new Function("CAIO_ACTION", actionKey, `${ss.secure()}`)(this.config.name, this) 404 | } 405 | } 406 | 407 | module.exports = { 408 | ActionEnv, 409 | ActionData, 410 | Action 411 | } 412 | --------------------------------------------------------------------------------