您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Convert address/coords in focused fields into osmand.net/map pin links (split pill + hotkey). No site-specific code.
// ==UserScript== // @name OsmAnd link helper (generic, pin links) // @namespace jasper-tools // @version 1.5.3 // @description Convert address/coords in focused fields into osmand.net/map pin links (split pill + hotkey). No site-specific code. // @license GPL-3.0-or-later // @author Jasper Aorangi // @match https://ksuite.infomaniak.com/*/calendar/* // @match https://calendar.google.com/calendar/* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect nominatim.openstreetmap.org // ==/UserScript== /*! * OsmAnd Link Helper * Copyright (C) 2025 Jasper Aorangi * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * SPDX-License-Identifier: GPL-3.0-or-later */ (() => { // ===================== SETTINGS ===================== const SETTINGS = { linkType: "map", // "map" | "go" (map → ?pin=LAT,LON#ZOOM/LAT/LON) zoom: 17, // 15=city, 17=street, 18–19=building decimals: 6, // coord precision in URL limit: 5, // max geocoder choices in picker countrycodes: "", // e.g. "us,nz,gb" to bias results keepOriginalInClipboard: true, debug: true, // extra console logging // ---- Hotkey (configurable) ---- // Default = Alt+O hotkey: { alt: true, ctrl: false, meta: false, shift: false, key: "o" }, // ---- append options ---- // "none" → replace with just the link // "link" → keep original text, newline + link // "address_and_link" → address, newline + link // "all" → keep original, newline + address, newline + link // ---- hotkey mode ---- appendMode: "link", // ---- split pill modes (LEFT / RIGHT halves) ---- appendModeLeft: "none", appendModeRight: "address_and_link", useGeocoderAddressForAppend: true, // use geocoder's display_name for the address line addressLabel: "", // e.g. "Address: " linkLabel: "", // e.g. "OsmAnd: " newlineReplacementInSingleLine: " — ", // <input> can't display "\n" }; // Put this near the top-level (under SETTINGS is fine): let actionLock = false; // ===================== LOGGING ====================== const LOG_TAG = "%c[OsmAnd]"; const LOG_STYLE = "color:#0a84ff;font-weight:bold"; const log = (...a) => { if (!SETTINGS.debug) return; try { console.log(LOG_TAG, LOG_STYLE, ...a); } catch(_) {} }; // Match a keyboard event against SETTINGS.hotkey const matchesHotkey = (e, hk = SETTINGS.hotkey) => { if (!hk) return false; const wantKey = String(hk.key || "").toLowerCase(); const gotKey = String(e.key || "").toLowerCase(); return (!!hk.alt === !!e.altKey) && (!!hk.ctrl === !!e.ctrlKey) && (!!hk.meta === !!e.metaKey) && (!!hk.shift === !!e.shiftKey) && (wantKey === gotKey); }; // Temporarily switch append mode for the duration of an action const withAppendMode = async (mode, fn) => { const prev = SETTINGS.appendMode; SETTINGS.appendMode = mode; try { await fn(); } finally { SETTINGS.appendMode = prev; } }; // ===================== UTILS ======================== const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const visible = (el) => !!(el && el.getClientRects().length && getComputedStyle(el).visibility !== "hidden" && getComputedStyle(el).display !== "none"); const escapeHtml = (s)=>String(s).replace(/[&<>"']/g,c=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); // apply \n policy for single-line inputs const applyNewlinePolicy = (el, text) => (el?.tagName?.toLowerCase() === "input") ? String(text).replace(/\r?\n/g, SETTINGS.newlineReplacementInSingleLine) : String(text); // ===================== TOASTS / PICKER ============== const toast = (msg, ok=true) => { const t = document.createElement("div"); t.textContent = msg; Object.assign(t.style,{ position:"fixed",left:"50%",transform:"translateX(-50%)",bottom:"24px", padding:"10px 14px",borderRadius:"10px",background:ok?"#0a0a0a":"#7a1d1d", color:"#fff",fontSize:"12px",zIndex:2147483647,boxShadow:"0 6px 20px rgba(0,0,0,.25)", maxWidth:"80%",textAlign:"center",whiteSpace:"pre-wrap",pointerEvents:"none" }); document.body.appendChild(t); setTimeout(()=>t.remove(),2500); }; // NOTE: Added third parameter onCancel; clicking scrim/Cancel now resolves the awaiting Promise. const picker = (results,onPick,onCancel)=>{ const wrap=document.createElement("div"); Object.assign(wrap.style,{ position:"fixed",left:"50%",top:"20%",transform:"translateX(-50%)", background:"#fff",color:"#111",border:"1px solid #ccc",borderRadius:"12px", zIndex:2147483647,maxWidth:"640px",width:"90%",boxShadow:"0 10px 30px rgba(0,0,0,.25)",overflow:"hidden" }); const head=document.createElement("div"); head.textContent="Select a location"; Object.assign(head.style,{padding:"10px 14px",fontWeight:"600",borderBottom:"1px solid #eee"}); const list=document.createElement("div"); results.forEach((r,idx)=>{ const item=document.createElement("button"); item.type="button"; item.innerHTML=`<div style="font-size:13px;text-align:left;"> <div style="font-weight:600">${escapeHtml(r.display_name||"Unnamed")}</div> ${r.lat&&r.lon?`<div style="opacity:.7">${r.lat}, ${r.lon}</div>`:""} </div>`; Object.assign(item.style,{width:"100%",padding:"10px 14px",border:"0",borderBottom:"1px solid #f0f0f0",background:"#fff",cursor:"pointer"}); item.addEventListener("mouseover",()=>item.style.background="#fafafa"); item.addEventListener("mouseout",()=>item.style.background="#fff"); item.addEventListener("click",()=>{ cleanup(); onPick(r,idx); }); list.appendChild(item); }); const foot=document.createElement("div"); Object.assign(foot.style,{padding:"8px 14px",display:"flex",gap:"8px",justifyContent:"flex-end"}); const cancel=document.createElement("button"); cancel.textContent="Cancel"; Object.assign(cancel.style,{padding:"6px 10px",borderRadius:"8px",border:"1px solid #ccc",background:"#fff",cursor:"pointer"}); cancel.onclick=()=>{ cleanup(); onCancel && onCancel(); }; foot.appendChild(cancel); wrap.append(head,list,foot); const scrim=document.createElement("div"); Object.assign(scrim.style,{position:"fixed",inset:"0",background:"rgba(0,0,0,.25)",zIndex:2147483646}); scrim.addEventListener("click",()=>{ cleanup(); onCancel && onCancel(); }); const cleanup=()=>{wrap.remove();scrim.remove();}; document.body.append(scrim,wrap); }; // ===================== FIELD HELPERS ================= const isEditable=(el)=>{ if(!el||!(el instanceof Element)) return false; const tag=el.tagName?.toLowerCase(); if(tag==="input"||tag==="textarea") return !el.disabled&&!el.readOnly; if(el.isContentEditable) return true; const role=el.getAttribute("role"); return role==="textbox"||role==="combobox"; }; const getEditableRoot=(el)=>{ let cur=el; for(let i=0;i<7&&cur;i++){ if(isEditable(cur)) return cur; const ce=cur.querySelector?.("[contenteditable='true'],[contenteditable='']"); if(ce) return ce; cur=cur.parentElement; } return null; }; const getText=(el)=>{ const tag=el.tagName?.toLowerCase(); if(tag==="input"||tag==="textarea") return el.value||""; if(el.isContentEditable||el.getAttribute("role")==="textbox") return (el.innerText||el.textContent||"").trim(); return ""; }; // Insert for rich editors const insertTextRich = (el, text) => { try{ el.focus(); document.execCommand("selectAll", false, null); const ok=document.execCommand("insertText", false, text); if(ok) return true; }catch(_){} try{ el.focus(); const ev1=new InputEvent("beforeinput",{bubbles:true,cancelable:true,data:text,inputType:"insertFromPaste"}); el.dispatchEvent(ev1); const ev2=new InputEvent("input",{bubbles:true,data:text,inputType:"insertFromPaste"}); el.dispatchEvent(ev2); el.textContent=text; el.dispatchEvent(new Event("change",{bubbles:true})); el.dispatchEvent(new Event("blur",{bubbles:true})); return true; }catch(_){} return false; }; const setTextGeneric=(el,text)=>{ const finalText = applyNewlinePolicy(el, text); // normalize newlines for <input> if(SETTINGS.keepOriginalInClipboard){ const prev=getText(el); if(prev) navigator.clipboard?.writeText(prev).catch(()=>{}); } const tag=el.tagName?.toLowerCase(); if(tag==="input"||tag==="textarea"){ el.value=finalText; el.dispatchEvent(new Event("input",{bubbles:true})); el.dispatchEvent(new Event("change",{bubbles:true})); el.dispatchEvent(new Event("blur",{bubbles:true})); return true; } if(el.isContentEditable||el.getAttribute("role")==="textbox"){ return insertTextRich(el, finalText); } const ce=el.querySelector?.("[contenteditable='true'],[contenteditable='']"); if(ce) return insertTextRich(ce, finalText); return false; }; // ===================== URL BUILDING ================== const fmt = (n) => Number(n).toFixed(SETTINGS.decimals); const buildUrl=(lat,lon)=>{ const z=SETTINGS.zoom; if(SETTINGS.linkType==="map"){ return `https://osmand.net/map?pin=${fmt(lat)},${fmt(lon)}#${z}/${fmt(lat)}/${fmt(lon)}`; } return `https://osmand.net/go.html?lat=${fmt(lat)}&lon=${fmt(lon)}&z=${z}`; }; const parseLatLon=(s)=>{ const m=String(s).trim().match(/(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)/); return m?{lat:m[1],lon:m[2]}:null; }; // ---- compose final text with append options ---- const composeFinal = ({ original, url, addressText }) => { const nl = "\n"; const addrLine = (SETTINGS.addressLabel || "") + (addressText || ""); const linkLine = (SETTINGS.linkLabel || "") + url; switch (SETTINGS.appendMode) { case "none": return url; case "link": return original ? `${original}${nl}${linkLine}` : linkLine; case "address_and_link": return addressText ? `${addrLine}${nl}${linkLine}` : linkLine; case "all": if (original) return addressText ? `${original}${nl}${addrLine}${nl}${linkLine}` : `${original}${nl}${linkLine}`; return addressText ? `${addrLine}${nl}${linkLine}` : linkLine; default: return url; } }; // ===================== NETWORK ====================== const getJSON=(url)=>new Promise((resolve,reject)=>{ if(typeof GM_xmlhttpRequest==="function"){ GM_xmlhttpRequest({ method:"GET",url,headers:{Accept:"application/json","Accept-Language":navigator.language||"en"}, onload:(res)=>{ if(res.status>=200&&res.status<300){ try{resolve(JSON.parse(res.responseText));}catch(e){reject(e);} } else reject(new Error(`HTTP ${res.status}`)); }, onerror:()=>reject(new Error("Network error (GM)")), ontimeout:()=>reject(new Error("Network timeout (GM)")) }); } else { fetch(url,{headers:{Accept:"application/json","Accept-Language":navigator.language||"en"}}) .then(r=>r.ok?r.json():Promise.reject(new Error(`HTTP ${r.status}`))).then(resolve,reject); } }); const geocode = async (q) => { const base = "https://nominatim.openstreetmap.org/search"; const params = new URLSearchParams({ format:"jsonv2", limit:String(SETTINGS.limit||5), q }); if(SETTINGS.countrycodes) params.set("countrycodes", SETTINGS.countrycodes); const url = `${base}?${params.toString()}`; log("Geocode URL:", url); const data = await getJSON(url); log("Geocode results:", data); return data; }; // ===================== FLOATING PILL ================= const btn=document.createElement("button"); btn.textContent="→ OsmAnd"; Object.assign(btn.style,{ position:"fixed",zIndex:2147483647,padding:"6px 10px",borderRadius:"999px", border:"1px solid #888",background:"#fff",boxShadow:"0 2px 8px rgba(0,0,0,.15)",fontSize:"12px", display:"none",cursor:"pointer",userSelect:"none",pointerEvents:"auto" }); // Keep contents inside the rounded pill btn.style.overflow = "hidden"; btn.style.backgroundClip = "padding-box"; // Base color (left half) btn.style.backgroundColor = "#fff"; // Two layered gradients: // - top layer: a 2px vertical divider at 50% // - bottom layer: right half tinted (≈25% grey) btn.style.backgroundImage = [ "linear-gradient(to right," + "transparent 0, transparent calc(50% - 1px)," + "#bdbdbd calc(50% - 1px), #bdbdbd calc(50% + 1px)," + // divider "transparent calc(50% + 1px), transparent 100%)", "linear-gradient(to right," + "#fff 0, #fff 50%," + "rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.25) 100%)" // right-half tint ].join(", "); btn.style.backgroundRepeat = "no-repeat, no-repeat"; btn.style.backgroundSize = "100% 100%, 100% 100%"; btn.style.backgroundPosition = "0 0, 0 0"; btn.style.setProperty("pointer-events","auto","important"); btn.style.setProperty("z-index","2147483647","important"); document.documentElement.appendChild(btn); let lastEditable=null, btnVisible=false; const placeBtnNear=(el)=>{ const r=el.getBoundingClientRect(); const top=Math.max(8, r.bottom+6); const left=Math.min(window.innerWidth-120, r.right-100); btn.style.top=`${top+window.scrollY}px`; btn.style.left=`${left+window.scrollX}px`; }; const showBtn=(el)=>{ lastEditable=el; placeBtnNear(el); btn.style.display="block"; btnVisible=true; }; const hideBtn=()=>{ btn.style.display="none"; btnVisible=false; }; document.addEventListener("focusin",(e)=>{ const root=getEditableRoot(e.target) || e.target; if(root) showBtn(root); else hideBtn(); }, true); document.addEventListener("scroll",()=>{ if(btnVisible && lastEditable) placeBtnNear(lastEditable); }, true); // ===================== ACTION FLOW =================== const buildAndInsert = async (target, text, originalText) => { // 1) Coordinates passthrough const coord=parseLatLon(text); let url; if (coord) { url = buildUrl(coord.lat, coord.lon); log("Coord passthrough → URL:", url); const finalText = composeFinal({ original: originalText, url, addressText: SETTINGS.useGeocoderAddressForAppend ? null : text }); if (!setTextGeneric(target, finalText)) await navigator.clipboard?.writeText(finalText); toast("Converted coordinates → OsmAnd link"); return; } // 2) Geocode text toast("Looking up address…"); const results = await geocode(text); if(!Array.isArray(results)||results.length===0){ toast("No geocoding result.", false); return; } const pickOne = async (r) => { url = buildUrl(r.lat, r.lon); log("Chosen result:", r, "URL:", url); const addr = SETTINGS.useGeocoderAddressForAppend ? (r.display_name || text) : text; const finalText = composeFinal({ original: originalText, url, addressText: addr }); if (!setTextGeneric(target, finalText)) await navigator.clipboard?.writeText(finalText); toast(`Set link for:\n${addr}`); }; if (results.length === 1) return pickOne(results[0]); // NOTE: Resolve on cancel/close so the action lock always releases return new Promise((resolve)=>picker( results.slice(0, SETTINGS.limit||5), async (r)=>{ await pickOne(r); resolve(); }, ()=>resolve() )); }; const handleAction = async () => { if (actionLock) { log("handleAction ignored (already running)"); return; } actionLock = true; // Optional: visually disable the pill while we work const oldTxt = btn.textContent; btn.textContent = "…"; btn.style.opacity = "0.6"; btn.style.pointerEvents = "none"; try { const target = lastEditable || getEditableRoot(document.activeElement) || document.activeElement; if(!target) { toast("No editable field focused.", false); return; } const originalText = getText(target); let text = (originalText || "").trim(); if(!text){ text = prompt("Address to geocode for OsmAnd:"); if(!text) return; } log("Triggered on target:", target, "Initial text:", text); await buildAndInsert(target, text, originalText); } catch (err) { log("handleAction error:", err); toast(`Failed: ${err.message}`, false); } finally { actionLock = false; btn.textContent = oldTxt; btn.style.opacity = ""; btn.style.pointerEvents = "auto"; } }; // Split pill click handler (left/right half → different append modes) const handleSplitPillClick = (e) => { const rect = btn.getBoundingClientRect(); const leftHalf = (e.clientX - rect.left) <= (rect.width / 2); const chosenMode = leftHalf ? (SETTINGS.appendModeLeft || "none") : (SETTINGS.appendModeRight || "address_and_link"); e.preventDefault(); e.stopPropagation(); withAppendMode(chosenMode, handleAction); }; ["pointerdown","mousedown","click"].forEach(ev=>{ btn.addEventListener(ev, (e)=>handleSplitPillClick(e), true); }); // Hotkey (uses SETTINGS.hotkey) -- continues to use SETTINGS.appendMode document.addEventListener("keydown", (e) => { if (matchesHotkey(e)) { if (actionLock) return; e.preventDefault(); handleAction(); } }); log("Ready. Focus any editable field, then click the pill (left/right) or press the hotkey."); })();