// Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-glyph: folder-open; icon-color: red; share-sheet-inputs: file-url; /** * Clean files * * @version 2.3.1 * @author Honye */ /** * @version 1.2.2 */ /** * 多语言国际化 * @param {{[language: string]: string} | [en:string, zh:string]} langs */ const i18n = (langs) => { const language = Device.language(); if (Array.isArray(langs)) { langs = { en: langs[0], zh: langs[1], others: langs[0] }; } else { langs.others = langs.others || langs.en; } return langs[language] || langs.others }; /** * @file Scriptable WebView JSBridge native SDK * @version 1.0.3 * @author Honye */ /** * @typedef Options * @property {Record void>} methods */ const sendResult = (() => { let sending = false; /** @type {{ code: string; data: any }[]} */ const list = []; /** * @param {WebView} webView * @param {string} code * @param {any} data */ return async (webView, code, data) => { if (sending) return sending = true; list.push({ code, data }); const arr = list.splice(0, list.length); for (const { code, data } of arr) { const eventName = `ScriptableBridge_${code}_Result`; const res = data instanceof Error ? { err: data.message } : data; await webView.evaluateJavaScript( `window.dispatchEvent( new CustomEvent( '${eventName}', { detail: ${JSON.stringify(res)} } ) )` ); } if (list.length) { const { code, data } = list.shift(); sendResult(webView, code, data); } else { sending = false; } } })(); /** * @param {WebView} webView * @param {Options} options */ const inject = async (webView, options) => { const js = `(() => { const queue = window.__scriptable_bridge_queue if (queue && queue.length) { completion(queue) } window.__scriptable_bridge_queue = null if (!window.ScriptableBridge) { window.ScriptableBridge = { invoke(name, data, callback) { const detail = { code: name, data } const eventName = \`ScriptableBridge_\${name}_Result\` const controller = new AbortController() window.addEventListener( eventName, (e) => { callback && callback(e.detail) controller.abort() }, { signal: controller.signal } ) if (window.__scriptable_bridge_queue) { window.__scriptable_bridge_queue.push(detail) completion() } else { completion(detail) window.__scriptable_bridge_queue = [] } } } window.dispatchEvent( new CustomEvent('ScriptableBridgeReady') ) } })()`; const res = await webView.evaluateJavaScript(js, true); if (!res) return inject(webView, options) const methods = options.methods || {}; const events = Array.isArray(res) ? res : [res]; // 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题 // 可能是因为 JavaScript 是单线程导致的 const sendTasks = events.map(({ code, data }) => { return (() => { try { return Promise.resolve(methods[code](data)) } catch (e) { return Promise.reject(e) } })() .then((res) => sendResult(webView, code, res)) .catch((e) => { console.error(e); sendResult(webView, code, e instanceof Error ? e : new Error(e)); }) }); await Promise.all(sendTasks); inject(webView, options); }; /** * @param {WebView} webView * @param {object} args * @param {string} args.html * @param {string} [args.baseURL] * @param {Options} options */ const loadHTML = async (webView, args, options = {}) => { const { html, baseURL } = args; await webView.loadHTML(html, baseURL); inject(webView, options).catch((err) => console.error(err)); }; const fm = FileManager.local(); const usedICloud = fm.isFileStoredIniCloud(module.filename); /** * @param {string[]} fileURLs * @param {string} destPath */ const copyFiles = async (fileURLs, destPath) => { let isReplaceAll = false; const fm = FileManager.local(); for (const fileURL of fileURLs) { const fileName = fm.fileName(fileURL, true); const filePath = fm.joinPath(destPath, fileName); if (fm.fileExists(filePath)) { if (isReplaceAll) { fm.remove(filePath); } else { const alert = new Alert(); alert.message = `“${fileName}”${i18n([' already exists. Do you want to replace it?', '已存在,是否替换?'])}`; const actions = [i18n(['All Yes', '全是']), i18n(['Yes', '是']), i18n(['No', '否'])]; for (const action of actions) alert.addAction(action); alert.addCancelAction(i18n(['Cancel', '取消'])); const value = await alert.present(); switch (actions[value]) { case i18n(['All Yes', '全是']): isReplaceAll = true; fm.remove(filePath); break case i18n(['Yes', '是']): fm.remove(filePath); break case i18n(['No', '否']): continue default: // 取消 return } } } fm.copy(fileURL, filePath); } const alert = new Alert(); alert.title = i18n(['Import successful', '导入成功']); alert.message = i18n(['Re-enter this directory to view', '重新进入此目录可查看']); alert.addCancelAction(i18n(['OKay', '好的'])); await alert.present(); }; /** * @param {string} destPath 输出目录 */ const importFiles = async (destPath) => { let fileURLs = args.fileURLs; if (!fileURLs.length) { try { fileURLs = await DocumentPicker.open(); } catch (e) { // 用户取消 return } } await copyFiles(fileURLs, destPath); }; /** * @param {object} options * @param {string} options.title * @param {File[]} options.list * @param {string} [options.directory] */ const presentList = async (options) => { const { title, list, directory } = options; const webView = new WebView(); const css = `:root { --text-primary: #1e1f24; --text-secondary: #8b8d98; --color-primary: #007aff; --color-danger: #ea3939; --divider-color: #eff0f3; --card-background: #fff; --card-radius: 10px; --list-header-color: rgba(60,60,67,0.6); --bg-btn: rgba(4, 122, 246, 0.1); --fixed-btn-height: 3rem; } * { -webkit-user-select: none; user-select: none; } body { margin: 0; -webkit-font-smoothing: antialiased; font-family: "SF Pro Display","SF Pro Icons","Helvetica Neue","Helvetica","Arial",sans-serif; min-height: 100vh; box-sizing: border-box; accent-color: var(--color-primary); padding-top: env(safe-area-inset-top); background-color: var(--card-background); color: var(--text-primary); } .header { position: sticky; z-index: 99; top: env(safe-area-inset-top); left: 0; right: 0; height: 3.5rem; text-align: center; background: var(--card-background); display: flex; align-items: center; padding: 0 1rem; } .header__left, .header__right { flex: 1; min-width: 6rem; } .header__left { text-align: left; } .header__right { text-align: right; } .header__btn, .select-all, .select { height: 1.5rem; font-size: 0.875rem; background-color: var(--bg-btn); border-radius: 99px; border: none; } .title { font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .list { padding: 0; margin: 0; list-style: none; } .icon-yuan { color: #666; } .icon-gouxuan { color: var(--color-primary); } .item { padding-left: 1rem; } .item, .item__body { flex: 1; display: flex; align-items: center; overflow: hidden; } .item__body { column-gap: 0.5rem; } .item__selection { font-size: 0; width: 0; height: 1.5rem; transition: all .3s; } .item__selection[hidden] { display: none; } .item__name { font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .item__content { flex: 1; padding: 0.75rem 0; border-bottom: 0.5px solid var(--divider-color); } .item__info { margin-top: 0.3rem; font-size: 0.75rem; color: var(--text-secondary); } .list-select .item__selection { margin-right: 0.5rem; width: 1.5rem; font-size: 1.5rem; } .fixed-bottom { position: fixed; z-index: 10; bottom: 0; left: 0; right: 0; padding-bottom: env(safe-area-inset-bottom); background: var(--card-background); border-top: 0.5px solid var(--divider-color); transform: translateY(100%); transition: all 0.25s; } .btn-del { margin: 0; display: flex; width: 100%; height: var(--fixed-btn-height); justify-content: center; align-items: center; column-gap: 0.125rem; font-size: 0.875rem; background-color: var(--card-background); color: var(--color-danger); padding: 0; border: none; } .fixed-bottom.show { transform: translateY(0); } .bottom-holder { margin-top: 2rem; box-sizing: content-box; height: var(--fixed-btn-height); padding-bottom: env(safe-area-inset-bottom); } @media (prefers-color-scheme: dark) { :root { --color-danger: #b22e2e; --text-primary: #fff; --text-secondary: #797b86; --divider-color: #292a2e; --card-background: #19191b; --list-header-color: rgba(235,235,245,0.6); --bg-btn: rgba(9, 109, 215, 0.1); } }`; const js = `window.invoke = (code, data) => { ScriptableBridge.invoke(code, data) } const isSelectMode = () => { return document.querySelector('.list').classList.contains('list-select') } const removeItems = (items) => { const list = document.querySelector('.list') for (const item of items) { const el = document.querySelector(\`.item[data-name="\${item.name}"]\`) el.parentNode.remove() } } document.querySelector('.select').addEventListener('click', (e) => { /** @type {HTMLButtonElement} */ const target = e.currentTarget target.innerText = target.innerText === ${i18n(['"Select"', '"选择"'])} ? ${i18n(['"Done"', '"完成"'])} : ${i18n(['"Select"', '"选择"'])} document.querySelector('.select-all').toggleAttribute('hidden') document.querySelector('#import')?.toggleAttribute('hidden') document.querySelector('.list').classList.toggle('list-select') document.querySelector('.fixed-bottom').classList.toggle('show') }) document.querySelectorAll('.item') .forEach((el) => { el.addEventListener('click', (e) => { const target = e.currentTarget if (isSelectMode()) { target.querySelectorAll('.item__selection').forEach((el) => { el.toggleAttribute('hidden') }) } else { const { name } = target.dataset invoke('view', JSON.parse(JSON.stringify(target.dataset))) } }) }) document.querySelector('.select-all').addEventListener('click', (e) => { /** @type {HTMLButtonElement} */ const target = e.currentTarget const isSelected = target.innerText === ${i18n(['"Deselect All"', '"取消全选"'])} target.innerText = isSelected ? ${i18n(['"Select All"', '"全选"'])} : ${i18n(['"Deselect All"', '"取消全选"'])} document.querySelectorAll('.item__selection').forEach((e, i) => { if (isSelected) { if (i % 2) { e.setAttribute('hidden', '') } else { e.removeAttribute('hidden') } } else { if (i % 2) { e.removeAttribute('hidden') } else { e.setAttribute('hidden', '') } } }) }) document.querySelector('.fixed-bottom').addEventListener('click', () => { const selectedItems = [] for (const el of document.querySelectorAll('.item__selection:nth-child(even):not([hidden])')) { selectedItems.push({ ...el.parentNode.dataset }) } invoke('remove', selectedItems) }) window.addEventListener('JWeb', (e) => { const { code, data } = e.detail switch (code) { case 'remove-success': removeItems(JSON.parse(data)) break; } })`; const html = ` ${title}
${directory ? `` : '' }

${title}

`; const view = async (data) => { const { isDirectory, filePath, name } = data; if (Number(isDirectory)) { const unit = i18n(['items', '项']); const list = fm.listContents(filePath) .map((name) => { const path = fm.joinPath(filePath, name); const isDirectory = fm.isDirectory(path); const date = fm.modificationDate(path).toLocaleDateString('zh-CN'); const size = fm.fileSize(path); return { name, info: isDirectory ? `${date} - ${fm.listContents(path).length} ${unit}` : `${date} - ${size > 1024 ? `${(size / 1024).toFixed(1)} MB` : `${size} KB`}`, filePath: path, isDirectory } }); presentList({ title: name, list, directory: filePath }); } else { if (!fm.isFileDownloaded(filePath)) { await fm.downloadFileFromiCloud(filePath); } if (/.(js|json)$/.test(filePath)) { QuickLook.present(filePath); return } if (/.(jpg|jpeg|gif|png|heic|heif|webp)$/i.test(filePath)) { QuickLook.present(filePath, false); return } try { const image = fm.readImage(filePath); QuickLook.present(image, false); return } catch (e) { console.warn(e); } try { const text = fm.readString(filePath); QuickLook.present(text); return } catch (e) { console.warn(e); } QuickLook.present(filePath); } }; const remove = async (list) => { for (const file of list) { fm.remove(file.filePath); } webView.evaluateJavaScript( `window.dispatchEvent(new CustomEvent( 'JWeb', { detail: { code: 'remove-success', data: '${JSON.stringify(list)}' } } ))`, false ); }; await loadHTML( webView, { html, baseURL: 'https://scriptore.imarkr.com/scriptables/Clean%20Files%202' }, { methods: { view, remove, import: () => importFiles(directory) } } ); webView.present(); }; /** @type {File[]} */ const directories = [ { name: i18n(['Local Cache', '本地缓存']), filePath: FileManager.local().cacheDirectory(), isDirectory: true }, { name: i18n(['Local Documents', '本地文件']), filePath: FileManager.local().documentsDirectory(), isDirectory: true }, { name: i18n(['Local Library', '本地库存']), filePath: FileManager.local().libraryDirectory(), isDirectory: true }, { name: i18n(['Local Temporary', '本地暂存']), filePath: FileManager.local().temporaryDirectory(), isDirectory: true } ]; if (usedICloud) { directories.push( { name: i18n(['iCloud Documents', 'iCloud 文件']), filePath: FileManager.iCloud().documentsDirectory(), isDirectory: true }, { name: i18n(['iCloud Library', 'iCloud 库存']), filePath: FileManager.iCloud().libraryDirectory(), isDirectory: true } ); } presentList({ title: 'Clean Files', list: directories }); /** * @typedef {object} File * @property {string} File.name * @property {string} [File.info] * @property {string} File.filePath * @property {boolean} File.isDirectory */