diff --git a/docs/live-folders-specs.md b/docs/live-folders-specs.md index ea839e1bd6..cb2284f92b 100644 --- a/docs/live-folders-specs.md +++ b/docs/live-folders-specs.md @@ -94,6 +94,7 @@ Remote APIs can return any JSON, but the Live Folder must provide a mapping conf { "type": "rest", "url": "https://api.example.com/posts", + "params": {}, "mapping": { "items": "data.posts", "id": "id", @@ -103,6 +104,8 @@ Remote APIs can return any JSON, but the Live Folder must provide a mapping conf } ``` +`params` (optional): Object for URL template substitution (`{key}` → `params.key`) and query parameters (unused keys appended as `?key=value`). + ### Installation of REST API Live Folder These schemas would be stored inside a marketplace on Zen's web platform, allowing users to easily discover and integrate new REST API Live Folders into their workspace. @@ -110,3 +113,38 @@ These schemas would be stored inside a marketplace on Zen's web platform, allowi If the user wants to create a new REST API Live Folder, they can do so by providing the necessary schema and configuration through the marketplace interface. This will enable them to customize the folder's behavior and data mapping according to their specific needs. If it's a custom API and the schema is not publicly available, users can still create a Live Folder by defining their own mapping configuration. This allows them to integrate with proprietary APIs while adhering to Zen's Live Folder standards. This mapping configuration will be fetched via `https://example.com/zen-live-folder.schema.json`. + +--- + +## Example Configurations + +### Gitea + +Uses the [Gitea Issues API](https://docs.gitea.com/api/#tag/issue). Replace `gitea.example.com`, `owner`, and `repo` with your Gitea instance, org/user, and repository. Use `type: "issues"` or `type: "pulls"` for issues vs pull requests. For private repos, add `"Authorization": "token YOUR_TOKEN"` to `headers`; otherwise use `"headers": {}` to rely on session cookies when logged in. + +**Issues:** + +```json +{ + "url": "https://gitea.example.com/api/v1/repos/{owner}/{repo}/issues", + "params": { + "owner": "my-org", + "repo": "my-project", + "state": "open", + "type": "issues" + }, + "label": "Gitea Issues", + "icon": "favicon", + "headers": {}, + "mapping": { + "items": "", + "id": "id", + "title": "title", + "url": "html_url", + "subtitle": "user.login" + }, + "maxItems": 50 +} +``` + +**Pull requests:** change `params.type` to `"pulls"` and the label to `"Gitea Pull Requests"`. diff --git a/locales/en-US/browser/browser/zen-live-folders.ftl b/locales/en-US/browser/browser/zen-live-folders.ftl index 79988c1dce..eaaf18a84e 100644 --- a/locales/en-US/browser/browser/zen-live-folders.ftl +++ b/locales/en-US/browser/browser/zen-live-folders.ftl @@ -99,3 +99,23 @@ zen-live-folder-github-option-repo-list-note = zen-live-folders-promotion-title = Live Folder Created! zen-live-folders-promotion-description = Latest content from your RSS feeds or GitHub pull requests will appear here automatically. + +zen-live-folder-rest-custom = + .label = Custom REST API… + +zen-live-folder-rest-dialog-title = Create Custom REST Live Folder +zen-live-folder-rest-dialog-edit-title = Edit REST Live Folder +zen-live-folder-rest-dialog-save = Save +zen-live-folder-rest-dialog-config = Configuration (JSON) +zen-live-folder-rest-dialog-hint = Include: url, params (optional, for {placeholder} substitution and query string), label, icon (optional, use "favicon"), headers, mapping +zen-live-folder-rest-dialog-create = Create +zen-live-folder-rest-option-headers = + .label = Edit headers + +zen-live-folder-rest-option-edit-config = + .label = Edit configuration… +zen-live-folder-rest-prompt-headers = Enter HTTP headers as JSON (e.g. {"Authorization": "Bearer token"}): +zen-live-folder-rest-dialog-cancel = Cancel +zen-live-folder-rest-invalid-json = Invalid mapping JSON. +zen-live-folder-rest-invalid-url = Invalid or unsupported URL. +zen-live-folder-rest-invalid-headers = Invalid headers JSON. diff --git a/src/browser/base/content/zen-panels/popups.inc b/src/browser/base/content/zen-panels/popups.inc index 2ff92383d4..51b80e6089 100644 --- a/src/browser/base/content/zen-panels/popups.inc +++ b/src/browser/base/content/zen-panels/popups.inc @@ -3,6 +3,27 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + + + + + + diff --git a/src/zen/common/styles/zen-panels/dialog.css b/src/zen/common/styles/zen-panels/dialog.css index e25f3e8d79..6c2ade2440 100644 --- a/src/zen/common/styles/zen-panels/dialog.css +++ b/src/zen/common/styles/zen-panels/dialog.css @@ -21,3 +21,96 @@ transform: translateY(-10%); } } + +.zen-rest-live-folder-dialog .zen-rest-dialog-title { + margin: 0 0 16px; + font-size: 1.25em; +} + +.zen-rest-live-folder-dialog .zen-rest-dialog-hint { + margin: 8px 0 0; + font-size: 0.9em; + color: var(--input-placeholder-color, light-dark(#8f8f9d, #737373)); +} + +.zen-rest-live-folder-dialog { + padding: 20px; + min-width: 400px; + max-width: 90vw; + background: var(--arrowpanel-background, light-dark(#fff, #1c1b22)); + color: var(--panel-color, light-dark(#15141b, #fbfbfe)); + border-radius: 12px; + border: 1px solid var(--panel-border-color, light-dark(#cfcfd8, #43434a)); + box-shadow: var(--panel-shadow, 0 4px 16px rgba(0, 0, 0, 0.2)); +} + +.zen-rest-live-folder-dialog::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +.zen-rest-live-folder-dialog label { + display: block; + margin-block-end: 4px; + font-weight: 500; + color: inherit; +} + +.zen-rest-live-folder-dialog input, +.zen-rest-live-folder-dialog textarea { + display: block; + width: 100%; + margin-block-end: 16px; + padding: 8px 12px; + box-sizing: border-box; + background: var(--input-bgcolor, light-dark(#fff, #2b2a33)); + color: var(--input-color, light-dark(#15141b, #fbfbfe)); + border: 1px solid var(--input-border-color, light-dark(#8f8f9d, #52525e)); + border-radius: 6px; + font: inherit; +} + +.zen-rest-live-folder-dialog input::placeholder, +.zen-rest-live-folder-dialog textarea::placeholder { + color: var(--input-placeholder-color, light-dark(#8f8f9d, #737373)); +} + +.zen-rest-live-folder-dialog textarea { + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace; + font-size: 13px; + min-height: 120px; + resize: vertical; +} + +.zen-rest-dialog-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-block-start: 16px; +} + +.zen-rest-dialog-create { + padding: 8px 16px; + cursor: pointer; + background: var(--zen-colors-primary, #0060df); + color: #fff; + border: none; + border-radius: 6px; + font-weight: 500; +} + +.zen-rest-dialog-create:hover { + background: var(--zen-colors-primary-hover, #0250bb); +} + +.zen-rest-dialog-cancel { + padding: 8px 16px; + cursor: pointer; + background: transparent; + color: var(--panel-color, inherit); + border: 1px solid var(--input-border-color, light-dark(#8f8f9d, #52525e)); + border-radius: 6px; +} + +.zen-rest-dialog-cancel:hover { + background: var(--input-bgcolor, light-dark(rgba(12, 12, 13, 0.07), rgba(255, 255, 255, 0.08))); +} diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs index 3267d579fe..18f1ad097d 100644 --- a/src/zen/folders/ZenFolders.mjs +++ b/src/zen/folders/ZenFolders.mjs @@ -76,7 +76,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature { document.getElementById("context_moveTabToGroup").before(contextMenuItems); const contextMenuItemsToolbar = window.MozXULElement.parseXULToFragment( ` - + + ` ); @@ -98,7 +102,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature { .after(contextMenuItemsToolbar); const folderActionsMenu = document.getElementById("zenFolderActions"); - folderActionsMenu.addEventListener("popupshowing", event => { + folderActionsMenu.addEventListener("popupshowing", async event => { const target = event.explicitOriginalTarget; let folder; if (gBrowser.isTabGroupLabel(target)) { @@ -117,7 +121,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature { return; } this.#lastFolderContextMenu = folder; - gZenLiveFoldersUI.buildContextMenu(folder); + await gZenLiveFoldersUI.buildContextMenu(folder); const newSubfolderItem = document.getElementById( "context_zenFolderNewSubfolder" diff --git a/src/zen/live-folders/LiveFoldersComponents.manifest b/src/zen/live-folders/LiveFoldersComponents.manifest index 885d2690d1..6a3f48527f 100644 --- a/src/zen/live-folders/LiveFoldersComponents.manifest +++ b/src/zen/live-folders/LiveFoldersComponents.manifest @@ -3,4 +3,3 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. category browser-window-delayed-startup resource:///modules/zen/ZenLiveFoldersManager.sys.mjs ZenLiveFoldersManager.init -category browser-quit-application-granted resource:///modules/zen/ZenLiveFoldersManager.sys.mjs ZenLiveFoldersManager.uninit diff --git a/src/zen/live-folders/RestLiveFolderDialog.sys.mjs b/src/zen/live-folders/RestLiveFolderDialog.sys.mjs new file mode 100644 index 0000000000..0ed742e2bf --- /dev/null +++ b/src/zen/live-folders/RestLiveFolderDialog.sys.mjs @@ -0,0 +1,270 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +const DEFAULT_CONFIG_JSON = `{ + "url": "https://api.example.com/items", + "params": {}, + "label": "", + "icon": "", + "headers": {}, + "mapping": { + "items": "", + "id": "id", + "title": "title", + "url": "url", + "subtitle": "author" + }, + "maxItems": 100 +}`; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", +}); + +/** + * Builds the config JSON object for a REST live folder. + * + * @param {object} state - The live folder state. + * @returns {string} - JSON string of the config. + */ +function configToJson(state) { + const config = { + url: state.url ?? "https://api.example.com/items", + params: state.params ?? {}, + label: state.label ?? "", + icon: state.icon ?? "", + headers: state.headers ?? {}, + mapping: state.mapping ?? { + items: "", + id: "id", + title: "title", + url: "url", + subtitle: "author", + }, + maxItems: state.maxItems ?? 100, + }; + return JSON.stringify(config, null, 2); +} + +/** + * Opens the Custom REST API Live Folder creation or edit dialog. + * The dialog shows a single JSON editor with the full config object. + * + * @param {Window} win - The browser window. + * @param {object} [options] - Optional options. + * @param {object} [options.liveFolder] - If provided, edit mode: pre-fill with this folder's config and update on save. + * @returns {Promise} - Resolves to true if a folder was created/updated, false if cancelled. + */ +export async function openRestLiveFolderDialog(win, options = {}) { + const { liveFolder } = options; + const isEditMode = !!liveFolder; + + const doc = win.document; + const dialog = doc.createElementNS("http://www.w3.org/1999/xhtml", "dialog"); + dialog.setAttribute("id", "zen-rest-live-folder-dialog"); + dialog.className = "zen-rest-live-folder-dialog"; + + const form = doc.createElementNS("http://www.w3.org/1999/xhtml", "form"); + form.method = "dialog"; + + const titleEl = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + titleEl.className = "zen-rest-dialog-title"; + + const configLabel = doc.createElementNS("http://www.w3.org/1999/xhtml", "label"); + configLabel.setAttribute("data-l10n-id", "zen-live-folder-rest-dialog-config"); + configLabel.htmlFor = "zen-rest-dialog-config"; + const configTextarea = doc.createElementNS("http://www.w3.org/1999/xhtml", "textarea"); + configTextarea.id = "zen-rest-dialog-config"; + configTextarea.rows = 20; + configTextarea.spellcheck = false; + configTextarea.value = isEditMode ? configToJson(liveFolder.state) : DEFAULT_CONFIG_JSON; + + const hintEl = doc.createElementNS("http://www.w3.org/1999/xhtml", "p"); + hintEl.className = "zen-rest-dialog-hint"; + + const buttons = doc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + buttons.className = "zen-rest-dialog-buttons"; + const createBtn = doc.createElementNS("http://www.w3.org/1999/xhtml", "button"); + createBtn.type = "submit"; + createBtn.setAttribute( + "data-l10n-id", + isEditMode ? "zen-live-folder-rest-dialog-save" : "zen-live-folder-rest-dialog-create" + ); + createBtn.className = "zen-rest-dialog-create"; + const cancelBtn = doc.createElementNS("http://www.w3.org/1999/xhtml", "button"); + cancelBtn.type = "button"; + cancelBtn.setAttribute("data-l10n-id", "zen-live-folder-rest-dialog-cancel"); + cancelBtn.className = "zen-rest-dialog-cancel"; + buttons.appendChild(createBtn); + buttons.appendChild(cancelBtn); + + form.appendChild(titleEl); + form.appendChild(configLabel); + form.appendChild(configTextarea); + form.appendChild(hintEl); + form.appendChild(buttons); + dialog.appendChild(form); + + doc.documentElement.appendChild(dialog); + + const titleId = isEditMode + ? "zen-live-folder-rest-dialog-edit-title" + : "zen-live-folder-rest-dialog-title"; + const createId = isEditMode + ? "zen-live-folder-rest-dialog-save" + : "zen-live-folder-rest-dialog-create"; + const ids = [ + titleId, + "zen-live-folder-rest-dialog-config", + createId, + "zen-live-folder-rest-dialog-cancel", + "zen-live-folder-rest-dialog-hint", + ]; + let titleStr; + let configLabelStr; + let createLabelStr; + let cancelLabelStr; + let hintStr; + try { + [titleStr, configLabelStr, createLabelStr, cancelLabelStr, hintStr] = + await doc.l10n.formatValues(ids); + } catch { + titleStr = isEditMode ? "Edit REST Live Folder" : "Create Custom REST Live Folder"; + configLabelStr = "Configuration (JSON)"; + createLabelStr = isEditMode ? "Save" : "Create"; + cancelLabelStr = "Cancel"; + hintStr = + "Include: url, params (optional, for {placeholder} substitution and query string), label, icon (optional, use \"favicon\"), headers, mapping"; + } + + const fallback = (s, d) => (s != null && s !== "" ? s : d); + dialog.setAttribute("aria-label", fallback(titleStr, "Edit REST Live Folder")); + titleEl.textContent = fallback(titleStr, isEditMode ? "Edit REST Live Folder" : "Create Custom REST Live Folder"); + configLabel.textContent = fallback(configLabelStr, "Configuration (JSON)"); + createBtn.textContent = fallback(createLabelStr, isEditMode ? "Save" : "Create"); + cancelBtn.textContent = fallback(cancelLabelStr, "Cancel"); + hintEl.textContent = fallback( + hintStr, + 'Include: url, label, icon (optional, use "favicon" for favicon from API origin), headers, mapping' + ); + + return new Promise((resolve) => { + function cleanup() { + dialog.remove(); + } + + cancelBtn.addEventListener("click", () => { + dialog.close(); + cleanup(); + resolve(false); + }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + let config; + try { + config = JSON.parse(configTextarea.value || "{}"); + } catch { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-json", { + timeout: 4000, + }); + return; + } + + const url = config.url; + if (!url || typeof url !== "string") { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-url", { + timeout: 4000, + }); + return; + } + + try { + new URL(url); + } catch { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-url", { + timeout: 4000, + }); + return; + } + + const protocol = new URL(url).protocol; + if (protocol !== "http:" && protocol !== "https:") { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-url", { + timeout: 4000, + }); + return; + } + + const mapping = config.mapping; + if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-json", { + timeout: 4000, + }); + return; + } + + const required = ["items", "id", "title", "url"]; + for (const key of required) { + if (mapping[key] === undefined || mapping[key] === null) { + win.gZenUIManager?.showToast?.("zen-live-folder-rest-invalid-json", { + timeout: 4000, + }); + return; + } + } + + let headers = {}; + if (config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)) { + for (const [k, v] of Object.entries(config.headers)) { + if (k && v != null && typeof v === "string") { + headers[k] = v; + } + } + } + + let params = {}; + if (config.params && typeof config.params === "object" && !Array.isArray(config.params)) { + for (const [k, v] of Object.entries(config.params)) { + if (k && (v == null || typeof v === "string")) { + params[k] = v; + } + } + } + + const createConfig = { + url, + params: Object.keys(params).length > 0 ? params : undefined, + mapping, + label: config.label && typeof config.label === "string" ? config.label : undefined, + icon: config.icon && typeof config.icon === "string" ? config.icon : undefined, + headers: Object.keys(headers).length > 0 ? headers : undefined, + maxItems: + config.maxItems != null && Number.isFinite(config.maxItems) ? config.maxItems : undefined, + }; + + let success = false; + if (isEditMode) { + success = await lazy.ZenLiveFoldersManager.updateFolderFromRestConfig( + liveFolder.id, + createConfig + ); + } else { + const created = await lazy.ZenLiveFoldersManager.createFolderFromRestConfig( + win, + createConfig + ); + success = created !== -1; + } + + dialog.close(); + cleanup(); + resolve(success); + }); + + dialog.showModal(); + }); +} diff --git a/src/zen/live-folders/ZenLiveFolder.sys.mjs b/src/zen/live-folders/ZenLiveFolder.sys.mjs index 54ed5d2c1e..aee08e98e1 100644 --- a/src/zen/live-folders/ZenLiveFolder.sys.mjs +++ b/src/zen/live-folders/ZenLiveFolder.sys.mjs @@ -5,8 +5,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { NetUtil: "resource://gre/modules/NetUtil.sys.mjs", DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", - NetworkHelper: - "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", + NetworkHelper: "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", }); export class nsZenLiveFolderProvider { @@ -136,10 +135,7 @@ export class nsZenLiveFolderProvider { userContextId = space.containerTabId || 0; } } - const principal = Services.scriptSecurityManager.createContentPrincipal( - uri, - { userContextId } - ); + const principal = Services.scriptSecurityManager.createContentPrincipal(uri, { userContextId }); const channel = lazy.NetUtil.newChannel({ uri, @@ -150,13 +146,20 @@ export class nsZenLiveFolderProvider { contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, loadingPrincipal: principal, securityFlags: - Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL | - Ci.nsILoadInfo.SEC_COOKIES_INCLUDE, + Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT | Ci.nsILoadInfo.SEC_COOKIES_INCLUDE, triggeringPrincipal: principal, }).QueryInterface(Ci.nsIHttpChannel); - for (const [name, value] of Object.entries(headers)) { - channel.setRequestHeader(name, value, false); + if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (key && value != null && typeof value === "string") { + try { + channel.setRequestHeader(key, value, false); + } catch (ex) { + // Ignore invalid or forbidden headers + } + } + } } let httpStatus = null; @@ -177,7 +180,7 @@ export class nsZenLiveFolderProvider { byteChunks.push(lazy.NetUtil.readInputStream(stream, count)); } }, - onStartRequest: request => { + onStartRequest: (request) => { const http = request.QueryInterface(Ci.nsIHttpChannel); try { @@ -190,10 +193,7 @@ export class nsZenLiveFolderProvider { contentType = http.getResponseHeader("content-type"); } catch (ex) {} - if ( - contentType && - !lazy.NetworkHelper.isTextMimeType(contentType.split(";")[0].trim()) - ) { + if (contentType && !lazy.NetworkHelper.isTextMimeType(contentType.split(";")[0].trim())) { request.cancel(Cr.NS_ERROR_FILE_UNKNOWN_TYPE); } @@ -226,9 +226,7 @@ export class nsZenLiveFolderProvider { let effectiveCharset = "utf-8"; - const mimeType = contentType - ? contentType.split(";")[0].trim().toLowerCase() - : ""; + const mimeType = contentType ? contentType.split(";")[0].trim().toLowerCase() : ""; if (mimeType === "text/html") { effectiveCharset = this.sniffCharset(bytes, headerCharset); } else if (headerCharset) { @@ -265,12 +263,7 @@ export class nsZenLiveFolderProvider { */ sniffCharset(bytes, headerCharset = "") { // 1. BOM detection (highest priority) - if ( - bytes.length >= 3 && - bytes[0] === 0xef && - bytes[1] === 0xbb && - bytes[2] === 0xbf - ) { + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { return "utf-8"; } if (bytes.length >= 2) { @@ -287,9 +280,7 @@ export class nsZenLiveFolderProvider { // is more likely to be correct. try { const headLen = Math.min(bytes.length, 8192); - const head = new TextDecoder("windows-1252").decode( - bytes.subarray(0, headLen) - ); + const head = new TextDecoder("windows-1252").decode(bytes.subarray(0, headLen)); const metaCharsetRegex = / !itemId.startsWith(prefix) - ) + Array.from(this.dismissedItems).filter((itemId) => !itemId.startsWith(prefix)) ); if (deleteFolder) { @@ -398,9 +453,7 @@ class nsZenLiveFoldersManager { } // itemid -> id:itemid - const itemIds = new Set( - items.map(item => this.#makeCompositeId(liveFolder.id, item.id)) - ); + const itemIds = new Set(items.map((item) => this.#makeCompositeId(liveFolder.id, item.id))); const outdatedTabs = []; const existingItemIds = new Set(); @@ -424,18 +477,10 @@ class nsZenLiveFoldersManager { animate: !folder.collapsed, }); - // Remove the dismissed items that are no longer in the given list. - // Only do this when the fetch returned results — an empty list may - // indicate a transient failure (e.g. auth expired, HTML changed) - // and we must not wipe all dismissals in that case. - if (itemIds.size > 0) { - for (const dismissedItemId of this.dismissedItems) { - if ( - dismissedItemId.startsWith(`${liveFolder.id}:`) && - !itemIds.has(dismissedItemId) - ) { - this.dismissedItems.delete(dismissedItemId); - } + // Remove the dismissed items that are no longer in the given list + for (const dismissedItemId of this.dismissedItems) { + if (dismissedItemId.startsWith(`${liveFolder.id}:`) && !itemIds.has(dismissedItemId)) { + this.dismissedItems.delete(dismissedItemId); } } @@ -449,14 +494,11 @@ class nsZenLiveFoldersManager { // Only add the items that are not already in the folder and was not dismissed by the user const newItems = items - .filter(item => { + .filter((item) => { const compositeId = this.#makeCompositeId(liveFolder.id, item.id); - return ( - !existingItemIds.has(compositeId) && - !this.dismissedItems.has(compositeId) - ); + return !existingItemIds.has(compositeId) && !this.dismissedItems.has(compositeId); }) - .map(item => { + .map((item) => { const tab = this.window.gBrowser.addTrustedTab(item.url, { createLazyBrowser: true, inBackground: true, @@ -467,9 +509,6 @@ class nsZenLiveFoldersManager { }); // createLazyBrowser can't be pinned by default this.window.gBrowser.pinTab(tab); - if (userContextId) { - tab.setAttribute("zenDefaultUserContextId", "true"); - } if (item.icon) { this.window.gBrowser.setIcon(tab, item.icon); if (tab.linkedBrowser) { @@ -478,10 +517,7 @@ class nsZenLiveFoldersManager { }); } } - tab.setAttribute( - "zen-live-folder-item-id", - this.#makeCompositeId(liveFolder.id, item.id) - ); + tab.setAttribute("zen-live-folder-item-id", this.#makeCompositeId(liveFolder.id, item.id)); if (item.subtitle) { tab.setAttribute("zen-show-sublabel", item.subtitle); const tabLabel = tab.querySelector(".zen-tab-sublabel"); @@ -534,10 +570,7 @@ class nsZenLiveFoldersManager { if (!this.window) { return null; } - const folder = lazy.ZenWindowSync.getItemFromWindow( - this.window, - liveFolder.id - ); + const folder = lazy.ZenWindowSync.getItemFromWindow(this.window, liveFolder.id); if (folder?.isZenFolder) { return folder; } @@ -582,7 +615,7 @@ class nsZenLiveFoldersManager { let data = []; for (let [id, liveFolder] of this.liveFolders) { const prefix = `${id}:`; - const dismissedItems = Array.from(this.dismissedItems).filter(itemId => + const dismissedItems = Array.from(this.dismissedItems).filter((itemId) => itemId.startsWith(prefix) ); @@ -634,7 +667,7 @@ class nsZenLiveFoldersManager { continue; } - const folder = folders.find(x => x.id === entry.id); + const folder = folders.find((x) => x.id === entry.id); if (!folder) { // No point restore if the live folder can't find its folder continue; @@ -651,7 +684,7 @@ class nsZenLiveFoldersManager { liveFolder.tabsState = entry.tabsState || []; liveFolder.state.lastErrorId = entry.data.state.lastErrorId; if (entry.dismissedItems && Array.isArray(entry.dismissedItems)) { - entry.dismissedItems.forEach(id => this.dismissedItems.add(id)); + entry.dismissedItems.forEach((id) => this.dismissedItems.add(id)); } liveFolder.start(); diff --git a/src/zen/live-folders/ZenLiveFoldersUI.mjs b/src/zen/live-folders/ZenLiveFoldersUI.mjs index 159e68e838..52ae01ed11 100644 --- a/src/zen/live-folders/ZenLiveFoldersUI.mjs +++ b/src/zen/live-folders/ZenLiveFoldersUI.mjs @@ -4,17 +4,22 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - ZenLiveFoldersManager: - "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", + ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", }); +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["browser/zen-live-folders.ftl"]) +); + class nsZenLiveFoldersUI { init() { const popup = window.document .getElementById("context_zenLiveFolderOptions") .querySelector("menupopup"); - popup.addEventListener("command", event => { + popup.addEventListener("command", (event) => { const option = event.target; const folderId = option.getAttribute("option-folder"); @@ -37,9 +42,7 @@ class nsZenLiveFoldersUI { } #restoreUIStateForLiveFolder(liveFolder) { - const folder = window.gZenWorkspaces.allTabGroups.find( - x => x.id === liveFolder.id - ); + const folder = window.gZenWorkspaces.allTabGroups.find((x) => x.id === liveFolder.id); if (!folder) { return; } @@ -50,9 +53,7 @@ class nsZenLiveFoldersUI { } for (const { itemId, label } of liveFolder.tabsState) { - const tab = folder.tabs.find( - t => t.getAttribute("zen-live-folder-item-id") === itemId - ); + const tab = folder.tabs.find((t) => t.getAttribute("zen-live-folder-item-id") === itemId); if (tab && label) { const tabLabel = tab.querySelector(".zen-tab-sublabel"); tab.setAttribute("zen-show-sublabel", label); @@ -74,18 +75,72 @@ class nsZenLiveFoldersUI { btn.removeAttribute("live-folder-action"); } + #createXULElement(tagName) { + return window.MozXULElement.parseXULToFragment(`<${tagName} />`).firstElementChild; + } + + #escapeXULAttribute(value) { + return String(value) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/`) + .firstElementChild; + } + return this.#createXULElement("menuitem"); + } + + async #setMenuLabel(element, option) { + element.removeAttribute("data-l10n-id"); + element.removeAttribute("data-l10n-args"); + + if (option.label && !option.l10nId) { + element.setAttribute("label", option.label); + return; + } + + if (option.l10nId) { + try { + const [message] = await lazy.l10n.formatMessages([ + { id: option.l10nId, args: option.l10nArgs ?? undefined }, + ]); + const labelAttr = message?.attributes?.find(attr => attr.name === "label"); + if (labelAttr?.value) { + element.setAttribute("label", labelAttr.value); + return; + } + } catch (ex) { + console.error("Live folder menu l10n failed:", option.l10nId, ex); + } + } + + if (option.label) { + element.setAttribute("label", option.label); + } + } + #applyMenuItemAttributes(menuItem, option, folderId) { - menuItem.setAttribute("data-l10n-id", option.l10nId); + if (option.type) { + menuItem.setAttribute("type", option.type); + } if (option.checked !== undefined) { - menuItem.setAttribute("type", option.type ?? "checkbox"); - if (option.checked === true) { + // XUL treats any present "checked" attribute as on — remove it when false. + if (option.checked) { menuItem.setAttribute("checked", "true"); + } else { + menuItem.removeAttribute("checked"); } } - if (option.l10nArgs) { - menuItem.setAttribute("data-l10n-args", JSON.stringify(option.l10nArgs)); + if (option.type === "radio" && option.key) { + menuItem.setAttribute("name", option.key); } menuItem.setAttribute("option-folder", folderId); @@ -98,27 +153,31 @@ class nsZenLiveFoldersUI { } } - #appendOptions(parentPopup, options, folderId) { + async #appendOptions(parentPopup, options, folderId) { for (const option of options) { if (option.type === "separator") { - parentPopup.appendChild(document.createXULElement("menuseparator")); + parentPopup.appendChild(this.#createXULElement("menuseparator")); continue; } if (option.options) { - const menu = document.createXULElement("menu"); + const menuFragment = window.MozXULElement.parseXULToFragment( + "" + ); + const menu = menuFragment.firstElementChild; + const subPopup = menu.querySelector("menupopup"); this.#applyMenuItemAttributes(menu, option, folderId); + await this.#setMenuLabel(menu, option); - const subPopup = document.createXULElement("menupopup"); - this.#appendOptions(subPopup, option.options, folderId); + await this.#appendOptions(subPopup, option.options, folderId); - menu.appendChild(subPopup); parentPopup.appendChild(menu); continue; } - const menuItem = document.createXULElement("menuitem"); + const menuItem = this.#createMenuItem(option); this.#applyMenuItemAttributes(menuItem, option, folderId); + await this.#setMenuLabel(menuItem, option); if (option.value !== undefined) { menuItem.setAttribute("option-value", option.value); @@ -128,10 +187,8 @@ class nsZenLiveFoldersUI { } } - buildContextMenu(folder) { - const optionsElement = document.getElementById( - "context_zenLiveFolderOptions" - ); + async buildContextMenu(folder) { + const optionsElement = window.document.getElementById("context_zenLiveFolderOptions"); let hidden = true; if (folder.isLiveFolder) { @@ -150,9 +207,8 @@ class nsZenLiveFoldersUI { intervals.push({ hours }); } - intervals = intervals.map(entry => { - const ms = - "mins" in entry ? entry.mins * MINUTE_MS : entry.hours * HOUR_MS; + intervals = intervals.map((entry) => { + const ms = "mins" in entry ? entry.mins * MINUTE_MS : entry.hours * HOUR_MS; return { l10nId: @@ -172,8 +228,7 @@ class nsZenLiveFoldersUI { const contextMenuItems = [ { key: "lastFetched", - l10nId: - liveFolder.state.lastErrorId || "zen-live-folder-last-fetched", + l10nId: liveFolder.state.lastErrorId || "zen-live-folder-last-fetched", l10nArgs: { time: this.#timeAgo(liveFolder.state.lastFetched) }, disabled: true, }, @@ -192,12 +247,12 @@ class nsZenLiveFoldersUI { popup.innerHTML = ""; - this.#appendOptions(popup, contextMenuItems, folder.id); + await this.#appendOptions(popup, contextMenuItems, folder.id); hidden = false; } optionsElement.hidden = hidden; - document.getElementById("live-folder-separator").hidden = hidden; + window.document.getElementById("live-folder-separator").hidden = hidden; } #timeAgo(date) { @@ -205,9 +260,7 @@ class nsZenLiveFoldersUI { return "-"; } - const rtf = new Intl.RelativeTimeFormat(Services.locale.appLocaleAsBCP47, { - numeric: "auto", - }); + const rtf = new Intl.RelativeTimeFormat(Services.locale.appLocaleAsBCP47, { numeric: "auto" }); const secondsDiff = (date - Date.now()) / 1000; const absSeconds = Math.abs(secondsDiff); diff --git a/src/zen/live-folders/moz.build b/src/zen/live-folders/moz.build index 37c474035f..4513d1183f 100644 --- a/src/zen/live-folders/moz.build +++ b/src/zen/live-folders/moz.build @@ -4,7 +4,9 @@ EXTRA_JS_MODULES.zen += [ "providers/GithubLiveFolder.sys.mjs", + "providers/RestAPILiveFolder.sys.mjs", "providers/RssLiveFolder.sys.mjs", + "RestLiveFolderDialog.sys.mjs", "ZenLiveFolder.sys.mjs", "ZenLiveFoldersManager.sys.mjs", ] diff --git a/src/zen/live-folders/providers/RestAPILiveFolder.sys.mjs b/src/zen/live-folders/providers/RestAPILiveFolder.sys.mjs new file mode 100644 index 0000000000..cb557601c8 --- /dev/null +++ b/src/zen/live-folders/providers/RestAPILiveFolder.sys.mjs @@ -0,0 +1,266 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const MAX_RESPONSE_SIZE = 1024 * 1024; // 1 MB per spec +const DEFAULT_MAX_ITEMS = 100; +const DEFAULT_ICON = "chrome://browser/skin/zen-icons/selectable/code.svg"; + +/** + * Resolves a dot-notation path in an object (e.g. "data.posts" -> obj.data.posts). + * Empty path returns the object itself. + * + * @param {object} obj - The root object. + * @param {string} path - Dot-separated path (e.g. "data.items", "" for root). + * @returns {unknown} The value at the path, or undefined if not found. + */ +function getByPath(obj, path) { + if (!path || typeof path !== "string") { + return obj; + } + const parts = path.trim().split(".").filter(Boolean); + let current = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") { + return undefined; + } + current = current[part]; + } + return current; +} + +/** + * Builds the final URL from a template and params. + * - Replaces {key} placeholders in the URL with params[key] (URL-encoded). + * - Params not used in the path are appended as query string. + * + * @param {string} urlTemplate - URL with optional {paramName} placeholders. + * @param {object} params - Key-value pairs for substitution and query params. + * @returns {string} The resolved URL. + */ +function buildUrl(urlTemplate, params) { + if (!params || typeof params !== "object" || Array.isArray(params)) { + return urlTemplate; + } + + const used = new Set(); + let url = urlTemplate; + + for (const [key, value] of Object.entries(params)) { + if (value == null || typeof value !== "string") { + continue; + } + const placeholder = `{${key}}`; + if (url.includes(placeholder)) { + url = url.split(placeholder).join(encodeURIComponent(value)); + used.add(key); + } + } + + const queryParams = Object.entries(params) + .filter(([k, v]) => !used.has(k) && v != null && typeof v === "string") + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + + if (queryParams.length > 0) { + const sep = url.includes("?") ? "&" : "?"; + url += sep + queryParams.join("&"); + } + + return url; +} + +/** + * URI used for favicon lookup. API subdomains (e.g. api.github.com) often have no + * favicon; use the registrable site (github.com) instead. + * + * @param {string} pageUrl - Resolved request URL. + * @returns {nsIURI} + */ +function faviconLookupUri(pageUrl) { + const uri = Services.io.newURI(pageUrl); + if (uri.host.startsWith("api.")) { + const siteHost = uri.host.slice(4); + return Services.io.newURI(`${uri.scheme}://${siteHost}/`); + } + return uri; +} + +export class nsRestAPILiveFolderProvider extends nsZenLiveFolderProvider { + static type = "rest"; + + constructor({ id, state, manager }) { + super({ id, state, manager }); + + this.state.url = state.url ?? ""; + this.state.params = + state.params && typeof state.params === "object" && !Array.isArray(state.params) + ? state.params + : {}; + this.state.mapping = state.mapping ?? { + items: "", + id: "id", + title: "title", + url: "url", + }; + this.state.label = state.label ?? ""; + this.state.icon = state.icon ?? ""; + this.state.maxItems = state.maxItems ?? DEFAULT_MAX_ITEMS; + this.state.headers = state.headers && typeof state.headers === "object" ? state.headers : {}; + } + + async fetchItems() { + try { + const url = buildUrl(this.state.url, this.state.params); + const { text } = await this.fetch(url, { + maxContentLength: MAX_RESPONSE_SIZE, + headers: this.state.headers, + }); + + let data; + try { + data = JSON.parse(text); + } catch { + return "zen-live-folder-failed-fetch"; + } + + const mapping = this.state.mapping; + const itemsPath = mapping.items ?? ""; + let items = getByPath(data, itemsPath); + + if (!Array.isArray(items)) { + if (itemsPath === "" && Array.isArray(data)) { + items = data; + } else { + return "zen-live-folder-failed-fetch"; + } + } + + const maxItems = this.state.maxItems ?? DEFAULT_MAX_ITEMS; + const mapped = items + .slice(0, maxItems) + .map((item) => { + const id = getByPath(item, mapping.id ?? "id"); + const title = getByPath(item, mapping.title ?? "title"); + const url = getByPath(item, mapping.url ?? "url"); + if (id == null || title == null || url == null) { + return null; + } + const result = { + id: String(id), + title: String(title), + url: String(url), + }; + if (mapping.subtitle) { + const subtitle = getByPath(item, mapping.subtitle); + if (subtitle != null) { + result.subtitle = String(subtitle); + } + } + return result; + }) + .filter(Boolean); + + return mapped; + } catch (error) { + console.error("Error fetching or parsing REST API:", error); + return "zen-live-folder-failed-fetch"; + } + } + + /** + * Resolves the folder icon for display. When icon is "favicon", uses Places + * (same as RSS) so the SVG folder icon gets a data: URI, not a remote .ico URL. + * + * @param {string} [icon] - Config icon value. + * @param {string} [urlTemplate] - API URL template. + * @param {object} [params] - URL params. + * @returns {Promise} + */ + static async resolveFolderIcon(icon, urlTemplate, params = {}) { + if (icon && icon !== "favicon") { + return icon; + } + if (!urlTemplate) { + return DEFAULT_ICON; + } + + let pageUrl; + try { + pageUrl = buildUrl(urlTemplate, params); + } catch { + return DEFAULT_ICON; + } + + try { + const favicon = await lazy.PlacesUtils.favicons.getFaviconForPage( + faviconLookupUri(pageUrl) + ); + if (favicon?.dataURI?.spec) { + return favicon.dataURI.spec; + } + } catch (error) { + console.error("Failed to resolve favicon for REST live folder:", error); + } + + return DEFAULT_ICON; + } + + async getMetadata() { + const icon = await nsRestAPILiveFolderProvider.resolveFolderIcon( + this.state.icon, + this.state.url, + this.state.params + ); + return { + label: this.state.label || this.state.url || "REST API", + icon, + }; + } + + get options() { + return [ + { + label: "Edit configuration...", + key: "editConfig", + }, + ]; + } + + onOptionTrigger(option) { + super.onOptionTrigger(option); + const key = option.getAttribute("option-key"); + if (key === "editConfig") { + this.#openEditConfigDialog(); + } + } + + async #openEditConfigDialog() { + const { openRestLiveFolderDialog } = ChromeUtils.importESModule( + "resource:///modules/zen/RestLiveFolderDialog.sys.mjs", + { global: "current" } + ); + await openRestLiveFolderDialog(this.manager.window, { liveFolder: this }); + } + + serialize() { + return { + state: { + ...this.state, + url: this.state.url, + params: this.state.params, + mapping: this.state.mapping, + label: this.state.label, + icon: this.state.icon, + maxItems: this.state.maxItems, + headers: this.state.headers, + }, + }; + } +} diff --git a/src/zen/live-folders/providers/RssLiveFolder.sys.mjs b/src/zen/live-folders/providers/RssLiveFolder.sys.mjs index ab99fa4adb..95367798f3 100644 --- a/src/zen/live-folders/providers/RssLiveFolder.sys.mjs +++ b/src/zen/live-folders/providers/RssLiveFolder.sys.mjs @@ -39,51 +39,38 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { const elements = doc.querySelectorAll(selector); const items = Array.from(elements) - .map(item => { + .map((item) => { const title = item.querySelector("title")?.textContent || ""; const linkNode = item.querySelector("link"); const url = - isAtom && linkNode - ? linkNode.getAttribute("href") - : linkNode?.textContent || ""; + isAtom && linkNode ? linkNode.getAttribute("href") : linkNode?.textContent || ""; const guid = item.querySelector(isAtom ? "id" : "guid")?.textContent; const id = guid || url; - const dateStr = item.querySelector( - isAtom ? "updated" : "pubDate" - )?.textContent; + const dateStr = item.querySelector(isAtom ? "updated" : "pubDate")?.textContent; const date = dateStr ? new Date(dateStr) : null; return { title, url, id, date }; }) - .filter(item => { + .filter((item) => { if (!item.url || !item.date) { return false; } - try { - const parsed = Services.io.newURI(item.url); - if (parsed.scheme !== "http" && parsed.scheme !== "https") { - return false; - } - } catch { - return false; - } if (!this.state.timeRange) { return true; } - return ( - !isNaN(item.date.getTime()) && item.date.getTime() >= cutoffTime - ); + return !isNaN(item.date.getTime()) && item.date.getTime() >= cutoffTime; }) .slice(0, this.state.maxItems); for (let item of items) { if (item.url) { try { - const url = Services.io.newURI(item.url); - const favicon = - await lazy.PlacesUtils.favicons.getFaviconForPage(url); + const url = new URL(item.url); + const favicon = await lazy.PlacesUtils.favicons.getFaviconForPage( + Services.io.newURI(url.href) + ); item.icon = favicon?.dataURI.spec || this.manager.window.gZenEmojiPicker.getSVGURL("logo-rss.svg"); @@ -112,7 +99,7 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { _buildItemLimitOptions() { const entries = [5, 10, 25, 50]; - return entries.map(entry => { + return entries.map((entry) => { return this._buildRadioOption({ key: "maxItems", value: entry, @@ -141,15 +128,13 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { l10nId: "zen-live-folder-time-range-all-time", }), { type: "separator" }, - ...entries.map(entry => { + ...entries.map((entry) => { const isDays = "days" in entry; return this._buildRadioOption({ key: "timeRange", value: entry.ms, - l10nId: isDays - ? "zen-live-folder-time-range-days" - : "zen-live-folder-time-range-hours", + l10nId: isDays ? "zen-live-folder-time-range-days" : "zen-live-folder-time-range-hours", l10nArgs: isDays ? { days: entry.days } : { hours: entry.hours }, }); }), @@ -180,10 +165,7 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { try { const response = await fetch(url); if (!response.ok) { - return { - label: "", - icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg"), - }; + return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") }; } const text = await response.text(); @@ -193,20 +175,14 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { const title = ( isAtom ? doc.querySelector("feed > title")?.textContent - : doc.querySelector("rss > channel > title, channel > title") - ?.textContent + : doc.querySelector("rss > channel > title, channel > title")?.textContent )?.trim(); const linkNode = isAtom - ? doc.querySelector( - "feed > link[rel='alternate'][href], feed > link[href]" - ) + ? doc.querySelector("feed > link[rel='alternate'][href], feed > link[href]") : doc.querySelector("rss > channel > link, channel > link"); const feedLink = - (isAtom - ? linkNode?.getAttribute("href") - : linkNode?.textContent - )?.trim() || ""; + (isAtom ? linkNode?.getAttribute("href") : linkNode?.textContent)?.trim() || ""; const faviconPageUrl = feedLink ? new URL(feedLink, url).href : url; let favicon = await lazy.PlacesUtils.favicons.getFaviconForPage( @@ -215,23 +191,16 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { return { label: title || "", - icon: - favicon?.dataURI.spec || - window.gZenEmojiPicker.getSVGURL("logo-rss.svg"), + icon: favicon?.dataURI.spec || window.gZenEmojiPicker.getSVGURL("logo-rss.svg"), }; } catch (e) { - return { - label: "", - icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg"), - }; + return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") }; } } static async promptForFeedUrl(window, initialUrl = "") { const input = { value: initialUrl }; - const [prompt] = await lazy.l10n.formatValues([ - "zen-live-folder-rss-prompt-feed-url", - ]); + const [prompt] = await lazy.l10n.formatValues(["zen-live-folder-rss-prompt-feed-url"]); const promptOk = Services.prompt.prompt(window, prompt, null, input, null, { value: null, }); @@ -259,10 +228,7 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { } async getMetadata() { - return nsRssLiveFolderProvider.getMetadata( - this.state.url, - this.manager.window - ); + return nsRssLiveFolderProvider.getMetadata(this.state.url, this.manager.window); } async onOptionTrigger(option) { @@ -271,7 +237,7 @@ export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider { const key = option.getAttribute("option-key"); const value = option.getAttribute("option-value"); - if (!this.options.some(x => x.key === key)) { + if (!this.options.some((x) => x.key === key)) { return; }